Merge branch 'main' into fix/workspace-git-init-skip
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Harden the workspace per-repo land loop against partial-failure races. A lost `landedSha` DB write after a sub-repo's integration ref already advanced no longer silently continues — it escalates to a retryable partial-land error, and the landed predicate now recognizes an already-landed repo via its `Fusion-Task-Id` trailer on retry, so a re-run never produces a second squash commit. The land lease is now taskId-aware across registry kinds: a merging task can no longer clobber an executing task's acquire lease on a shared sub-repo (any foreign-task holder is treated as contention), and the active-session registry rejects foreign-task overwrites instead of silently clobbering. The transient `merging` status is always reset before any throw escapes the land loop (no stuck-`merging` leak), and finalize re-reads the latest task and no longer swallows the merge-details persist failure (no finalizing on a stale row).
|
||||
|
||||
Harden the workspace merge dispatch and user-facing merge doors. The partial-land retry catch now fails closed when the task row can't be read (DB outage no longer triggers an indefinite retry storm). The merge-confirmed reachability fast-path skips workspace tasks (whose recorded commitSha lives in a sub-repo, not the workspace root) so a fully-landed workspace task is no longer demoted/parked. The dashboard and CLI merge doors now report `merged: true` (and `mergeConfirmed`/`commitSha`) when a workspace fully lands, mirroring the engine result. Transient sub-repo land-lease contention (`WorkspaceRepoLandBusyError`) is re-enqueued with capped backoff on a separate bounded counter instead of burning the merge-retry quota, so pure contention can't park a never-failed task. Retry backoff is capped at 60s.
|
||||
5
.changeset/fix-workspace-phase-c-review-round-2.md
Normal file
5
.changeset/fix-workspace-phase-c-review-round-2.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Address Phase C workspace merge-loop review feedback. A sub-repo recognized as already-landed via the `Fusion-Task-Id` trailer fallback (when its `landedSha` persist was lost) now resolves and re-records a concrete `landedSha`, so finalize no longer drops it and mis-reports a fully-landed workspace task as a no-op (`mergeConfirmed:false`). A manual merge that hits sub-repo land-lease contention now surfaces the busy error to the user without consuming the persisted `mergeRetries` quota (matching the auto path's separate busy counter). The partial-land retry persists the incremented retry count before arming the backoff timer — a failed write now fails closed instead of looping without consuming budget — and clears the stale busy-contention counter when a real partial land supersedes transient busy failures. The CLI and dashboard merge doors use the shared `isWorkspaceTask` predicate instead of re-inlining the workspace check, and integration-branch shell interpolation in base-commit capture uses POSIX single-quote escaping.
|
||||
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts.
|
||||
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving.
|
||||
5
.changeset/merger-unification-runaimerge-sole-path.md
Normal file
5
.changeset/merger-unification-runaimerge-sole-path.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Merger unification (master-plan U0): `runAiMerge` (the FN-5633 clean-room AI merge path) is now the **sole** merge path. The engine dispatch, the `fn task merge` CLI command, and the UI-only (`--no-engine`) dashboard merge all route through `runAiMerge`; the legacy `aiMergeTask` pipeline is soft-deprecated (body retained, `@deprecated`). The `merger.mode` setting is now **inert and deprecated** — the type and field are retained as published surface, but the `"deterministic"` value no longer selects a different pipeline; observing it logs a one-time deprecation warning and proceeds via the unified AI merge path. A new shared `assertNotWorkspaceTaskMerge` guard rejects workspace-mode tasks (populated `workspaceWorktrees`) at every merge entry point with a clear error until per-repo merge support (master-plan U6) lands.
|
||||
7
.changeset/retire-optional-steps-declaration.md
Normal file
7
.changeset/retire-optional-steps-declaration.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": major
|
||||
---
|
||||
|
||||
**Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`.
|
||||
|
||||
Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`).
|
||||
5
.changeset/session-routing-headers.md
Normal file
5
.changeset/session-routing-headers.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add `X-Session-Id` and `X-Session-Affinity` request headers to all LLM chat completion requests. These let LLM gateways sticky-route consecutive requests from the same conversation to the same backend, and let observability tools (Langfuse, Arize, etc.) group the otherwise-stateless API calls of a session into a single multi-turn trace. Both headers carry the same stable identifier — the task id when available (stable across pause/resume), otherwise the pi session id. (#1675)
|
||||
5
.changeset/workflow-node-help.md
Normal file
5
.changeset/workflow-node-help.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workflow editor: add a Help section to the node detail pane. Every node now documents what it does, how to configure it, and its inputs/outputs/edges — including the engine-managed merge-lifecycle nodes (auto-merge gate, branch-group member integration, branch-group promotion, PR and recovery nodes), which are surfaced read-only with an "Engine-managed" badge.
|
||||
5
.changeset/workflow-optional-group-subgraphs.md
Normal file
5
.changeset/workflow-optional-group-subgraphs.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. Optional-group enable resolution correctly handles id collisions with add-on template ids, so a group's enable state is not silently bypassed during task creation/update. (The legacy declaration-based optional-steps model is retired in a sibling changeset; only the `workflow-step` seam infrastructure removal remains a follow-up.)
|
||||
8
.changeset/workspace-dashboard-floor.md
Normal file
8
.changeset/workspace-dashboard-floor.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace tasks no longer render blank in the dashboard. Task cards and the task
|
||||
detail view now surface a workspace task's acquired per-sub-repo worktrees as a
|
||||
read-only "N repos acquired" placeholder and flat repo → worktree/branch list,
|
||||
instead of an empty branch area (no `task.worktree`/`task.branch`).
|
||||
5
.changeset/workspace-per-repo-acquisition-hardening.md
Normal file
5
.changeset/workspace-per-repo-acquisition-hardening.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode (Phase A / U2): harden per-repo worktree acquisition. Each sub-repo worktree now gets the task identity guard installed (single-repo parity), a per-repo base commit SHA captured local-first against that sub-repo's resolved integration branch (shared `integrationBranch` override stripped so each repo falls through to its own `origin/HEAD`), and same-sub-repo acquisition exclusivity registered in the path-keyed active-session registry. Re-acquiring an already-acquired `(taskId, repo)` is idempotent, and acquisition failures surface an error plus an audit event instead of silently stalling.
|
||||
5
.changeset/workspace-per-repo-land-lease.md
Normal file
5
.changeset/workspace-per-repo-land-lease.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode (Phase C U3): serialize concurrent same-sub-repo lands with a per-repo file-scope lease. When two workspace tasks try to land onto the SAME sub-repo's local integration ref at the same time, the merge phase now registers the sub-repo's absolute path in the path-keyed active-session registry under a distinct `workspace-repo-land` kind before each land and releases it in a `finally` (on land success or failure — no stuck lock). A second task contending for the same sub-repo fast-fails with a retryable `WorkspaceRepoLandBusyError`, which the existing partial-land auto-retry-then-park dispatch handles (consume a `mergeRetry`, re-enqueue with backoff, then operator-park). Disjoint sub-repos lease different paths and never serialize against each other. The lease prevents clean-room ai-merge worktree collisions; ref correctness is already guaranteed by `advanceIntegrationBranchRef`'s CAS (concurrent-advance → rebuild).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode Phase A (U1): executor session scoping. In workspace mode the executor now skips the root worktree acquisition and every rootDir git preflight (base-commit capture, contamination, worktree-liveness), runs the agent session rooted at the browse-only workspace root, and tracks acquired sub-repo worktrees as a per-task set. Single-repo tasks are unchanged (one-element set, byte-for-byte preflight parity).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode (Phase B, U1): per-repo post-session change capture, contamination detection, and worktree-invariant verification. In workspace mode the executor now loops `task.workspaceWorktrees`, reusing `captureModifiedFiles` per sub-repo (diffing each against its own `baseCommitSha`, with a merge-base fallback when undefined) to aggregate repo-prefixed `task.modifiedFiles` and surface per-repo contamination, and un-stubs `verifyWorktreeInvariants` to assert each acquired worktree's git toplevel and `fusion/<id>` branch. Single-repo behavior is unchanged.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged.
|
||||
|
||||
Phase-B hardening: the per-repo scope-leak guard now fails CLOSED — a thrown capture/diff error in any sub-repo refuses `fn_task_done` (naming the repo) instead of failing open, and a scoped task that acquired zero sub-repo worktrees is blocked rather than silently passing. A legitimate per-repo `.changeset/` file is no longer falsely flagged off-scope (the always-allowed carve-out now runs against the repo-local path). Per-repo review stops at the first non-APPROVE sub-repo so a later repo's reviewer error can't mask an already-determined REVISE/RETHINK. Per-repo capture failures are isolated (one repo's error no longer drops the whole modified-files write), and the reported offending/failing repo is now deterministic (sorted repo iteration). Single-repo behavior remains unchanged.
|
||||
12
.changeset/workspace-phase-c-u1-per-repo-merge-loop.md
Normal file
12
.changeset/workspace-phase-c-u1-per-repo-merge-loop.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the
|
||||
`runAiMerge` clean-room land closure (single-repo behavior unchanged) and add
|
||||
`landWorkspaceTask`, which lands each acquired sub-repo's `fusion/<id>` branch onto
|
||||
that repo's OWN local integration ref (re-resolved per repo with overrides stripped),
|
||||
land-as-you-go with no remote push. The engine merge dispatch and the user-facing
|
||||
CLI/dashboard merge doors now route workspace tasks through this loop instead of
|
||||
throwing; `store.mergeTask`, `aiMergeTask`, and the `runAiMerge` chokepoint keep
|
||||
throwing `WorkspaceTaskMergeError` as defense-in-depth.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent
|
||||
auto-retry-then-park. `landWorkspaceTask` now records each sub-repo's `landedSha` after
|
||||
its branch advances that repo's local integration ref, and on a re-run SKIPS any repo
|
||||
whose recorded `landedSha` is an ancestor of (or equals) its current integration tip — so
|
||||
an interrupted multi-repo land retries only the un-landed repos and never re-advances an
|
||||
already-landed ref. When every acquired repo's landed predicate holds, the task moves to
|
||||
`done` EXACTLY ONCE via the task-global finalize path with an aggregate `mergeDetails`
|
||||
(representative `commitSha` + a `workspaceLandedShas` map). A partial land (some repos
|
||||
unlanded) does not move the task done; the engine merge dispatch surfaces it as a
|
||||
retryable failure that consumes a `mergeRetry` and auto-retries the merge (skipping landed
|
||||
repos) up to the configured max, then operator-parks the task as failed.
|
||||
9
.changeset/workspace-phase-d-self-healing.md
Normal file
9
.changeset/workspace-phase-d-self-healing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`.
|
||||
|
||||
Phase D P1 TOCTOU fix (merge-queue dispatch blind spot): the workspace partial-land and phantom-land-lease reconcilers now consult a new `ProjectEngine.isMergePending(taskId)` seam (true if the task is in the engine's in-memory `mergeQueue` or `mergeActive`). This closes the dequeue→rawMerge window where a workspace task is being merged but no other liveness signal fires yet (the id is shifted out of `mergeQueue` while `activeMergeTaskId` / `merging` status / the `workspace-repo-land` lease are not yet set inside `landWorkspaceTask`). The partial-land reconciler skips a merge-pending candidate (emitting `task:reconcile-workspace-partial-land-no-action` with reason `merge-pending`) instead of launching a second concurrent `landWorkspaceTask` (double-squash risk, since a same-task land lease is not contention), and lease reclaim leaves a merge-pending owner's not-yet-registered lease alone. Wired via `InProcessRuntime.setMergePendingProvider`; undefined (unwired) is treated as not-pending so existing guards still apply.
|
||||
|
||||
Phase D review hardening: every single-commit-finalize self-healing site is now workspace-gated so a partial-landed workspace task can never be marked fully merged on one repo's commit — `recoverStuckMergeDeadlocks` (the twin of recoverInterruptedMergingTasks), `recoverOrphanOnlyScopeViolations`, `recoverAlreadyMergedReviewTasks`, `recoverBranchMisboundInReviewTasks`, and `recoverDoneTaskMergeMetadata` all skip workspace tasks and defer recovery to the workspace partial-land reconciler. The partial-land reconciler now bounds its `enqueueMerge` re-enqueue (parks `failed` after repeated queue rejections instead of looping forever) and treats a branch-gone-and-not-landed sub-repo as unrecoverable even when a stale unreachable `landedSha` is present. Phantom land-lease reclaim now only reclaims a demonstrably TERMINAL owner (never an `in-progress` executing task that registered its lease early). Orphan per-repo worktree removal failures are now engine-logged and retry-bounded. The canonical `isRepoLanded` predicate moved to a new dependency-free `workspace-land-predicate` module, dissolving the self-healing ↔ merger-ai import cycle (public export preserved).
|
||||
@@ -195,6 +195,9 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
- FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs.
|
||||
- FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression.
|
||||
- FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move.
|
||||
- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/<id>` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move.
|
||||
- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched).
|
||||
- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk).
|
||||
|
||||
|
||||
## Reference docs (deeper detail)
|
||||
|
||||
@@ -61,6 +61,10 @@ sub-directories. Fusion discovers sub-repos at init time and records them in
|
||||
single root-level worktree; instead, the agent acquires per-repo worktrees
|
||||
on demand via `fn_acquire_repo_worktree`.
|
||||
|
||||
Workspace-task merges are **non-atomic**: each sub-repo lands on its own local
|
||||
integration ref independently, so a partial-land window (some sub-repos merged,
|
||||
others not) is possible mid-task — this state is local and operator-resettable.
|
||||
|
||||
### Project Identity
|
||||
The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement.
|
||||
|
||||
@@ -260,6 +264,11 @@ A workflow graph node that reads a declared Artifact and runs a registry parser
|
||||
### 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.
|
||||
|
||||
### Optional step group
|
||||
A workflow graph container node (alongside `foreach`/`loop`) whose template subgraph runs once when a task has enabled it and is bypassed otherwise — the graph-native way to make a step optional per task. Enablement is a per-task toggle set seeded from the group's workflow-level default; the group's own node id is the toggle key. It replaces the earlier execution-inert *declaration* model (a separate optional-step list run through a hidden seam), so optional steps are now real, placeable nodes rather than an out-of-graph facet.
|
||||
|
||||
Single pass — no iteration or rework inside the template (this is what distinguishes it from `foreach`/`loop`). Because the toggle key is the node id, renaming or recreating a group resets its per-task enablement; and because that id may deliberately equal a built-in step-template id, the per-task enable set must keep group ids identity-stable rather than round-tripping them through legacy step-template materialization (which would remap the key and silently bypass the group).
|
||||
|
||||
## Persistence & migrations
|
||||
|
||||
### Schema-Version Sweep
|
||||
|
||||
@@ -866,6 +866,9 @@ Features:
|
||||
- Start, stop, and restart the current server session
|
||||
- Manage preview URLs with embedded preview and **Open in new tab** fallback
|
||||
- Tail live logs, load older history, and refresh session status
|
||||
- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar.
|
||||
|
||||
<!-- FNXC:DevServerDocs 2026-06-23-00:00: The narrow right-sidebar Dev Server host must describe the preview modal launcher so users do not expect the preview iframe to remain inline when the dock is too constrained for logs and preview together. -->
|
||||
|
||||
For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
---
|
||||
title: "feat: Optional-group container nodes + add-ons as insertable subgraphs"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: feat
|
||||
plan_id: 2026-06-21-002-feat-workflow-optional-group-subgraphs
|
||||
---
|
||||
|
||||
# feat: Optional-group container nodes + add-ons as insertable subgraphs
|
||||
|
||||
## Summary
|
||||
|
||||
Today "optional steps" are **execution-inert declarations**: a workflow lists `optionalSteps:
|
||||
[{ templateId, defaultOn }]`, the create/edit UI seeds a per-task `enabledWorkflowSteps` set, and a
|
||||
single hidden `workflow-step` seam node runs every enabled step *after* the graph finishes. Nothing about
|
||||
optionality is visible in the graph, and the steps cannot be placed, ordered, or composed.
|
||||
|
||||
This plan makes optionality **graph-native**. A new `optional-group` container node — modeled on the
|
||||
existing `foreach`/`loop` container nodes — holds a `template:{ nodes, edges }` subgraph. The graph
|
||||
executor runs that subgraph **once** when the group is enabled for the task and **passes through
|
||||
(skips)** it when disabled. Enable state reuses the existing per-task `enabledWorkflowSteps` facet plus a
|
||||
workflow-level `defaultOn`, keyed by the group. Separately, every prior pre-workflow **add-on** (the seven
|
||||
`WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review,
|
||||
accessibility-check, browser-verification, frontend-ux-design) becomes **insertable from the editor's
|
||||
template palette as a node subgraph**, and can be inserted already wrapped in an `optional-group` so an
|
||||
author can drop in "Security Audit (optional)" in one action.
|
||||
|
||||
Finally, the plan **replaces** the declaration-based system per the confirmed scope decision: the built-in
|
||||
**coding** and **stepwise-coding** workflows migrate `browser-verification` onto an `optional-group`, and
|
||||
the now-dead `WorkflowOptionalStep` / `optionalSteps` declaration, `resolveWorkflowOptionalSteps` source,
|
||||
the `workflow-step` seam node, and its compiler seam-anchor are retired — without breaking the create/edit
|
||||
toggle surfaces, which re-point to the new source.
|
||||
|
||||
**Plan depth:** Deep. Cross-cutting across core IR + validation, the engine graph executor, per-task
|
||||
persistence/seeding, the visual node editor, the built-in workflows, and a behavior-affecting removal of
|
||||
the legacy execution path.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The declaration model has three structural limits this plan removes:
|
||||
|
||||
- **Invisible & unplaceable.** `optionalSteps` never appears in the graph
|
||||
(`packages/core/src/workflow-ir-types.ts:314-339`, marked "Execution-inert; the graph executor ignores
|
||||
this facet"). All enabled steps run in one lump at the `workflow-step` seam
|
||||
(`packages/engine/src/executor.ts` `runWorkflowSteps`, gated on `enabledWorkflowSteps`), so an author
|
||||
cannot put an optional step *between* two graph nodes, order multiple optional steps, or branch on one.
|
||||
- **Add-ons are flat, not composable.** A `WorkflowStepTemplate`
|
||||
(`packages/core/src/types.ts:868-899`) is a flat prompt/script config. The seven built-in add-ons appear
|
||||
in the editor palette today only as **single "Built-in steps"** entries
|
||||
(`WorkflowNodeEditor.tsx:1002` `stepEntries`), not as subgraphs you can compose or gate.
|
||||
- **Two ways to express "run this sometimes."** The graph already has real conditional routing
|
||||
(`shouldTraverseEdge` in `packages/engine/src/workflow-graph-executor.ts:657`, edge `condition` of
|
||||
`success`/`failure`/`outcome:<x>`) and container nodes (`foreach`/`loop`), yet optionality lives in a
|
||||
parallel, execution-inert declaration channel. Converging optionality onto the graph removes the split.
|
||||
|
||||
The graph already provides every seam this needs: container nodes compile/execute via a `template`
|
||||
subgraph (`WorkflowForeachConfig`/`WorkflowLoopConfig`, `workflow-ir-types.ts:129-165`), the executor
|
||||
dispatches them in `runNodeAndTraverse` (`workflow-graph-executor.ts:431-485`), the editor renders them as
|
||||
React Flow group nodes with `parentId` children (`workflow-flow-mapping.ts:46-82,316-403,431-550`), and the
|
||||
palette can already insert multi-node subgraphs via `insertFragment` (`WorkflowNodeEditor.tsx:1425-1430`).
|
||||
The work is to add one new container kind that branches on a per-task toggle, project the add-on catalog
|
||||
into the palette as subgraphs, and migrate the built-ins off the legacy path.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **R1 — Optional-group container kind.** Add an `optional-group` node kind to the IR carrying a
|
||||
`template:{ nodes, edges }` subgraph, mirroring `WorkflowForeachConfig`. Parse + validate it.
|
||||
- **R2 — Run-or-bypass execution.** The graph executor runs the group's template **once** when the group
|
||||
is enabled for the task and **passes through** (skips the subgraph, continues to the group's children)
|
||||
when disabled. No rework budget; a single pass.
|
||||
- **R3 — Enable state reuses the per-task facet.** Whether a group runs is driven by the existing per-task
|
||||
`enabledWorkflowSteps` set plus a workflow-level `defaultOn` on the group, keyed by the group's stable
|
||||
id. New tasks seed their enabled set from each group's `defaultOn` at creation.
|
||||
- **R4 — Author optional groups in the node editor.** An author can add an `optional-group` container,
|
||||
name it, set `defaultOn`, and place nodes inside it — reusing the foreach/loop group UX. The node type is
|
||||
registered so it renders (not as `react-flow__node-default`).
|
||||
- **R5 — Every add-on is an insertable subgraph.** All seven `WORKFLOW_STEP_TEMPLATES` add-ons are
|
||||
insertable from the editor palette as a node subgraph, and offered with an "insert as optional group"
|
||||
variant that drops the add-on wrapped in an `optional-group` (seeded `defaultOn`).
|
||||
- **R6 — Built-ins migrated, behavior preserved.** The coding and stepwise-coding built-ins express
|
||||
`browser-verification` as an `optional-group` (default OFF). A task with it enabled runs the step; a task
|
||||
with it disabled does not — proven by an execution-level (not traversal-only) test.
|
||||
- **R7 — Legacy path retired without surface breakage.** The `WorkflowOptionalStep`/`optionalSteps`
|
||||
declaration, `resolveWorkflowOptionalSteps` as the toggle source, the `workflow-step` seam node, and its
|
||||
compiler seam-anchor are removed. The create/edit toggle surfaces (inline card, New Task modal, Workflow
|
||||
tab, steps dropdown) keep working by resolving their toggle list from `optional-group` nodes instead.
|
||||
- **R8 — Validation invariants hold.** Optional-group templates are validated by walking the subgraph
|
||||
(children are not in `ir.nodes`): all template nodes reachable, no illegal (non-rework) cycles, no seam
|
||||
nodes inside a group. Graphs with no optional groups serialize byte-identically (R9 of prior art).
|
||||
- **R9 — Additive serialization.** A workflow with no optional groups round-trips through the node editor
|
||||
byte-identically; `optional-group` introduces no new top-level IR keys (it is just a node kind).
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Execution: container branches on the per-task toggle
|
||||
|
||||
The new dispatch slots into `runNodeAndTraverse` beside `foreach`/`loop`. Enabled → run the template
|
||||
sub-walk once (reuse the loop/foreach template-walk machinery, no rework budget); disabled → return success
|
||||
and traverse the group's children, skipping the body entirely.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Prev["upstream node"] --> OG{{"optional-group node\n(id, defaultOn, template)"}}
|
||||
OG -->|"enabled = task.enabledWorkflowSteps.includes(group.id)"| CHK{enabled?}
|
||||
CHK -->|yes| RUN["run template sub-walk ONCE\n(template.nodes/edges, single pass)"]
|
||||
CHK -->|no| SKIP["pass through\noutcome=success, value=bypassed"]
|
||||
RUN --> CHILD["traverseChildren(OG, result)"]
|
||||
SKIP --> CHILD
|
||||
CHILD --> Next["downstream node"]
|
||||
```
|
||||
|
||||
Key boundary: the **decision** (run vs skip) is read from per-task state at the trigger seam, exactly like
|
||||
the existing per-task auto-merge override — so it must be consulted wherever the run is gated, not only in
|
||||
the executor branch (see Risks R-2). The **body** is an ordinary subgraph the executor already knows how to
|
||||
walk.
|
||||
|
||||
### Authoring + add-on projection: catalog → palette → graph
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CAT["WORKFLOW_STEP_TEMPLATES\n(7 add-ons: flat prompt/script config)"]
|
||||
CAT -->|"project to subgraph entry"| PAL["editor template palette\n(Built-in steps → subgraph entries)"]
|
||||
PAL -->|"insert as node"| N["prompt/script node\n(add-on config)"]
|
||||
PAL -->|"insert as optional group"| OGW["optional-group{ template:[ add-on node ], defaultOn }"]
|
||||
N --> CANVAS["canvas IR"]
|
||||
OGW --> CANVAS
|
||||
CANVAS -->|"flowToIr / irToFlow\n(group children via parentId)"| IR["WorkflowIrV2"]
|
||||
```
|
||||
|
||||
The add-on→subgraph projection reuses the existing `insertFragment` subgraph-insertion path
|
||||
(`WorkflowNodeEditor.tsx:1425`), which already remaps ids and rewires internal edges — so "insert as
|
||||
optional group" is a wrap-then-insert, not a new insertion engine.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD-1 — `optional-group` is a container node mirroring `WorkflowForeachConfig`, not a bypass edge.**
|
||||
Per the confirmed scope decision, optionality is encapsulated in a container (foreach/loop style) holding
|
||||
a `template:{ nodes, edges }`, rather than inline nodes plus an explicit bypass edge. This reuses the
|
||||
entire group-node toolchain (IR config, validation, React Flow `parentId` children, `insertFragment`),
|
||||
and the "skip" is the container passing through rather than a visible routed edge.
|
||||
|
||||
- **KTD-2 — Enable state reuses `enabledWorkflowSteps`, keyed by the group's stable node id.** No new
|
||||
persistence. The per-task `tasks.enabledWorkflowSteps` column (`db.ts:324`,
|
||||
`store.ts:424,2140`) holds the ids of enabled groups; `defaultOn` on the group seeds it at task creation
|
||||
via the existing materialization path. Keying on the **node id** (stable for built-ins and preserved
|
||||
across editor round-trips, like foreach/loop ids) means renaming/recreating a group resets its per-task
|
||||
state — acceptable and identical to today's `templateId` keying.
|
||||
|
||||
- **KTD-3 — Single pass, no rework budget.** Unlike `foreach` (per-step) and `loop` (bounded repeat), an
|
||||
optional-group runs its template exactly once when enabled. Reuse the loop/foreach template-walk helper
|
||||
but disable rework/iteration. Rework edges are **forbidden inside** an optional-group template (validation
|
||||
rejects them) to keep the single-pass guarantee unambiguous.
|
||||
|
||||
- **KTD-4 — Re-point the toggle resolver, don't keep two sources.** `resolveWorkflowOptionalSteps`
|
||||
(`workflow-optional-steps.ts`) currently maps `ir.optionalSteps` → display metadata for the create/edit
|
||||
UI. Replace its source with a scan of `optional-group` nodes (id, group name, `defaultOn`), preserving its
|
||||
output shape (`ResolvedWorkflowOptionalStep[]`) so the inline card, New Task modal, Workflow tab, and
|
||||
steps dropdown keep consuming it unchanged. This is what lets R7 retire the declaration without breaking
|
||||
the four toggle surfaces.
|
||||
|
||||
- **KTD-5 — Add-ons stay flat configs; the palette projects them to subgraphs at insert time.** Do **not**
|
||||
rewrite `WorkflowStepTemplate` into a nodes+edges shape. A template projects to a single `prompt`/`script`
|
||||
node (carrying its `prompt`/`scriptName`/`toolMode`/`gateMode`/`phase`/model), and the "optional" variant
|
||||
wraps that node in an `optional-group`. Keeping the catalog flat avoids migrating plugin-contributed
|
||||
templates and keeps the resolver/seeding logic simple.
|
||||
|
||||
- **KTD-6 — `workflow-step` seam removal is a compiler change, not just an IR edit.** `workflow-step` is a
|
||||
registered seam anchor in the compiler's canonical pipeline
|
||||
(`workflow-compiler.ts:45,170` `planning → execute → workflow-step → review → merge`). Retiring it
|
||||
requires removing it from `SEAM_NAMES`/`expectedSeamOrder` and updating the built-in IRs + their
|
||||
byte-identity parity oracles together, or the compiler will reject (or mis-order) the migrated graphs.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
Grouped into three phases: **A — core/engine** (the construct runs), **B — editor** (authoring + add-on
|
||||
palette), **C — migration/cleanup** (built-ins on the new model, legacy path retired).
|
||||
|
||||
### Phase A — Core construct + execution
|
||||
|
||||
### U1. `optional-group` IR type, parse, and validation
|
||||
|
||||
**Goal:** Introduce the `optional-group` node kind and its `template` config, and validate it by walking
|
||||
the subgraph (R1, R8).
|
||||
|
||||
**Requirements:** R1, R8, R9
|
||||
|
||||
**Dependencies:** none
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-ir-types.ts` (modify — add `"optional-group"` to `WorkflowIrNodeKind`; add
|
||||
`WorkflowOptionalGroupConfig { defaultOn?: boolean; template: { nodes; edges } }` mirroring
|
||||
`WorkflowForeachConfig` at `:129`)
|
||||
- `packages/core/src/workflow-ir.ts` (modify — `validateV2` calls a new `validateOptionalGroup(node, ...)`;
|
||||
extend cycle/reachability/seam walks to descend into the template subgraph)
|
||||
- `packages/core/src/__tests__/workflow-ir.test.ts` (modify/create — validation cases)
|
||||
|
||||
**Approach:**
|
||||
- Add the kind + config type. The group's stable enable key is its node `id` (KTD-2); `defaultOn` lives on
|
||||
the node `config`.
|
||||
- In validation, treat the template like a foreach template: its nodes are **not** in `ir.nodes`, so every
|
||||
reader/validator must walk the subgraph explicitly (learning: per-entity blast-radius `:37`). Validate:
|
||||
all template nodes reachable from the template's entry; endpoints reference template-local nodes; **no
|
||||
rework edges** inside (KTD-3); **no seam nodes** inside (mirror `validateParallelism`'s seam-in-branch
|
||||
rule, `workflow-ir.ts:190+`).
|
||||
- `parseWorkflowIr` descends into optional-group templates the same way it clamps foreach/loop configs.
|
||||
|
||||
**Patterns to follow:** `WorkflowForeachConfig` type + `validateV2`/foreach template validation in
|
||||
`workflow-ir.ts`; the seam-in-branch check in `validateParallelism`.
|
||||
|
||||
**Test scenarios:**
|
||||
- A v2 IR with one `optional-group` (valid template) parses and validates.
|
||||
- A template referencing an undefined template-local node throws `WorkflowIrError`.
|
||||
- A rework edge inside an optional-group template is rejected.
|
||||
- A seam node (e.g. `merge-gate`) inside an optional-group template is rejected.
|
||||
- An unreachable template node is rejected.
|
||||
- A graph with **no** optional-group serializes/parses byte-identically (R9).
|
||||
|
||||
**Verification:** `pnpm --filter @fusion/core test workflow-ir` green; no change to graphs without optional
|
||||
groups.
|
||||
|
||||
---
|
||||
|
||||
### U2. Executor: run-once-or-bypass dispatch for `optional-group`
|
||||
|
||||
**Goal:** Make the graph executor run an enabled group's template once and pass through a disabled group
|
||||
(R2), reading enable state from per-task `enabledWorkflowSteps` (R3).
|
||||
|
||||
**Requirements:** R2, R3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/workflow-graph-executor.ts` (modify — add an `optional-group` branch in
|
||||
`runNodeAndTraverse` at `:389-485`, beside `foreach`/`loop`)
|
||||
- `packages/engine/src/workflow-graph-loop.ts` or a shared helper (modify/extract — reuse the
|
||||
single-template-walk without iteration/rework for the enabled path)
|
||||
- `packages/engine/src/__tests__/workflow-graph-optional-group.test.ts` (create — execution-level tests)
|
||||
|
||||
**Approach:**
|
||||
- Resolve `enabled = currentTask.enabledWorkflowSteps?.includes(node.id) ?? false`. **Read the freshest
|
||||
task state** at the seam (the executor already re-reads the task in `runWorkflowSteps`); ensure
|
||||
`enabledWorkflowSteps` is in whatever projection the gate reads (learning: per-task override slim-SELECT
|
||||
trap `:105`).
|
||||
- Enabled → walk `node.config.template` once via the extracted helper, threading the same
|
||||
`runTemplateNode`/`shouldTraverseEdge` deps the foreach/loop handlers pass; collect `visitedNodeIds`;
|
||||
set `context[node:id:outcome]`. Disabled → `return await traverseChildren(node, { outcome: "success",
|
||||
value: "optional-group-bypassed" })`.
|
||||
- Namespaced child ids: reuse the foreach instance-id scheme defensively — parse candidate-style and
|
||||
validate the template node exists, not just the container (learning `:56`).
|
||||
|
||||
**Execution note:** Start with the failing **two-task divergence** execution test (below) — it is the
|
||||
contract that guards the dead-toggle failure mode.
|
||||
|
||||
**Test scenarios:**
|
||||
- **Two-task divergence (critical):** two tasks identical except `enabledWorkflowSteps` — the one including
|
||||
the group id records the template's node execution; the sibling records none and reaches the same
|
||||
downstream node. (Shape from per-task-auto-merge learning `:106`.)
|
||||
- Enabled group runs its template exactly **once** (not per-step, not looped) even when the graph has a
|
||||
foreach elsewhere.
|
||||
- Disabled group is byte-inert: downstream context/outcome identical to a graph with the group removed
|
||||
(kill-switch inertness, learning `:55`).
|
||||
- A template node failure inside an enabled group surfaces as the group's outcome and routes the group's
|
||||
`failure`/`outcome:` edges.
|
||||
|
||||
**Verification:** new engine test green; existing graph-executor tests unaffected.
|
||||
|
||||
---
|
||||
|
||||
### U3. Per-task enable resolution from optional-group nodes + `defaultOn` seeding
|
||||
|
||||
**Goal:** Re-point the per-task toggle source from `ir.optionalSteps` to `optional-group` nodes and seed
|
||||
new tasks' enabled set from each group's `defaultOn` (R3, R7-prep).
|
||||
|
||||
**Requirements:** R3, R7
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-optional-steps.ts` (modify — `resolveWorkflowOptionalSteps` scans
|
||||
`optional-group` nodes instead of `ir.optionalSteps`, preserving `ResolvedWorkflowOptionalStep[]` output)
|
||||
- `packages/core/src/` task-creation/materialization path that seeds `enabledWorkflowSteps` from `defaultOn`
|
||||
(modify — seed from optional-group `defaultOn`; today's `materializeDefaultWorkflowSteps`, see
|
||||
`types.ts:2668-2675`)
|
||||
- `packages/core/src/__tests__/workflow-optional-steps.test.ts` (modify — group-sourced resolution)
|
||||
|
||||
**Approach:**
|
||||
- Resolver: walk `ir` (v2) nodes, collect `optional-group` nodes → `{ id, name (from config), defaultOn }`.
|
||||
Keep output shape identical so the four UI surfaces (inline card, modal, Workflow tab, dropdown) need no
|
||||
change beyond what U7/U-editor already covers. Unknown/stale ids in `enabledWorkflowSteps` are ignored
|
||||
(defensive, as today).
|
||||
- Seeding: at task creation, the enabled set is the ids of optional-group nodes whose effective
|
||||
`defaultOn` is true — mirroring the prior `optionalStep.defaultOn ?? false` precedence.
|
||||
- Grep every reader of `enabledWorkflowSteps`/`optionalSteps` and confirm each now resolves from groups
|
||||
(learning: consult the override at every trigger seam `:100,:102`).
|
||||
|
||||
**Test scenarios:**
|
||||
- `resolveWorkflowOptionalSteps` over a workflow with two optional-group nodes returns both, with names and
|
||||
`defaultOn` from node config.
|
||||
- A new task created against that workflow seeds `enabledWorkflowSteps` to exactly the `defaultOn: true`
|
||||
group ids.
|
||||
- A workflow with no optional-group nodes resolves to `[]` and seeds an empty set.
|
||||
- Stale id in `enabledWorkflowSteps` (group since removed) does not crash resolution or execution.
|
||||
|
||||
**Verification:** core optional-steps tests green; creation seeding covered.
|
||||
|
||||
---
|
||||
|
||||
### Phase B — Editor authoring + add-on palette
|
||||
|
||||
### U4. Render & author the `optional-group` container in the node editor
|
||||
|
||||
**Goal:** Let an author add, name, configure (`defaultOn`), and fill an `optional-group` container,
|
||||
reusing the foreach/loop group UX, with the node type registered so it renders (R4).
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** U1 (kind exists)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx` (modify — add `optional-group` to the
|
||||
node-type registry + icon, render as a group container like `foreach`/`loop`)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — treat `optional-group` as a group
|
||||
kind everywhere foreach/loop are special-cased: `groupTemplateConfigOf` `:266`, group child
|
||||
reassembly `:441-480`, intra-template edge handling `:517`, group delete `:611,:636`,
|
||||
condition-editability `:663`)
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — inspector controls: group name +
|
||||
`defaultOn` toggle; help entry)
|
||||
- `packages/dashboard/app/components/nodes/node-help.ts` (modify — add an `optional-group` help entry)
|
||||
- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (modify — round-trip group
|
||||
children)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — add/name/toggle/fill)
|
||||
|
||||
**Approach:**
|
||||
- Mirror the `foreach`/`loop` group node: a React Flow `type: "group"` with `parentId` children using the
|
||||
existing `foreachChildFlowId` namespacing (`workflow-flow-mapping.ts:75`). `irToFlow` renders the
|
||||
template as children; `flowToIr` reassembles it (the `:454,:480` kind checks gain `optional-group`).
|
||||
- Inspector: a `defaultOn` checkbox (labeled, focus ring) and the group name; the body is authored by
|
||||
dropping nodes inside, identical to foreach.
|
||||
- **Register the node type** in `WorkflowNodeTypes.tsx` — an unregistered kind renders as
|
||||
`react-flow__node-default` with missing children (learning: worktree bundle `:24`). Verify against a
|
||||
fresh `FUSION_CLIENT_DIR` bundle, non-4040 port.
|
||||
|
||||
**Test scenarios:**
|
||||
- Adding an optional-group, dropping a prompt node inside, and saving yields IR with an `optional-group`
|
||||
node whose `template.nodes` contains the inner node (round-trip).
|
||||
- Toggling `defaultOn` marks the editor dirty and persists on save.
|
||||
- Deleting the group removes its `parentId` children (no orphans) — mirrors the foreach delete test.
|
||||
- The node renders with its registered type (not `react-flow__node-default`) — asserted via node-type
|
||||
registry presence.
|
||||
|
||||
**Verification:** mapping + editor tests green; real-browser check that the container renders, accepts
|
||||
child nodes, and the `defaultOn` toggle works (fresh worktree bundle; verify on a mobile viewport per
|
||||
Risks R-4).
|
||||
|
||||
---
|
||||
|
||||
### U5. Add-ons as insertable subgraphs (plus "insert as optional group")
|
||||
|
||||
**Goal:** Make all seven `WORKFLOW_STEP_TEMPLATES` add-ons insertable from the palette as node subgraphs,
|
||||
each also offerable wrapped in an `optional-group` (R5).
|
||||
|
||||
**Requirements:** R5, KTD-5
|
||||
|
||||
**Dependencies:** U4 (optional-group authoring exists)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — the "Built-in steps" palette
|
||||
section `:1002,:2741`, the existing `stepTemplateToNode()` projector `:249-275`, and the
|
||||
`handleInsertStepTemplate`/`handleInsertFragment` handlers `:1425-1453`: add an "insert as optional
|
||||
group" variant per add-on)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — `insertFragment` `:1110-1219`
|
||||
already expands subgraphs incl. group `parentId` children; add the wrap-in-`optional-group` helper that
|
||||
builds the fragment IR from a projected add-on node)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — insert-as-node and
|
||||
insert-as-optional-group for an add-on)
|
||||
|
||||
**Approach:**
|
||||
- Project each add-on to a node using the **existing `stepTemplateToNode()`** (`WorkflowNodeEditor.tsx:
|
||||
249-275`), which already maps a `WorkflowStepTemplate` → a `prompt`/`script` node carrying its
|
||||
`prompt`/`scriptName`/`toolMode`/`gateMode`/model (KTD-5). "Insert as node" is today's behavior.
|
||||
- "Insert as optional group" wraps that projected node in an `optional-group{ template:{ nodes:[node],
|
||||
edges:[] }, defaultOn }` (seeded from the template's `defaultOn`) and inserts it through the existing
|
||||
`insertFragment` path (`workflow-flow-mapping.ts:1110-1219`), which already remaps ids, rewires internal
|
||||
edges, and expands group `parentId` children — so no new insertion engine is needed.
|
||||
- Surface both variants in the palette's existing "Built-in steps" group; keep `data-testid` conventions.
|
||||
|
||||
**Test scenarios:**
|
||||
- Each of the seven add-ons appears in the palette and inserts a node carrying its template config.
|
||||
- "Insert as optional group" for `security-audit` yields an `optional-group` whose `template` holds the
|
||||
security-audit node and whose `defaultOn` matches the template default.
|
||||
- Inserting an add-on subgraph remaps ids so two insertions of the same add-on do not collide.
|
||||
- A plugin-contributed template (if present) still inserts as a node (catalog stays flat, KTD-5).
|
||||
|
||||
**Verification:** editor tests green; real-browser insert of an add-on and an optional-group-wrapped add-on,
|
||||
then save → reopen round-trip.
|
||||
|
||||
---
|
||||
|
||||
### Phase C — Migrate built-ins, retire the legacy path
|
||||
|
||||
### U6. Migrate coding + stepwise-coding built-ins to `optional-group` browser-verification
|
||||
|
||||
**Goal:** Express `browser-verification` as an `optional-group` (default OFF) in both built-ins, preserving
|
||||
runtime behavior, and update parity oracles (R6).
|
||||
|
||||
**Requirements:** R6, R8
|
||||
|
||||
**Dependencies:** U2, U3 (execution + seeding), U1 (kind)
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/builtin-coding-workflow-ir.ts` (modify — replace `optionalSteps:[{templateId:
|
||||
"browser-verification"}]` `:123` + the `workflow-step` seam node `:69` with a `browser-verification`
|
||||
`optional-group` on the pre-merge path)
|
||||
- `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (modify — add the `browser-verification`
|
||||
optional-group on the pre-merge path; stepwise had no `workflow-step` seam at all)
|
||||
- `packages/core/src/__tests__/` built-in IR snapshot/parity fixtures (modify — update byte-identity
|
||||
oracles deliberately)
|
||||
- `packages/engine/src/__tests__/` stepwise/coding execution parity test (modify — enabled-runs/disabled-
|
||||
skips at the built-in level)
|
||||
|
||||
**Approach:**
|
||||
- Place the optional-group on the success path where `workflow-step` sat (pre-merge, after execute/steps,
|
||||
before review), so an enabled task runs browser-verification pre-merge exactly as before.
|
||||
- The stepwise IR is a documented byte-identity parity oracle — adding the construct shifts its snapshot;
|
||||
update the fixture deliberately and confirm the group runs **once** post-foreach, not per step-instance
|
||||
(learning `:55`, prior plan R-5).
|
||||
|
||||
**Test scenarios:**
|
||||
- `resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)` returns one `browser-verification` group,
|
||||
`defaultOn: false`; same for stepwise.
|
||||
- Execution: a coding task with the group enabled runs browser-verification pre-merge; disabled does not
|
||||
(two-task divergence at the built-in level).
|
||||
- Stepwise: same divergence; the group runs once after the foreach completes.
|
||||
- Both built-ins parse and pass `validateV2`.
|
||||
|
||||
**Verification:** core + engine built-in tests green; parity oracles updated and passing.
|
||||
|
||||
---
|
||||
|
||||
### U7. Retire the declaration-based optional-steps path
|
||||
|
||||
**Goal:** Remove the now-dead `WorkflowOptionalStep`/`optionalSteps` declaration, the `workflow-step` seam
|
||||
node + handler, and its compiler seam-anchor, keeping the four toggle surfaces working via U3's resolver
|
||||
(R7).
|
||||
|
||||
**Requirements:** R7
|
||||
|
||||
**Dependencies:** U3 (resolver re-pointed), U6 (built-ins migrated — nothing still declares `optionalSteps`)
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-ir-types.ts` (modify — remove `WorkflowOptionalStep` + `WorkflowIrV2.
|
||||
optionalSteps`)
|
||||
- `packages/core/src/workflow-compiler.ts` (modify — remove `workflow-step` from `SEAM_NAMES` `:45` and
|
||||
`expectedSeamOrder` `:170`)
|
||||
- `packages/engine/src/executor.ts` (modify — remove `runWorkflowSteps` + the `workflow-step` seam
|
||||
dispatch, now unreachable)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — drop `optionalSteps` threading in
|
||||
`flowToIr`/`optionalStepsOf` if present from the prior plan)
|
||||
- `packages/core/src/__tests__/`, `packages/engine/src/__tests__/` (modify — delete/replace tests asserting
|
||||
the legacy path; keep the resolver/seeding tests now backed by groups)
|
||||
|
||||
**Approach:**
|
||||
- This is a **behavior-affecting removal** — follow the codebase's Surface Enumeration discipline (see the
|
||||
section below). Enumerate every reader of `optionalSteps`/`workflow-step`/`runWorkflowSteps` and confirm
|
||||
each is migrated or removed; do not leave a mock-masked dead path (learning: branch-group dead-wiring).
|
||||
- Removing the seam anchor changes the compiler's accepted pipeline — confirm no remaining built-in or
|
||||
fragment references `workflow-step`, then drop it from both anchor lists together with the built-in edits
|
||||
from U6.
|
||||
|
||||
**Test scenarios:**
|
||||
- Grep proves zero remaining references to `optionalSteps`, `WorkflowOptionalStep`, `workflow-step` seam,
|
||||
and `runWorkflowSteps` in non-test source.
|
||||
- The compiler accepts the migrated built-ins with `workflow-step` removed from the seam order.
|
||||
- The four toggle surfaces (inline card, New Task modal, Workflow tab, steps dropdown) still render and
|
||||
submit `enabledWorkflowSteps` — now sourced from optional-group nodes (regression).
|
||||
- A pre-existing workflow JSON that still carries `optionalSteps` (legacy persisted) does not crash parse —
|
||||
the key is ignored, not fatal (back-compat decision: tolerate-and-drop). *(Confirm this stance in review;
|
||||
alternative is a one-time parse upgrade.)*
|
||||
|
||||
**Verification:** full core + engine + dashboard suites green; `pnpm lint`, `pnpm typecheck`, `pnpm build`,
|
||||
`pnpm test:gate` pass.
|
||||
|
||||
---
|
||||
|
||||
## Surface Enumeration
|
||||
|
||||
Behavior-affecting change (new execution construct + removal of the legacy path) and a UI-affordance change
|
||||
(new node kind + retired toggle source), so per AGENTS.md ("Fix the Invariant, Not the Repro", FN-5893) the
|
||||
surfaces are enumerated:
|
||||
|
||||
- **Workflow providers/graphs:** built-in **coding** and **stepwise-coding** (both migrated, U6); any
|
||||
fragment or user workflow that declared `optionalSteps` (tolerated-and-dropped, U7).
|
||||
- **Execution states:** group **enabled** (runs once), **disabled** (passes through), **stale id** in
|
||||
`enabledWorkflowSteps` (ignored), **template failure** (routes group failure edge).
|
||||
- **Editor breakpoints:** desktop and **mobile** node editor — container render, child placement,
|
||||
`defaultOn` toggle, palette insert (both variants).
|
||||
- **Per-task toggle surfaces:** inline quick-create card, New Task modal, task-detail Workflow tab, steps
|
||||
dropdown — all re-sourced from optional-group nodes (U3/U7).
|
||||
- **Validation:** subgraph walked (not just `ir.nodes`); no rework/seam inside a group; graphs without
|
||||
optional groups byte-identical.
|
||||
- **Compiler:** seam-anchor list with `workflow-step` removed; canonical pipeline still valid for migrated
|
||||
built-ins.
|
||||
|
||||
## Symptom Verification
|
||||
|
||||
- **Original symptom:** optionality is invisible and unplaceable — enabled steps run in one lump at a
|
||||
hidden seam, and add-ons cannot be composed or gated in the graph.
|
||||
- **Exact reproduction:** build/inspect the coding workflow; `browser-verification` appears only as an
|
||||
`optionalSteps` declaration, runs at the `workflow-step` seam, and is absent from the graph; add-ons
|
||||
appear in the palette only as flat single steps.
|
||||
- **Assertion it is gone:** an enabled optional-group runs its placed template once at its graph position
|
||||
and a disabled one is inert (two-task divergence execution test, U2/U6); every add-on inserts as a
|
||||
node/optional-group subgraph (U5); no `workflow-step`/`optionalSteps` path remains (U7 greps).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- `optional-group` IR kind + validation (U1), executor run/bypass (U2), per-task resolution + seeding (U3).
|
||||
- Editor container authoring + node-type registration (U4); add-ons as insertable subgraphs incl.
|
||||
optional-group wrapping (U5).
|
||||
- Built-in coding + stepwise migration (U6); retiring the declaration/seam/compiler-anchor path (U7).
|
||||
|
||||
**Already built (reuse, not rebuilt):**
|
||||
- Container-node toolchain: `foreach`/`loop` config, group rendering, `parentId` children,
|
||||
`insertFragment` subgraph insertion.
|
||||
- Per-task `enabledWorkflowSteps` column, create-surface toggles, the four toggle UIs, and
|
||||
`resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3).
|
||||
- Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5).
|
||||
|
||||
### Delivered cohort (this PR) vs. Deferred
|
||||
This PR delivers **U1–U6 plus U7a** (10 commits). U7a retired the legacy declaration *model*: the core
|
||||
`WorkflowOptionalStep` type + `WorkflowIrV2.optionalSteps` field + `validateOptionalSteps`, and the editor's
|
||||
declaration **authoring** surface (`WorkflowOptionalStepsPanel`, `optionalStepsOf`, the `flowToIr`
|
||||
`optionalSteps` threading). A code-review pass also fixed a P1 (the optional-group toggle-id collision in
|
||||
enable resolution) — captured in the commit history and in
|
||||
`docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`. The per-task toggle
|
||||
surfaces (`WorkflowOptionalStepsDropdown`, inline card, modal, Workflow tab) stayed — they consume the
|
||||
distinct `ResolvedWorkflowOptionalStep`.
|
||||
|
||||
- **Deferred: the `workflow-step` seam infrastructure removal.** What remains of "full U7" is excising the
|
||||
`workflow-step` seam itself — a shared `WorkflowSeam` union member woven through ~9 engine runtime files
|
||||
(`runtime-primitives`, `step-session-executor`, `workflow-node-handlers`, `active-session-registry`,
|
||||
`workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor). It is now orphaned
|
||||
(no built-in graph reaches it) but inert; excising it is its own focused refactor with its own blast radius.
|
||||
- **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field
|
||||
rather than the per-task toggle) — single-level, per-task-toggle only for now.
|
||||
- **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes.
|
||||
- The prior plan's deferred **unified per-task workflow-facet override** abstraction (optional steps +
|
||||
auto-merge + column-agent overrides) — strong `/ce-compound` candidate once this lands.
|
||||
|
||||
**Out of scope:**
|
||||
- New persistence/migrations. Reuses `tasks.enabledWorkflowSteps`; `optional-group` is just a node kind.
|
||||
- Rewriting `WorkflowStepTemplate` into a nodes+edges shape (KTD-5 keeps it flat).
|
||||
- Changing unrelated execution (merge lifecycle, branch groups, PR nodes).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R-1 — Subgraph-walking validation/readers miss template children.** Optional-group template nodes are
|
||||
not in `ir.nodes`; any validator, reachability pass, or id parser that only sees top-level nodes will be
|
||||
wrong. *Mitigation:* walk the subgraph explicitly and parse namespaced child ids candidate-style,
|
||||
validating the template node exists, not just the container.
|
||||
(`docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md:37,:56`)
|
||||
- **R-2 — Per-task enable consulted only in the executor branch.** If skip-vs-run is read only where the
|
||||
group executes and not at every gate/trigger that decides whether to run it, the toggle silently no-ops;
|
||||
a slim SELECT omitting `enabledWorkflowSteps` reads `undefined`. *Mitigation:* grep every gate; ensure the
|
||||
column is selected; ship the two-task divergence test.
|
||||
(`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md:100,:102,:105,:106`)
|
||||
- **R-3 — Seam-anchor / parity-oracle drift on built-in migration.** `workflow-step` is a compiler seam
|
||||
anchor and the stepwise IR is a byte-identity oracle; removing the seam and adding the construct shifts
|
||||
snapshots and can break compile-order validation. *Mitigation:* edit built-ins, seam-anchor lists, and
|
||||
parity fixtures together (KTD-6); confirm the group runs once post-foreach.
|
||||
(`docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md:117`; prior plan R-5)
|
||||
- **R-4 — Editor node-type registration + mobile.** An unregistered `optional-group` renders as
|
||||
`react-flow__node-default` with missing children, looking like a source bug; per-task toggle controls have
|
||||
a real-browser-only mobile failure history. *Mitigation:* register the node type and verify against a
|
||||
fresh `FUSION_CLIENT_DIR` worktree bundle on a non-4040 port; real-browser-check the `defaultOn` toggle on
|
||||
a mobile viewport.
|
||||
(`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md:24,:44`;
|
||||
`docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md:32,:53`)
|
||||
- **R-5 — Mock-masked dead wiring on removal.** Retiring the legacy path risks a green suite over a feature
|
||||
whose new path is never actually exercised (the branch-group failure class). *Mitigation:* execution-level
|
||||
(not traversal-only) tests for enabled-runs/disabled-skips at both the construct and built-in levels;
|
||||
grep-prove the old path is gone.
|
||||
(`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`)
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- **Execution seam:** `packages/engine/src/workflow-graph-executor.ts:389-485` (`runNodeAndTraverse`
|
||||
foreach/loop dispatch), `:657` (`shouldTraverseEdge`); `workflow-graph-foreach.ts`,
|
||||
`workflow-graph-loop.ts` (template sub-walk to reuse).
|
||||
- **IR + validation:** `packages/core/src/workflow-ir-types.ts:41-165` (node/edge/container types),
|
||||
`:314-339` (legacy `optionalSteps`); `packages/core/src/workflow-ir.ts:190+` (seam-in-branch),
|
||||
`:1218-1298` (`validateV2`, cycles/endpoints), `:1327` (`parseWorkflowIr`).
|
||||
- **Per-task facet:** `packages/core/src/types.ts:2421,2663-2680` (`enabledWorkflowSteps`, `workflowId`
|
||||
precedence), `db.ts:324`, `store.ts:424,2140`; `workflow-optional-steps.ts` (resolver to re-point);
|
||||
`executor.ts` `runWorkflowSteps` (seam consumer to retire).
|
||||
- **Add-on catalog:** `packages/core/src/types.ts:868-899` (`WorkflowStepTemplate`), `:902-1150`
|
||||
(seven `WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review,
|
||||
accessibility-check, browser-verification, frontend-ux-design); `WorkflowNodeEditor.tsx:249-275`
|
||||
(`stepTemplateToNode` add-on→node projector to reuse).
|
||||
- **Editor:** `packages/dashboard/app/components/workflow-flow-mapping.ts:46-82,266-269,316-403,431-550`
|
||||
(group children, `groupTemplateConfigOf`), `:1110-1219` (`insertFragment` subgraph insertion);
|
||||
`WorkflowNodeEditor.tsx:985-1010` (palette: fragments/steps/plugins, sourced from
|
||||
`/api/workflow-step-templates`), `:1425-1453` (insert handlers); `nodes/WorkflowNodeTypes.tsx` (node-type
|
||||
registry to extend).
|
||||
- **Compiler:** `packages/core/src/workflow-compiler.ts:45,165-196` (seam anchors + canonical order).
|
||||
- **Built-ins:** `packages/core/src/builtin-coding-workflow-ir.ts:69,123`,
|
||||
`builtin-stepwise-coding-workflow-ir.ts:132`.
|
||||
- **Prior art:** `docs/plans/2026-06-20-001-feat-workflow-optional-steps-node-editor-modal-plan.md` (the
|
||||
declaration-based system this replaces); institutional learnings cited inline under Risks.
|
||||
@@ -0,0 +1,589 @@
|
||||
---
|
||||
title: "feat: Workspace mode execution model — make multi-repo tasks run end-to-end"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
deepened: 2026-06-21
|
||||
decided: 2026-06-21
|
||||
type: feat
|
||||
origin: none (solo planning from PR #1710)
|
||||
pr: https://github.com/Runfusion/Fusion/pull/1710
|
||||
branch: pr-1710 (feat/workspace-multi-repo)
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: Workspace mode execution model — make multi-repo tasks run end-to-end
|
||||
|
||||
## Summary
|
||||
|
||||
PR #1710 lays a clean, additive foundation for **workspace mode**: a Project whose `rootDir` is a non-git parent directory containing multiple git sub-repos. The foundation adds a separate `task.workspaceWorktrees` field, an `fn_acquire_repo_worktree` agent tool, `acquireWorkspaceRepoWorktree()`, workspace config detection, and a validation bypass — without mutating any existing single-worktree invariant.
|
||||
|
||||
This plan covers the **deeper execution-model work** the PR deferred: making one task that spans multiple sub-repos run end-to-end through acquisition → capture → review → merge → self-healing. Architecture (user-confirmed): **one task spans repos** — a single task/session holds N per-repo worktrees in `task.workspaceWorktrees`, the merger merges each sub-repo's branch into that repo's own integration branch, and completion is a branch-anchored **conjunction** across all worktrees.
|
||||
|
||||
**Decisions settled this session** (see Decisions Made; previously the open forks):
|
||||
- **Merger unification (U0, lands first):** `aiMergeTask` is soft-deprecated; `runAiMerge` (the FN-5633 clean-room path, already the default) becomes the **sole** merge path. Workspace mode is built on `runAiMerge` only — no dual-path branching.
|
||||
- **Merge atomicity = land-as-you-go (local integration ref)** + an unconditional operator revert/force-complete escape hatch. Each repo's clean-room **advances that repo's local integration branch ref via `update-ref` CAS** as it passes — `runAiMerge` does **not** push to any remote (verified: `merger-ai.ts:817/847`; the only `git push` is the separate PR-mode path). Remote push is a separate existing mechanism (PR flow / pull-integration-worktree), **out of scope** here — workspace mode matches `runAiMerge`'s local-ref behavior. Consequence: a partial land is a transient **local** integration-state window, operator-resettable with a clean local reset (not a compensate-forward remote revert). Two-phase was rejected (see KTD8).
|
||||
- **Scope = full N>1 end-to-end** in one plan.
|
||||
|
||||
**The dominant engineering theme that survived review:** the single-worktree assumption is `cwd: rootDir`-bound git execution threaded through the most invariant-dense code in the repo — now concentrated, post-unification, in `runAiMerge`'s clean-room model (`merger-ai.ts`), the self-healing reconcilers, and `store.mergeTask`. A missed site silently strands or loses work — a documented incident class (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`). The hardest piece is reworking `runAiMerge`'s single-terminal clean-room pipeline into a per-repo loop (U6).
|
||||
|
||||
**Scope out:** the brand `kb→fn` rename; a full dashboard workspace-registration UI (a minimal "doesn't look broken" floor is in — U10); `branchContext`/shared-branch-group reuse (a distinct axis from branch groups).
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Today every task carries exactly one `(rootDir, branch, worktree)` triple, the lifecycle runs git in `rootDir`, and merge dispatches between two functions:
|
||||
|
||||
- **Executor** acquires one worktree at `executor.ts:~7430` (`acquireTaskWorktree({ rootDir })`), binds the agent session cwd to it, then captures base-commit SHA, modified files, contamination base, identity-guard hooks, review, and `verifyWorktreeInvariants` against that single path. The foundation's workspace guard at `executor.ts:7414-7418` only suppresses the `isGitRepository` *error message* — the acquisition itself is **not yet gated** on `pr-1710`, so the first workspace task crashes there today.
|
||||
- **Merger** dispatches at `project-engine.ts:2280-2282` (`mergerMode === "ai" ? runAiMerge(...) : aiMergeTask(...)`). `"ai"` is the **FN-5633 default**; the code labels `aiMergeTask` the **"legacy pipeline."** `runAiMerge` (`merger-ai.ts`) does a **clean-room temp worktree** (prefix `fusion-ai-merge-<taskId>-`), AI merge + AI review, then a single terminal `finalizeMerged → finalizeTask → store.moveTask(taskId,'done')` (`merger-ai.ts:1174/1194/1285/1384`). It already has an `{ empty: true }` finalize path (`:1174`) for empty squashes.
|
||||
- **`store.mergeTask` is a *third merge path*, not just cleanup** (corrected, round 3): `store.mergeTask` (`store.ts:11150`, called from `executor.ts:1742` `finalizeAlreadyInReviewTask` + `self-healing.ts:5830` the no-`enqueueMerge`-queue UI-only fallback) does a full `git checkout <target>` / `git merge --squash` / `git commit` (`store.ts:11256-11266`) **and then** `git worktree remove` / `git branch -d` — all via `runGitCommand` pinned to rootDir, keyed on singular `task.worktree`/`task.branch`. For a workspace task it would `git checkout` against the non-git root and fail. It is **not** unified by U0's dispatch change, so it must be made workspace-aware or gated (U6).
|
||||
- **Self-healing** reconcilers read scalar `task.worktree`/`task.branch` and run `git for-each-ref`/`git show-ref` against `rootDir`; the in-review rebind *deliberately skips* ambiguous multi-branch candidates and dedups by resolved SHA within one rootDir.
|
||||
- **Scheduler** file-scope leases key on `taskId` alone; the worktree pool is a recycle cache (`recycleWorktrees`-gated), **not** a cross-task lock.
|
||||
|
||||
In workspace mode `rootDir` is a **non-git** parent, so none of this works as-is. This plan (a) unifies merge onto `runAiMerge` (U0), then (b) re-targets the lifecycle from "one worktree, git in rootDir" to "N per-repo worktrees, git in each `repoAbsPath`," preserving every existing single-repo task's behavior.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### KTD0 — `runAiMerge` is the sole merge path (U0, lands before all workspace work)
|
||||
Soft-deprecate `aiMergeTask`: collapse the `project-engine.ts:2280-2282` dispatch to always-`runAiMerge`, mark `aiMergeTask` + its now-dead helpers `@deprecated` (body retained, deleted in a later pass), and retire/alias the `settings.merger.mode` setting. Low blast radius — `"ai"` is already the default, so default-config projects are unaffected; only projects explicitly on the (effectively unused) `"deterministic"` mode change behavior. Workspace mode then targets one canonical merge function — no dual-path forks, no "keep the legacy path working" regression burden.
|
||||
|
||||
### KTD1 — Session cwd = browse-only workspace root; skip root acquisition (resolved; not yet implemented on pr-1710)
|
||||
In workspace mode the main `acquireTaskWorktree({ rootDir })` (`executor.ts:~7430`) is **skipped** (cannot run against a non-git dir). Session cwd becomes the workspace root for browsing; **all edits happen inside per-repo worktrees** via `fn_acquire_repo_worktree`. On `pr-1710` the acquisition and the preflights between the workspace guard (`:7414`) and session create (`~:8443`) are **not yet gated** — U1 must gate the acquisition *and* every intervening preflight (identity guard, `resolveContaminationBaseRef` `:7536`, base-commit capture `:7525`, `verifyWorktreeInvariants`). Direction resolved; the gating is real work.
|
||||
|
||||
### KTD2 — One task spans repos; merge-boundary coherence is session-time only (accepted)
|
||||
A single task/session holds N per-repo worktrees. Merge runs **per repo** — each sub-repo's `fusion/<id>` branch lands into *that repo's own* local integration branch ref — and completion is the **conjunction**. Coherence is **session-time only**: repos land independently (land-as-you-go), so a task can briefly have repo A landed on its local integration ref while repo B is still in-flight. **This is accepted** (KTD8/D3 decision): a transient incoherent window in **local** integration state, resettable via the operator escape hatch, is acceptable for this tool. Because the merge advances a local ref (not a remote push — KTD8), the window is local-only: no shared remote is mutated until the separate, out-of-scope push step runs, so other developers don't pull a half-applied change from the merge itself. The rejected alternative (parent + per-repo child tasks) is in Alternatives Considered.
|
||||
|
||||
### KTD3 — Per-repo `baseCommitSha`, captured at each acquisition against that repo's resolved integration branch
|
||||
Per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`, base/fork-point must be measured against the **local** integration branch first (`merge-base HEAD <localIntegration> || origin/<integration>`). `resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes no branch param — U2 must extend it to accept the per-repo integration branch from `resolveIntegrationBranch(repoAbsPath, settings)`, or any sub-repo whose integration branch is not `main` re-introduces the diff-inflation bug R3 guards against. Stored as `workspaceWorktrees[repo].baseCommitSha`; the singular `task.baseCommitSha` is unused in workspace mode.
|
||||
|
||||
### KTD4 — Shared `@fusion/engine` "landed" predicate + `merged`-flag integrity
|
||||
A branch-anchored conjunction predicate (`isWorkspaceTaskLanded(task)`) in a shared `@fusion/engine` helper (`packages/engine/src/workspace-completion.ts`), imported by route logic, merger, and self-healing — **not** in published `@fusion/core` (no non-engine caller today). Per `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`, a column-only / any-one-branch check is the data-loss hazard; the predicate verifies *each* repo's merge landed on *that repo's* integration branch and reads stored row data only — never a re-derived string. **`merged`-flag integrity:** the operator `revert-landed-repo`/`force-complete` must clear `merged=false` + `mergeTargetBranch` **in the same atomic op** as the revert (or the flag drifts and the task reports landed forever); U6's crash re-entry skip relies **only** on the persisted per-repo flag (no live "landing evidence" re-derivation — that would contradict the row-only rule). The flag is honest at write time (set on land, cleared on revert), not by re-checking the tip at read time.
|
||||
|
||||
### KTD5 — File scope declared with repo-prefixed paths; per-repo filtering strips the prefix; leases skip cross-repo at compare time
|
||||
Workspace tasks declare `## File Scope` with workspace-relative prefixed paths (`wolf-server/src/**`). The repo prefix is derived from the first path segment matching a configured repo, **after canonicalizing** (strip leading/trailing slashes, resolve `.`); a non-matching first segment routes to an explicit `unscoped` fallback (logged, never silently no-leased). Consequences:
|
||||
- **Squash overlap (U6):** `assertSquashOverlapsFileScope` reads staged paths via `git diff --cached --name-only` with cwd = the sub-repo, so they are **repo-relative** (`src/foo.ts`). Per-repo filtering must both *select* the repo's scope entries **and strip the repo prefix** (`wolf-server/src/**` → `src/**`) or every per-repo merge throws `FileScopeViolationError` (verified against `merger.ts:4935-5099`).
|
||||
- **Leases (U7):** keep `activeScopes` as `Map<taskId, scope[]>` (no map-shape refactor); skip comparison at *overlap-check time* when two entries derive to different repo prefixes. Lease lifecycle (set/clear) untouched for existing tasks.
|
||||
|
||||
### KTD6 — Per-repo identity-guard hooks, init/setup at acquisition; same-sub-repo exclusivity is the lease's job (not the pool)
|
||||
`installTaskWorktreeIdentityGuard` and configured init/setup install/run **in each sub-repo worktree** at acquisition. The foundation already passes `runInitCommand: true`; U2 adds identity-guard install + per-repo base-commit capture. **Same-sub-repo concurrency:** the first draft's "per-repo pool lease" misread `WorktreePool` — it's a *recycle cache* gated on `settings.recycleWorktrees` (`acquire(taskId)` returns an arbitrary idle path; `assertNotDoubleLeased` only fires on same-*path* reuse; never consulted with recycling off — `worktree-acquisition.ts:279`), with **no** repo-keyed cross-task exclusivity. So same-sub-repo serialization comes from the **file-scope lease** (overlapping in-repo scopes) and, for the disjoint-scope case, a dedicated **repo-path exclusivity registry** on `activeSessionRegistry` path-keying (which `runAiMerge` already uses) — implemented in **U2 (Phase A)**, at acquisition, so the guard never lags acquisition by a phase.
|
||||
|
||||
### KTD7 — Aggregated, repo-tagged `modifiedFiles`, review, and a per-repo `MergeResult` breakdown
|
||||
`captureModifiedFiles`, contamination, `verifyWorktreeInvariants`, and `reviewStep` iterate `task.workspaceWorktrees` and run inner git with cwd = each sub-repo (not rootDir). Modified-file lists carry repo prefixes. Review runs per-repo and aggregates verdicts. The aggregated `MergeResult` (today single-repo-shaped) must carry a **per-repo results array** so retry counters, audit, and the dashboard attribute failure to the right sub-repo; no consumer reads a scalar `merged` for completion — only `isWorkspaceTaskLanded`.
|
||||
|
||||
### KTD8 — Cross-repo merge atomicity = **land-as-you-go (local integration ref) + unconditional escape hatch** (DECIDED)
|
||||
**The merge advances a LOCAL ref, not a remote push (verified — feasibility + adversarial, round 3).** `runAiMerge`/`landSquash` advance the repo's local integration branch ref via `update-ref` CAS (`merger-ai.ts:817/847`); there is **no `git push` in the merge path** (the only engine `git push` is the separate PR-mode `pr-response-run-ops.ts`). Workspace mode matches this: each sub-repo's clean-room **lands on that repo's local integration ref** as it passes. Remote push is a separate, existing per-repo mechanism (PR flow / pull-integration-worktree), **out of scope** for U6.
|
||||
|
||||
Each repo lands independently; the task reaches done only when `isWorkspaceTaskLanded` is true. A forever-unmergeable repo (or a bad half-landed change) is handled by an operator **revert-landed-repo / force-complete** affordance with an audit event, which clears the per-repo `merged` flag atomically (KTD4); because landing is a local ref advance, this is a **clean local reset**, not a compensate-forward remote revert. **The escape hatch is unconditional.** Two-phase (dry-run-all-then-land) was rejected: it adds real cost (holding N clean-rooms through a barrier; `runAiMerge` has no dry-run-without-landing primitive) for a coherence guarantee that the local-ref model already makes cheap to reset. Land-as-you-go is the natural fit for the clean-room model.
|
||||
|
||||
> **Merge order:** with local-ref-only landing, order is **low-stakes** — a partial state is local and operator-resettable, and nothing reaches a shared remote from the merge. The loop may iterate `workspaceWorktrees` in arbitrary (key) order for v1. Dependency-aware ordering (callee/API repos before callers) is an *optional* future refinement, relevant only if/when a remote-push step is added; recorded as a non-blocking note, not v1 work.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Workspace task lifecycle (one task, two sub-repos; push-as-you-go on the sole `runAiMerge` path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Ex as TaskExecutor
|
||||
participant WS as workspace root (non-git, browse-only)
|
||||
participant A as wolf-server worktree
|
||||
participant B as wolf-frontend worktree
|
||||
participant Mg as runAiMerge (sole path, per-repo clean-room)
|
||||
participant Core as @fusion/engine landed predicate
|
||||
|
||||
Ex->>Ex: loadWorkspaceConfig(rootDir) → present
|
||||
Ex->>WS: session cwd = workspace root (SKIP root acquire + all rootDir preflights)
|
||||
Note over Ex: agent browses, decides it needs repo A
|
||||
Ex->>A: fn_acquire_repo_worktree("wolf-server")
|
||||
A-->>A: acquireTaskWorktree(repoAbs) + identity guard + baseSha_A(localIntegration) + repo-path exclusivity
|
||||
Ex->>B: fn_acquire_repo_worktree("wolf-frontend")
|
||||
B-->>B: acquireTaskWorktree(repoAbs) + identity guard + baseSha_B(localIntegration) + repo-path exclusivity
|
||||
Note over Ex: agent commits in A and B; fn_task_done
|
||||
Ex->>A: captureModifiedFiles(baseSha_A, cwd=A) + review(A)
|
||||
Ex->>B: captureModifiedFiles(baseSha_B, cwd=B) + review(B)
|
||||
loop each entry in workspaceWorktrees (land-as-you-go, local ref)
|
||||
Mg->>A: landOneRepo(wolf-server): clean-room(repoAbs) + file-scope(strip prefix) + squash → advance wolf-server LOCAL integration ref (CAS)
|
||||
Note over Mg: persist workspaceWorktrees[A].merged=true (atomic), DON'T finalize task
|
||||
Mg->>B: landOneRepo(wolf-frontend): clean-room(repoAbs) + squash → advance wolf-frontend LOCAL integration ref (CAS)
|
||||
Note over Mg: persist workspaceWorktrees[B].merged=true (atomic)
|
||||
end
|
||||
Mg->>Core: isWorkspaceTaskLanded(task)?
|
||||
Core-->>Mg: true only if ALL entries merged on their target → finalize task → done
|
||||
Note over Mg: stuck repo → operator revert/force-complete (clean LOCAL reset; clears merged atomically)
|
||||
Note over Mg: remote push = separate existing per-repo step, OUT OF SCOPE
|
||||
```
|
||||
|
||||
### Single-worktree → multi-repo invariant inventory
|
||||
|
||||
The surface-enumeration spine (FN-5893). Every row is a single-worktree / `cwd:rootDir` assumption that must become per-repo; the U-ID column maps each to the unit that fixes it.
|
||||
|
||||
| Surface | Location | Today (singular) | Workspace behavior | Unit |
|
||||
|---|---|---|---|---|
|
||||
| Merge dispatch | `project-engine.ts:2280-2282` | `mergerMode==="ai" ? runAiMerge : aiMergeTask` | always `runAiMerge` (aiMergeTask `@deprecated`) | U0 |
|
||||
| Extra `aiMergeTask` callers | `cli/.../dashboard.ts:~1330` (`--no-engine` `onMergeImpl`), `cli/.../task.ts:~854` (`fn task merge`) | call `aiMergeTask` directly, bypassing dispatch | route to `runAiMerge` or workspace-guard | U0 |
|
||||
| Main acquisition | `executor.ts:~7430` | `acquireTaskWorktree({rootDir})` always | Skip when workspaceConfig | U1 |
|
||||
| Intervening preflights | `executor.ts:7414→8443` | identity guard, contamination `:7536`, base capture `:7525`, verify | all gated off in workspace mode | U1 |
|
||||
| Session cwd | `executor.ts:8443-8494` | `cwd: worktreePath` | `cwd: rootDir` (browse-only) | U1 |
|
||||
| `activeWorktrees` map (+~15 consumers) | `executor.ts:7667`, `:1585`,`:14491`,`:14518`, FN-6736 reclaim `:2055` | `taskId → one path`; `===` liveness | `taskId → set`; membership semantics at each consumer | U1 |
|
||||
| Identity-guard hooks | `executor.ts:14034` | installed in root worktree | installed per sub-repo at acquire | U2 |
|
||||
| Init/setup + same-repo exclusivity | `worktree-acquisition.ts:~633` | once at root; no exclusivity | per sub-repo at acquire; repo-path exclusivity registry (KTD6) | U2 |
|
||||
| Base-commit capture | `base-commit-capture.ts:26` (hardcodes `main`) | one `baseCommitSha` vs `main` | per-repo `baseSha` vs resolved integration branch (KTD3) | U2 |
|
||||
| Same-sub-repo exclusivity | `activeSessionRegistry` path-keying (NOT `worktree-pool.ts` — recycle cache, not a lock) | none for sub-repos | repo-path exclusivity registry at acquisition (KTD6) | U2 |
|
||||
| Contamination base | `executor.ts:7536` `assertCleanBranchAtBase(rootDir,…)` | one base, cwd rootDir | per-repo, cwd sub-repo | U3 |
|
||||
| Modified-files capture | `executor.ts:7853`, `:12198` | one diff | iterate worktrees, repo-tagged, cwd sub-repo | U3 |
|
||||
| `verifyWorktreeInvariants` (called by `fn_task_done`) | `executor.ts:10830` (one call site), `:12498` | one worktree | per acquired worktree | U3/U4 |
|
||||
| Review | `executor.ts:11169`, `reviewer.ts` | one worktree diff | per-repo passes, aggregated | U4 |
|
||||
| Landed predicate | route + merger + self-healing | column / one branch | `@fusion/engine` conjunction (KTD4) | U5 |
|
||||
| **Merge entry (sole path)** | `merger-ai.ts` `runAiMerge`: clean-room `:172` prefix, `finalizeMerged`/`finalizeTask` `:1194/1285/1384`, `{empty:true}` `:1174` | single-repo clean-room, terminal finalize | per-repo clean-room via `landOneRepo` seam; loop+finalize gated on predicate | U6 |
|
||||
| Clean-room parent dir | `merger-ai.ts` `finalizeMerged`/`landSquash` take `projectRootDir` | clean-room + local-sync at rootDir | pass `repoAbsPath` per repo; temp-prefix made repo-aware | U6 |
|
||||
| File-scope squash overlap | `merger.ts:4935-5099` | one staged set vs unified scope | per-repo filtered scope, **prefix stripped** (KTD5) | U6 |
|
||||
| `store.mergeTask` (3rd merge path + cleanup) | `store.ts:11150` checkout+squash+commit+remove at rootDir `:11256`; called `executor.ts:1742`/`self-healing.ts:5830` | full merge in rootDir, remove one worktree/branch | gate/convert per-repo, cwd sub-repo (or block workspace tasks from both callers) | U6 |
|
||||
| File-scope leases | `scheduler.ts:1373-1450` | `Map<taskId, scope[]>` | compare-time repo-prefix skip (KTD5) | U7 |
|
||||
| `reconcileTaskWorktreeMetadata` | `self-healing.ts:3974` | rebind one worktree | reconcile each entry, per-repo cwd | U8 |
|
||||
| `reclaimStaleActiveBranches` | `self-healing.ts:3291` | one `fusion/<id>` branch | per sub-repo, keyed `(repo, fusion/<id>)` | U8 |
|
||||
| `reconcileInReviewBranchRebind` | `self-healing.ts:3786` | skips ambiguous; SHA-dedup in one rootDir | per-repo rebind; scope dedup to correct sub-repo | U8 |
|
||||
| `reclaimSelfOwnedBranchConflicts` | `self-healing.ts:2739` | one worktree usability | per sub-repo | U8 |
|
||||
| `reclaimPrConflicts` | `self-healing.ts:2515` | one worktree | per sub-repo | U8 |
|
||||
| `reconcileCompletedTask` | `self-healing.ts:3555` | one worktree on complete | conjunction-aware | U8 |
|
||||
|
||||
---
|
||||
|
||||
## Output / Field Additions
|
||||
|
||||
Additive only — no migration to existing single-repo tasks:
|
||||
|
||||
```ts
|
||||
Task.workspaceWorktrees: Record<repoRelPath, {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
baseCommitSha?: string; // NEW (KTD3) — per-repo, vs resolved integration branch
|
||||
merged?: boolean; // NEW (KTD4) — set on land, cleared atomically on revert
|
||||
mergeTargetBranch?: string; // NEW (KTD4) — the repo's integration branch the squash landed on
|
||||
}>
|
||||
```
|
||||
|
||||
`@fusion/engine` new export: `isWorkspaceTaskLanded(task): boolean` (and the shared repo-prefix-derivation helper). `MergeResult` gains an optional `perRepo: Array<{ repo, merged, branch, error? }>` breakdown (KTD7).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Standing requirements for every unit:** add `FNXC:Workspace <yyyy-MM-dd-hh:mm>` comments (jsdoc-preferred) at each non-obvious decision point. Add a `.changeset/*.md` (`@runfusion/fusion: minor`). Per-repo work must emit **persisted** audit events on every acquisition/reconcile/merge failure path. Update the AGENTS.md **Run Audit** section with every new `task:*-workspace-*` event (enumerate exact names — the FN-6230 auto-close gate matches on these strings). All git execution that today targets `cwd: rootDir` must be re-targeted to the per-repo `repoAbsPath` — a per-repo loop wrapper is insufficient if inner git calls still target rootDir.
|
||||
|
||||
### U0. Merger unification — make `runAiMerge` the sole path, soft-deprecate `aiMergeTask`
|
||||
|
||||
**Goal:** Collapse merge onto `runAiMerge` so all downstream workspace work targets one canonical path.
|
||||
|
||||
**Requirements:** KTD0.
|
||||
|
||||
**Dependencies:** none (lands first, Phase 0).
|
||||
|
||||
> **Standalone-decision framing (review):** U0 is a system-wide merge change — it routes **every** task in **every** project through `runAiMerge` (clean-room + AI merge + AI reviewer), not just workspace tasks. It is worth doing on its own merits (single canonical merge path) even if workspace mode were cancelled, and it ships as its own Phase 0 PR with its own review and rollback story. Reviewers should evaluate "all merges become clean-room" as its own decision, not as workspace-mode plumbing.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/project-engine.ts` (`:2275-2282` — drop the `mergerMode` ternary; always `runAiMerge`)
|
||||
- `packages/cli/src/commands/dashboard.ts` (`~:1330` `onMergeImpl`, the `--no-engine` UI-only merge — currently calls `aiMergeTask` directly, `const`, despite the stale `:1299` comment; route to `runAiMerge` or workspace-guard)
|
||||
- `packages/cli/src/commands/task.ts` (`~:854` `runTaskMerge`, the `fn task merge` CLI command — calls `aiMergeTask` directly; route to `runAiMerge` or workspace-guard)
|
||||
- `packages/engine/src/merger.ts` (`aiMergeTask` + now-dead helpers → `@deprecated`; body retained for a later deletion pass)
|
||||
- `packages/core/src/types.ts` (`:505` `settings.merger.mode` — retire/alias; this is published `@runfusion/fusion` surface, needs a changeset)
|
||||
- `packages/engine/src/__tests__/` (update/retire `aiMergeTask`-specific tests; assert all entry points route to `runAiMerge`)
|
||||
|
||||
**Approach:** Replace the dispatch with an unconditional `runAiMerge` call, **and** route the two direct CLI/dashboard callers (`onMergeImpl`, `runTaskMerge`) the same way — collapsing only the engine dispatch leaves two live production `aiMergeTask` callers. Mark `aiMergeTask` `@deprecated` with a pointer to `runAiMerge`; do **not** delete the body yet (soft delete). For `merger.mode`: keep accepting it, ignore `"deterministic"`, log a one-time deprecation warning.
|
||||
|
||||
**Deterministic-mode blast-radius audit (do this, don't assert):** before claiming low blast radius, grep test fixtures, CI configs, and seeded project settings for `merger.mode === "deterministic"` (and `testMode`/mock interactions that may depend on `aiMergeTask`'s non-AI deterministic output) and enumerate which suites assert that behavior. Cite the result. Expectation is "effectively unused" (the user confirmed this for their projects), but the audit must confirm it rather than the plan asserting it.
|
||||
|
||||
**R7 merge-boundary guard lands here (moved from U1, review):** because U0 is Phase 0 and collapses the dispatch *before* U1, add the merge-boundary guard in U0 — reject any workspace task (populated `workspaceWorktrees`) from entering any merge path (`runAiMerge`, `store.mergeTask`, the CLI callers) with a clear error naming U6 as required. Otherwise a workspace task reaching `in-review` in the U0→U1 window crashes at `git rev-parse refs/heads/<integration>` against the non-git root. **U6 removes the guard** when the per-repo loop lands.
|
||||
|
||||
**Test scenarios:**
|
||||
- Every entry point (engine dispatch, `onMergeImpl`, `runTaskMerge`), any `mergerMode` value → routes to `runAiMerge`. (behavior unification across all callers)
|
||||
- A project previously on `"deterministic"` → routed to `runAiMerge` with a deprecation warning, not an error. (migration)
|
||||
- A workspace task reaching merge before U6 → held with a clear error naming U6 (R7 guard, all entry points). (safety floor in the U0→U1 window)
|
||||
- Existing `runAiMerge` single-repo behavior unchanged. (regression)
|
||||
|
||||
**Verification:** All merge entry points route to `runAiMerge`; `aiMergeTask` is unreachable in production and marked deprecated; the deterministic-mode audit is cited; the R7 guard blocks workspace tasks from every merge path until U6.
|
||||
|
||||
---
|
||||
|
||||
### U1. Workspace-mode session scoping — skip root acquisition + all rootDir preflights, browse-only root cwd
|
||||
|
||||
**Goal:** In workspace mode, skip the main `acquireTaskWorktree` *and every preflight between the workspace guard and session create*, run the session with cwd = workspace root, and tolerate no singular `task.worktree`.
|
||||
|
||||
**Requirements:** KTD1, KTD2.
|
||||
|
||||
**Dependencies:** U0 (the R7 merge-boundary guard lands in U0; U1 builds on the unified single merge path).
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` (acquisition `~:7430`, preflights `:7525`/`:7536`/identity guard, session create `~:8443-8494`, `activeWorktrees` `:7667` + consumers `:1585`/`:14491`/`:14518`/`:2055`, retry session `~:8935`)
|
||||
- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — currently `vi.mock`s the functions under test; build the real two-repo fixture harness here so Phase A and all later units use it)
|
||||
- `packages/engine/src/__tests__/executor-workspace-session.test.ts` (new)
|
||||
|
||||
**Approach:** Gate the `~:7430` acquisition behind `!this.workspaceConfig`, and gate each intervening preflight (identity guard install, `resolveContaminationBaseRef`, `captureBaseCommitSha`, `verifyWorktreeInvariants`) so none runs against the non-git root. Set session cwd = `this.rootDir`; do not set `task.worktree`. Convert `activeWorktrees` to `taskId → Set<path>` and update each enumerated consumer (`findActiveWorktreeOwner`, `hasActiveWorktreeBinding`, `getActiveWorktreeHolders`, FN-6736 phantom-binding reclaim) to membership semantics. Make `scopePromptToWorktree` a no-op in workspace mode. Leave the singular path byte-for-byte unchanged when `workspaceConfig` is absent.
|
||||
|
||||
> **R7 guard:** the merge-boundary guard now lands in **U0** (Phase 0, before this unit) so the U0→U1 window is covered; U1 must not reintroduce a path around it.
|
||||
|
||||
**Patterns to follow:** the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`.
|
||||
|
||||
**Execution note:** Build the real-fixture harness (two temp git repos) here — do not extend the foundation's self-mocking pattern.
|
||||
|
||||
**Test scenarios:**
|
||||
- Workspace config present → main `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path)
|
||||
- Non-workspace task → acquisition + all preflights called exactly as before; `cwd === worktreePath`. (regression)
|
||||
- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths. (integration)
|
||||
- Retry session in workspace mode uses `cwd === rootDir`. (edge)
|
||||
- Workspace task acquiring zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`; completion boundary defined (see U5). (edge/empty)
|
||||
- (R7 merge-boundary guard is tested in U0, where it now lives.)
|
||||
|
||||
**Verification:** A workspace task starts a session rooted at the workspace dir with no root worktree and no rootDir git preflight; a single-repo task is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### U2. Per-repo acquisition hardening — identity guard, init/setup, same-repo exclusivity, base-commit capture
|
||||
|
||||
**Goal:** Make `acquireWorkspaceRepoWorktree` install identity-guard hooks, register same-sub-repo exclusivity, and capture a per-repo `baseCommitSha` against the repo's **resolved** integration branch.
|
||||
|
||||
**Requirements:** KTD3, KTD6.
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`)
|
||||
- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it currently hardcodes `main`)
|
||||
- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`)
|
||||
- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD6; NOT `worktree-pool.ts`)
|
||||
- `packages/core/src/types.ts` (extend `workspaceWorktrees` entry with `baseCommitSha`)
|
||||
- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real git fixture)
|
||||
|
||||
**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard; (2) resolve the repo's integration branch via `resolveIntegrationBranch(repoAbsPath, settings)` and capture `baseCommitSha` via the **extended** `resolveCapturedBaseCommitSha(worktreePath, integrationBranch)`; (3) persist `baseCommitSha`; (4) register same-sub-repo exclusivity in the repo-path registry (KTD6) at Phase A, where the contention is created. Idempotent across `(taskId, repo)` and any global branch-name/worktree-path uniqueness.
|
||||
|
||||
> **Integration-branch caveat:** `resolveIntegrationBranch(rootDir, settings)` resolves `settings.integrationBranch` first, then the dir's `origin/HEAD`. Per-repo resolution must let each sub-repo fall through to its own `origin/HEAD` rather than inheriting a shared `settings.integrationBranch` override, unless the workspace genuinely shares one integration branch name.
|
||||
|
||||
**Execution note:** Real two-repo git fixture; commit-without-pushing to exercise local-ahead-of-origin.
|
||||
|
||||
**Test scenarios:**
|
||||
- Acquiring repo A captures `baseSha_A` = local integration tip even when `origin/<integration>` is behind. (happy path + R3 regression)
|
||||
- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 + caveat)
|
||||
- Identity-guard hook present; a commit on a non-`fusion/<id>` branch is rejected. (integration)
|
||||
- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the repo-path exclusivity registry. (concurrency — KTD6)
|
||||
- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency)
|
||||
- Acquisition failure persists an audit event and surfaces an error. (error path)
|
||||
|
||||
**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition.
|
||||
|
||||
---
|
||||
|
||||
### U3. Per-repo modified-files capture, contamination, worktree-invariant verification
|
||||
|
||||
**Goal:** Iterate `workspaceWorktrees` for modified-files capture, contamination, and `verifyWorktreeInvariants`, running inner git with cwd = each sub-repo.
|
||||
|
||||
**Requirements:** KTD7.
|
||||
|
||||
**Dependencies:** U2.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` (`captureModifiedFiles` `~:7853`/`:12198`, contamination `assertCleanBranchAtBase` `:7539` — **rewire cwd to sub-repo**, `verifyWorktreeInvariants` `:10830`/`:12498`)
|
||||
- `packages/core/src/types.ts` (`modifiedFiles` carries repo-prefixed paths)
|
||||
- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real git fixture)
|
||||
|
||||
**Approach:** Loop over `workspaceWorktrees`; for each repo run `git diff <baseSha>..HEAD` with cwd = that worktree, collect repo-prefixed files, aggregate into `task.modifiedFiles`. Run contamination + `verifyWorktreeInvariants` per worktree (cwd sub-repo). Skip the singular path in workspace mode.
|
||||
|
||||
**Test scenarios:**
|
||||
- Edits in repo A and B → `modifiedFiles` carries repo-prefixed paths from both. (happy path)
|
||||
- A worktree HEAD drifted off `fusion/<id>` → verify reports the offending repo. (error path)
|
||||
- Contamination check runs against the sub-repo, not rootDir. (the cwd correction)
|
||||
- Repo acquired, no edits → zero files, no error. (empty)
|
||||
- Single-repo task → identical to today. (regression)
|
||||
|
||||
**Verification:** Capture/verify cover all acquired worktrees with repo context and correct cwd.
|
||||
|
||||
---
|
||||
|
||||
### U4. Per-repo review and `fn_task_done` completion verification
|
||||
|
||||
**Goal:** Review per sub-repo and verify completion invariants across all acquired worktrees before `fn_task_done` succeeds.
|
||||
|
||||
**Requirements:** KTD7.
|
||||
|
||||
**Dependencies:** U3.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` (`reviewStep` `:11169`, `createReviewStepTool` `:8296`, `createTaskDoneTool` `:8279`/`:10830`)
|
||||
- `packages/engine/src/reviewer.ts` (per-repo worktree/diff context, aggregate verdicts)
|
||||
- `packages/engine/src/__tests__/reviewer-workspace.test.ts` (new)
|
||||
|
||||
**Approach:** In workspace mode `reviewStep` iterates `workspaceWorktrees`, one reviewer pass per repo with that repo's diff and prefix-stripped File Scope subset; aggregate repo-tagged verdicts. `fn_task_done` calls `verifyWorktreeInvariants` for every acquired worktree and blocks on any dirty/misbound repo or uncommitted in-scope change.
|
||||
|
||||
**Test scenarios:**
|
||||
- Two-repo task → two reviewer passes; reviewed only when both pass. (conjunction)
|
||||
- One repo has an uncommitted in-scope change at `fn_task_done` → blocked, naming the repo. (error path)
|
||||
- Reviewer finding in repo B is repo-tagged. (integration)
|
||||
- Single-repo task → one pass, unchanged. (regression)
|
||||
|
||||
**Verification:** Reviewed/complete only when every sub-repo passes review and invariant checks.
|
||||
|
||||
---
|
||||
|
||||
### U5. Shared `@fusion/engine` "landed" conjunction predicate + repo-prefix helper
|
||||
|
||||
**Goal:** Define the multi-repo completion predicate and the shared repo-prefix helper once in `@fusion/engine`.
|
||||
|
||||
**Requirements:** KTD4, KTD5.
|
||||
|
||||
**Dependencies:** U2.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/workspace-completion.ts` (new — `isWorkspaceTaskLanded` + the repo-prefix-derivation helper, so U6 and U7 both import from one home) + export from the engine index
|
||||
- `packages/core/src/types.ts` (extend entry with `merged`/`mergeTargetBranch`)
|
||||
- `packages/engine/src/__tests__/workspace-completion.test.ts` (new)
|
||||
|
||||
**Approach:** `isWorkspaceTaskLanded(task)` returns true only when **every** entry has `merged === true` and `mergeTargetBranch === <that repo's resolved integration branch>`. Reads stored row data only.
|
||||
|
||||
**Empty / no-op resolution (two cases, one rule):** (a) *zero acquisitions* → no-op done, consistent with U1's zero-acquire edge. (b) *acquired-but-unedited entry* (acquire repo A, edit nothing) → the entry exists with `merged=undefined`, so a naive conjunction returns `false` forever, stranding a fresh worktree+branch+registration; the rule: an acquired entry whose merge produces no net change resolves to `merged=true` (no-op) and its worktree/branch/registration is reclaimed.
|
||||
|
||||
> **Empty authority = tip-relative, not `baseSha..HEAD` (review).** `runAiMerge` computes "empty" as `!squashSha` — no net change vs the **current local integration tip** (`merger-ai.ts:1054/1126`), and `mergeAndReview` rebuilds the clean-room on the *new* tip if another task advanced it (`:1188`). U3/KTD3's `baseSha..HEAD` per-repo diff can disagree (e.g. HEAD==baseSha but the tip moved). **The tip-relative `!squashSha` result is the authority**; U5's orphan resolution must defer to U6's tip-relative outcome, not to the stale `baseSha..HEAD` diff — so the short-circuit holds even when another task advanced the integration tip (it rebuilds on the new tip and re-lands nothing). U6 owns the actual short-circuit; U5's predicate reads the resulting `merged` flag. (Note: U6 cannot reuse `finalizeMerged({empty:true})` directly — it finalizes the whole task; see U6.)
|
||||
|
||||
**Test scenarios:**
|
||||
- All entries `merged` on the right target → `true`. (happy path)
|
||||
- One `merged`, one not → `false`. The lost-work case. (critical)
|
||||
- `merged` but wrong `mergeTargetBranch` → `false`. (anchor correctness)
|
||||
- Zero `workspaceWorktrees` → no-op-done, consistent with U1. (empty state)
|
||||
- Acquired-but-unedited entry (empty diff) → resolves `merged=true` (no-op), not stranded `false`. (orphan-prevention — joint with U6)
|
||||
- Non-workspace task → delegating caller uses the scalar check, unchanged. (regression)
|
||||
|
||||
**Verification:** One source of truth for completion; both empty cases resolve consistently across U1/U5/U6 with no orphans.
|
||||
|
||||
---
|
||||
|
||||
### U6. Workspace-aware `runAiMerge` — per-repo clean-room loop, `landOneRepo` seam, push-as-you-go, escape hatch
|
||||
|
||||
**Goal:** Rework the sole merge path (`runAiMerge`) so each sub-repo's clean-room lands on that repo's **local** integration ref independently (no remote push — KTD8), the task finalizes only on the conjunction, with crash-safe re-entry and an operator escape hatch. Also gate the third merge path (`store.mergeTask`) for workspace tasks.
|
||||
|
||||
**Requirements:** KTD2, KTD4, KTD5, KTD7, KTD8.
|
||||
|
||||
**Dependencies:** U0, U5.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/merger-ai.ts` (`runAiMerge`, clean-room prefix `:172`, `finalizeMerged`/`finalizeTask` `:1194/1285/1384`, `{empty:true}` `:1174`)
|
||||
- `packages/engine/src/merger.ts` (file-scope check `:4935-5099`)
|
||||
- `packages/engine/src/project-engine.ts:2281` (dispatch — confirm workspace tasks route correctly post-U0)
|
||||
- `packages/core/src/store.ts` (`mergeTask` `~:11150` — the 3rd merge path: `checkout`/`squash`/`commit` `:11256` + worktree removal, `runGitCommand` pins `cwd:rootDir` `~:10989`)
|
||||
- `packages/engine/src/executor.ts:1742` (`finalizeAlreadyInReviewTask` — gate workspace tasks away from `store.mergeTask`)
|
||||
- `packages/engine/src/self-healing.ts:5830` (no-`enqueueMerge`-queue fallback — same gate)
|
||||
- `packages/engine/src/workspace-completion.ts` (import the predicate)
|
||||
- `packages/engine/src/__tests__/merger-workspace.test.ts` (new — real two-repo fixture)
|
||||
|
||||
**Approach — the `landOneRepo` seam (the core blocker).** `runAiMerge` is a single terminal pipeline: a successful merge falls into `finalizeMerged` (`:1194/1285`) → `finalizeTask` → `store.moveTask(taskId,'done')` (`:1364/1384`). Extract a `landOneRepo(repoAbsPath, entry)` step (clean-room + mergeAndReview + landSquash + `store.updateTask({workspaceWorktrees})` setting `merged=true`/`mergeTargetBranch` **atomically**) that **lands the local integration ref but does NOT finalize the task**; drive the per-repo loop + final `moveToDone` from a workspace-aware caller gated on `isWorkspaceTaskLanded`. Specifics the seam must handle:
|
||||
- `finalizeMerged` inseparably removes the **singular `task.worktree`** (`:1345`) and deletes the task branch before `moveTask`. Split it so `landOneRepo` removes the **per-entry `workspaceWorktrees[repo]`** worktree/branch itself — do **not** leave per-repo worktree cleanup to `store.mergeTask` (a naive extraction would leave every sub-repo worktree un-removed, since `runAiMerge` removes worktrees inside `finalizeMerged`, not via `store.mergeTask`).
|
||||
- `finalizeMerged`/`landSquash` take `projectRootDir` as clean-room parent + local-sync checkout — pass `repoAbsPath` per repo.
|
||||
- The clean-room temp prefix `fusion-ai-merge-<taskId>-` (`:172`) is task-keyed — make naming + `pruneExistingAiMergeWorktrees` **repo-scoped** or the N clean-rooms collide.
|
||||
- `runAiMerge`'s no-branch lost-work guard (reads singular `task.baseCommitSha`/`task.mergeDetails`, unused in workspace mode) re-targets to `workspaceWorktrees[repo]`.
|
||||
|
||||
**`store.mergeTask` (the 3rd merge path):** in workspace mode, gate the two callers (`executor.ts:1742` `finalizeAlreadyInReviewTask`, `self-healing.ts:5830` no-queue fallback) so a workspace task does not reach `store.mergeTask`'s `git checkout`/`merge --squash` at the non-git root; route workspace finalization through the `landOneRepo` loop instead. If `store.mergeTask` must run for per-repo worktree cleanup, iterate `workspaceWorktrees` with cwd = each sub-repo.
|
||||
|
||||
**Sequencing (KTD8 — land-as-you-go, LOCAL ref):** each repo's `landOneRepo` advances that repo's **local integration ref via CAS** (no remote push — KTD8); persist `merged` atomically before the next; re-entry skips entries already `merged===true` (the persisted flag is the signal — no live re-derivation, KTD4). Loop order is arbitrary/key-order for v1 (local-ref window is operator-resettable — KTD8). Per-repo file-scope check uses the **prefix-stripped** filtered scope (KTD5). **Empty per-repo case:** a repo whose merge yields `!squashSha` (no net change vs the rebuilt tip — the authority, see U5) sets `merged=true` and reclaims its worktree **via the same land/finalize split — NOT by calling `finalizeMerged({empty:true})` directly**, which would `moveTask('done')` the whole task. Aggregate a `MergeResult.perRepo` breakdown. **Operator escape hatch (unconditional):** `revert-landed-repo`/`force-complete` does a clean **local** reset and clears `merged`/`mergeTargetBranch` atomically (KTD4) with an audit event. **Remove the R7 guard** (now in U0) here once the loop is the gate; add a test confirming a workspace task reaches the merger after U6.
|
||||
|
||||
**Execution note:** Start with a failing two-repo merge contract test (both land on their own mains; task done only after both; crash between repos resumes correctly). Characterize existing `runAiMerge` single-repo behavior first.
|
||||
|
||||
**Test scenarios:**
|
||||
- Two-repo task, both clean → each clean-room advances its own **local integration ref** (no remote push); done via `isWorkspaceTaskLanded`; `perRepo` has both. (happy path)
|
||||
- Repo A lands (local ref), repo B conflicts → A `merged`, B not, task NOT done, `perRepo` names B; operator escape path exercised. (the data-safety case)
|
||||
- Crash after repo A persists `merged`, before repo B → re-entry skips A (persisted flag), resumes B, never re-lands A. (crash re-entry)
|
||||
- Operator revert-landed-repo on A → clean **local** reset; `merged`/`mergeTargetBranch` cleared atomically; `isWorkspaceTaskLanded` false; self-healing doesn't treat complete. (escape hatch / no drift)
|
||||
- Repo A acquired, no edits → `!squashSha` (tip-relative) short-circuits to `merged=true` via the land/finalize split (NOT `finalizeMerged({empty:true})`, which would finalize the whole task), worktree reclaimed, not stranded. (orphan-prevention — joint with U5)
|
||||
- Repo A acquired, no edits, **another task advanced A's integration tip** between acquire and merge → clean-room rebuilds on the new tip, still `!squashSha`/`merged=true`, does not re-land the other task's work. (tip-relative empty authority)
|
||||
- `landOneRepo` for repo A removes the **per-entry** `workspaceWorktrees[A]` worktree (not the singular `task.worktree`) and does not finalize the task. (finalize/cleanup split)
|
||||
- Workspace task routed to `store.mergeTask` (via `finalizeAlreadyInReviewTask` / self-healing no-queue fallback) is gated — does not `git checkout` the non-git root. (3rd-merge-path gating)
|
||||
- File-scope violation in repo B (path outside `wolf-frontend/**`) → `FileScopeViolationError` for B only, state reset. (invariant)
|
||||
- A path under `wolf-server/**` is NOT out-of-scope when merging `wolf-frontend` (per-repo filter + prefix strip). (false-positive fix)
|
||||
- N sub-repos' clean-rooms do not collide (repo-scoped temp prefix). (collision fix)
|
||||
- Workspace task reaches the merger after U6 (R7 guard removed). (dead-wiring prevention)
|
||||
- Single-repo task → `runAiMerge` unchanged. (regression)
|
||||
|
||||
**Verification:** `runAiMerge` lands each repo independently on its local integration ref via per-repo clean-rooms (no remote push), persists atomically, resumes after a crash, supports a clean local operator revert, gates `store.mergeTask` for workspace tasks, and finalizes only when all repos land; single-repo merges unaffected.
|
||||
|
||||
---
|
||||
|
||||
### U7. Per-repo file-scope leases (compare-time)
|
||||
|
||||
**Goal:** Skip cross-repo lease comparison at overlap-check time, without restructuring the lease map. (Same-sub-repo exclusivity for the disjoint-scope case is handled in U2 via the repo-path registry — KTD6.)
|
||||
|
||||
**Requirements:** KTD5.
|
||||
|
||||
**Dependencies:** U5 (imports the shared repo-prefix helper).
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/scheduler.ts` (overlap checks `~:1546`/`:1612` — derive repo prefix and skip cross-repo; leave `activeScopes` shape unchanged `:1373-1450`)
|
||||
- `packages/core/src/store.ts` (`parseFileScopeFromPrompt` — add a repo-prefix-aware accessor; keep the flat list working for non-workspace via `unscoped`)
|
||||
- `packages/engine/src/__tests__/scheduler-workspace-leases.test.ts` (new)
|
||||
|
||||
**Approach:** At overlap-check time, canonicalize each scope entry, derive its repo prefix via the U5 helper, and skip comparison when two entries belong to different repos. Non-workspace tasks use the `unscoped` sentinel and behave exactly as today.
|
||||
|
||||
**Test scenarios:**
|
||||
- Active task holds `wolf-frontend/**`; queued wants `wolf-server/**` → NOT blocked. (over-blocking fix)
|
||||
- Active holds `wolf-server/src/**`; queued wants `wolf-server/src/**` → blocked. (true overlap preserved)
|
||||
- A File Scope path whose first segment matches no configured repo → routes to `unscoped`, logged, not silently no-leased. (fallback)
|
||||
- Non-workspace tasks → lease behavior identical to today. (regression)
|
||||
|
||||
**Verification:** No false cross-repo blocking; same-repo overlap protection intact. (Disjoint-scope same-sub-repo serialization is verified in U2.)
|
||||
|
||||
---
|
||||
|
||||
### U8. Workspace-aware self-healing reconcilers
|
||||
|
||||
**Goal:** Make the worktree/branch reconcilers iterate `workspaceWorktrees`, run per-repo git (not rootDir), key candidates by `(repo, fusion/<id>)`, and stop mis-reclaiming multi-repo tasks.
|
||||
|
||||
**Requirements:** KTD2, KTD4.
|
||||
|
||||
**Dependencies:** U5, U6.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/self-healing.ts` — `reconcileTaskWorktreeMetadata` `:3974`, `reclaimStaleActiveBranches` `:3291`, `reconcileInReviewBranchRebind` `:3786` (runs `for-each-ref`/`show-ref` against `rootDir`), `reclaimSelfOwnedBranchConflicts` `:2739`, `reclaimPrConflicts` `:2515`, `reconcileCompletedTask` `:3555`
|
||||
- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new)
|
||||
- `AGENTS.md` (Run Audit section — **enumerate the exact new `task:*-workspace-*` event names**; the FN-6230 auto-close gate matches on these strings)
|
||||
|
||||
**Approach:** Branch each reconciler on `task.workspaceWorktrees`: verify/rebind/reclaim **each** entry, running git with cwd = the sub-repo and scoping candidate-matching + SHA-dedup to the correct sub-repo (so two repos that both have a `fusion/<id>` branch and divergent `main` are never matched across repos). Use `isWorkspaceTaskLanded` for completion. The in-review rebind no longer treats a multi-repo task as ambiguous. Preserve the `autoMerge:false` / live-session backward-move guards per repo. Emit a persisted audit event per workspace reconcile/reclaim.
|
||||
|
||||
**Execution note:** Characterize existing single-worktree reconciler behavior first; keep the scalar path for non-workspace tasks.
|
||||
|
||||
**Test scenarios:**
|
||||
- `reconcileTaskWorktreeMetadata` on a two-repo task with one stale entry → rebinds only the stale repo. (per-repo)
|
||||
- Two sub-repos each with a `fusion/<id>` branch + divergent `main` → candidate-matching never crosses repos. (the collision case)
|
||||
- `reconcileInReviewBranchRebind` no longer skips a workspace task as ambiguous. (deliberate-skip fix)
|
||||
- All-landed workspace task treated complete by `reconcileCompletedTask` (conjunction). (completion)
|
||||
- One-repo-unlanded workspace task under `autoMerge:false`/live session → not moved backward. (guard preserved)
|
||||
- Each reconcile/reclaim emits its persisted audit event. (observability)
|
||||
- Non-workspace tasks → every reconciler unchanged. (regression)
|
||||
|
||||
**Verification:** Reconcilers maintain multi-repo tasks per repo, never cross-match branches, and leave single-repo reconciliation unchanged.
|
||||
|
||||
---
|
||||
|
||||
### U9. End-to-end workspace harness (narrow)
|
||||
|
||||
**Goal:** One narrow end-to-end smoke test of a workspace task, on the real-fixture harness U1 introduced.
|
||||
|
||||
**Requirements:** all (verification backbone).
|
||||
|
||||
**Dependencies:** U1–U8.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — real two-repo fixture, mock AI provider)
|
||||
|
||||
**Approach:** Register a workspace, run a scripted-mock task that acquires both repos, edits + commits in each, calls `fn_task_done`, and asserts both branches merge to their own mains and the task lands via `isWorkspaceTaskLanded`. **FN-5048 discipline:** decompose most coverage into per-seam tests (U2/U3/U4/U6 each own theirs); this e2e is a *narrow smoke* — fixture → acquire×2 → merge → landed — with **fake timers, no real polling loops**, gated like `smoke:boot`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Full e2e: two-repo workspace task runs, edits both, merges both, lands — no real polling. (happy path smoke)
|
||||
- One-sub-repo workspace task completes (common case). (edge)
|
||||
|
||||
**Verification:** A workspace task runs end-to-end without real polling; per-seam invariants are covered by their own units.
|
||||
|
||||
---
|
||||
|
||||
### U10. Dashboard "doesn't look broken" floor for workspace tasks
|
||||
|
||||
**Goal:** Ensure the existing task views render workspace tasks (no `task.worktree`, populated `workspaceWorktrees`) without breakage. **Not** a full registration UI (deferred).
|
||||
|
||||
**Requirements:** KTD2.
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:**
|
||||
- Each component that reads `task.worktree`/`task.branch` for display (grep under `packages/dashboard/app/` and name them during implementation — task detail view and any task-row/summary).
|
||||
- `packages/dashboard/app/__tests__/` (new test asserting graceful render)
|
||||
- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note)
|
||||
|
||||
**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or hides the worktree/branch field when `task.worktree` is absent and `workspaceWorktrees` is populated. **Scope ceiling:** "doesn't look broken" only — a placeholder or flat per-repo path list, NOT a new rich per-repo-status component (that is the deferred registration UI).
|
||||
|
||||
**Non-atomic-semantics note (review):** add a one-line note to `CONCEPTS.md` (or `docs/dashboard-guide.md`) stating that workspace-task merges are **non-atomic**: each sub-repo lands on its own local integration ref independently, a partial-land window is possible mid-task, and it is local + operator-resettable (nothing reaches a shared remote from the merge). Sets the expectation at the point of use without expanding U10 into the deferred registration UI.
|
||||
|
||||
**Test scenarios:**
|
||||
- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list, no crash. (happy path)
|
||||
- Single-repo task → unchanged. (regression)
|
||||
|
||||
**Verification:** Workspace tasks are observable (not broken) in the dashboard at every execution stage.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** merger unification onto `runAiMerge` (U0); the full execution lifecycle for one-task-spanning-repos — session scoping, per-repo acquisition hardening, capture/review, the per-repo clean-room merge loop, the shared landed predicate, per-repo leases + same-repo exclusivity, self-healing reconcilers, a narrow e2e, and a dashboard breakage floor.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Hard deletion of `aiMergeTask` (U0 is a soft deprecation; remove the body in a later pass once no references remain).
|
||||
- Full dashboard UI for registering/visualizing workspace projects and rich per-repo task status (U10 is only the breakage floor).
|
||||
- `fn init` ergonomics beyond auto-detect (interactive repo selection, exclusions).
|
||||
- Concurrency limits / fairness across many sub-repos in one task.
|
||||
- A `/ce-compound` "single-worktree invariant inventory → multi-repo equivalents" learnings doc once this lands (the invariant table is its seed).
|
||||
|
||||
### Outside this product's identity
|
||||
- Reusing the **branch-group** shared-branch machinery — workspace mode (N repos × 1 branch each) is a distinct axis from branch groups (N tasks × 1 shared branch); conflating them reintroduces the documented branch-group hazards.
|
||||
- The `kb→fn` brand rename (tracked separately).
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
All four design questions from the planning session are resolved:
|
||||
|
||||
- **D1 (merger unification, → U0).** `runAiMerge` becomes the sole merge path; `aiMergeTask` is soft-deprecated. Workspace mode targets one canonical path. *Rationale:* `aiMergeTask` is already the "legacy pipeline" and `"ai"` is the default, so the change is cheap and removes dual-path forks.
|
||||
- **D2 (atomicity, → KTD8/U6).** Land-as-you-go on each repo's **local integration ref** (no remote push — `runAiMerge` doesn't push), with an **unconditional** operator revert/force-complete escape hatch (a clean local reset). *Rationale:* the merge advances a local ref, so a partial state is local and cheap to reset; two-phase would cost N held clean-rooms for a guarantee the local-ref model already makes cheap. **Workspace mode is local-ref-only** — remote push stays the separate existing per-repo mechanism, out of scope (D5).
|
||||
- **D3 (coherence expectation, → KTD2).** Session-time coherence is accepted; a transient half-applied **local** integration state is operator-resolved. *Rationale:* because nothing is pushed to a shared remote by the merge, the window is local-only and the operator escape hatch fully restores it.
|
||||
- **D5 (merge mechanism, → KTD8, round 3).** Workspace mode matches `runAiMerge`'s **local integration ref advance**; it does **not** add per-repo remote push. *Rationale:* parity with the existing canonical merge path; remote push is handled by the separate PR/pull mechanisms per repo.
|
||||
- **D4 (scope, → whole plan).** Full N>1 end-to-end in one plan (thin-N=1-slice alternative considered and declined).
|
||||
|
||||
Residual sub-design items are now specified work, not open questions: the per-repo clean-room rework + `landOneRepo` seam (U6), the repo-scoped temp-worktree naming (U6), and the AGENTS.md Run-Audit event enumeration (U8).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 — Missed `cwd:rootDir` / per-repo site strands work (critical).** Post-unification the merge surface is one path (`runAiMerge`), but the `cwd:rootDir` sites in `store.mergeTask`, self-healing, and the clean-room parent dir remain. Mitigation: the invariant inventory is the enumeration checklist; `isWorkspaceTaskLanded` (U5) is the single completion chokepoint; every reconciler keeps an explicit non-workspace path; grep every scalar `task.worktree`/`task.branch`/`task.baseCommitSha` read **and every `cwd: rootDir`** before declaring done.
|
||||
- **R2 — Partial merge = silent data loss.** Mitigation: U5/U6 make "done" strictly conjunctive; U6 persists `merged` atomically per repo and supports crash re-entry; the operator escape hatch + atomic flag-clear (KTD4/KTD8) handle the stranded case. Partial-failure + crash-re-entry tests are mandatory.
|
||||
- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 + U2 capture local-first against the **resolved** integration branch (the existing helper hardcodes `main` — must be extended); regression test commits without pushing and uses a non-`main` integration branch.
|
||||
- **R4 — Merger unification touches all tasks (U0).** Routing every task through `runAiMerge` is a behavior change for any project still on `"deterministic"`. Mitigation: low blast radius (`"ai"` is already the default); soft deprecation keeps `aiMergeTask` callable; U0 tests the `"deterministic"`→`runAiMerge` migration path with a warning, not an error.
|
||||
- **R5 — Stranded half-merge.** Mitigation: the operator revert/force-complete escape hatch is in U6 **unconditionally**; because landing is a local integration-ref advance (D5), revert is a **clean local reset** (not a compensate-forward remote revert) and clears the `merged` flag atomically (KTD4); test the forever-unmergeable-B scenario.
|
||||
- **R6 — Refactor-vs-main churn / stale line anchors.** This rewrites `runAiMerge`/executor/self-healing while main keeps changing them; cited line numbers will drift. Mitigation: phase the work, keep the non-workspace path untouched, prefer symbol/function anchors over line numbers, follow `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`.
|
||||
- **R7 — Pre-U6 workspace task strands.** Mitigation: **U0** (Phase 0, before U1 — moved earlier in review to cover the U0→U1 window) adds a merge-boundary guard across all merge entry points (`runAiMerge`, `store.mergeTask`, the CLI callers) holding workspace tasks until U6; **U6 removes it** (with a test) when the per-repo loop becomes the gate.
|
||||
- **R8 — Same-sub-repo concurrency window.** Two concurrent workspace tasks can acquire the same sub-repo with disjoint in-repo scopes (file-scope leases don't catch them; the pool is a recycle cache, not a lock). Mitigation: the repo-path exclusivity registry is implemented in U2 (Phase A), at acquisition.
|
||||
- **R9 — Auto-merge confirmation gate on partially-landed workspace tasks.** The fast-path auto-merge gate (`project-engine.ts:1934-1992`) and `getTaskHardMergeBlocker` read singular `mergeDetails`/`mergeConfirmed`; their behavior for a workspace task with some entries `merged` and some not is untraced. Mitigation: U6/U8 must route these gates through `isWorkspaceTaskLanded` (the conjunction chokepoint), not the scalar fields; trace before Phase C.
|
||||
- **Dependency:** wire any new engine capability at all engine-construction sites (`daemon.ts`/`serve.ts`/`dashboard.ts`) per the branch-group dead-wiring learning.
|
||||
|
||||
---
|
||||
|
||||
## Phased Delivery
|
||||
|
||||
Single plan, five phases (each a reviewable PR-sized slice; the non-workspace path stays green throughout). The real-fixture test harness is built in Phase A (U1). All four design questions are decided, so nothing blocks Phase A.
|
||||
|
||||
- **Phase 0 — Merger unification:** U0 (`runAiMerge` becomes the sole path). Lands first so all workspace work targets one merge function.
|
||||
- **Phase A — Run + safety floor:** U1 (incl. harness rewrite + R7 merge guard), U2, U10.
|
||||
- **Phase B — Capture & review:** U3, U4.
|
||||
- **Phase C — Merge (per-repo clean-room):** U5, U6, U7. The hardest phase — the `runAiMerge` `landOneRepo` rework.
|
||||
- **Phase D — Heal & e2e:** U8, U9.
|
||||
|
||||
> Note: Phases A–B deliver no standalone *user-shippable* value — a workspace task that runs but cannot merge is not usable — so realized value is concentrated in Phase C/D. The R7 merge guard (now in **Phase 0 / U0**) keeps the interim safe (held, not stranded) from the moment the dispatch is unified. Per the D4 decision, the thin-N=1-slice alternative (which would front-load value) was declined in favor of the full build. Also: U1 (Phase A) gates root preflights off, but per-repo contamination/`verifyWorktreeInvariants` returns in U3 (Phase B) — do not run a workspace task for real until Phase B lands (or pull per-repo contamination forward into U2).
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Parent task + per-repo child tasks (rejected, user-confirmed).** One coordinator fans out a child per sub-repo, each on the untouched single-worktree path. Lower blast radius and fewer dual-path forks, but loses single-agent cross-repo coherence and adds cross-task dependency orchestration, and reworks the PR's existing foundation. Rejected because cross-repo coherence during execution is the motivating use case.
|
||||
- **Two-phase / dry-run-all-then-land merge (rejected, → KTD8).** Would narrow the incoherent window, but costs N held clean-rooms + a new validated-but-unlanded lifecycle state, since `runAiMerge` has no dry-run-without-landing primitive — and the local-ref-only model (D5) already makes a partial state cheap to reset, so the extra cost buys little. Land-as-you-go + escape hatch chosen instead.
|
||||
- **Per-repo remote push during merge (rejected, → D5).** Would publish each repo to its shared remote as it lands, making the partial-land window visible to other developers and the escape hatch a compensate-forward revert (can't unwind what others pulled). Rejected: `runAiMerge` is local-ref-only today; workspace mode keeps parity and leaves remote push to the existing separate per-repo mechanisms.
|
||||
- **Thin N=1 vertical slice first (considered, declined → D4).** Would front-load usable value and isolate the hard clean-room-per-repo redesign to a later increment, but the user chose full N>1 end-to-end.
|
||||
- **Reuse branch-group shared-branch machinery (rejected).** Branch groups model N tasks sharing 1 branch; workspace mode is 1 task across N repos each with its own branch. Data shapes don't align; the branch-group hazards are documented and severe.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- PR #1710 (`feat/workspace-multi-repo`) foundation diff; codebase verification on `pr-1710` (incl. `project-engine.ts:2280-2282` dispatch, `merger-ai.ts` `runAiMerge`/`finalizeMerged`/`{empty:true}`, `store.mergeTask` call sites).
|
||||
- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3.
|
||||
- `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md` → KTD4 (conjunction predicate; dead-wiring at all engine sites).
|
||||
- `docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md` → R1 (merge-gate fan-out).
|
||||
- `docs/solutions/logic-errors/branch-group-name-collision-strands-mission-triage.md` → KTD5/U2/U8 (idempotency across uniqueness dimensions; persisted audit on failure).
|
||||
- `docs/architecture.md` reconciler inventory (FN-4962, FN-5083/FN-6695, FN-4954, FN-4948, FN-5279) → U8.
|
||||
- `CONCEPTS.md` workspace definition; `AGENTS.md` File-Scope invariant, Surface Enumeration (FN-5893), slow-test (FN-5048), Run Audit (FN-6230 auto-close gate).
|
||||
- Codebase maps + three ce-doc-review rounds (this session): surfaced the default-merge-path concern (resolved by U0), the `cwd:rootDir` surface, the `runAiMerge` terminal-finalize seam, the pool-isn't-a-lock correction, the `merged`-flag drift, the per-repo base-commit / file-scope-prefix corrections, and — round 3 — the **local-ref-not-push** correction (KTD8/D5), `store.mergeTask` being a **third merge path**, U0's two extra `aiMergeTask` callers, the empty-diff tip-relative authority, and the U0→U1 guard window.
|
||||
- Planning-session decisions (D1–D5): merger unification onto `runAiMerge` (D1), land-as-you-go local-ref atomicity (D2), session-time local-state coherence (D3), full N>1 scope (D4), local-ref-only mechanism / no per-repo remote push (D5).
|
||||
191
docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md
Normal file
191
docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "refactor: Merger unification (U0) — make runAiMerge the sole merge path"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: refactor
|
||||
origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, U0 / Phase 0)
|
||||
depth: standard
|
||||
---
|
||||
|
||||
# refactor: Merger unification (U0) — make `runAiMerge` the sole merge path
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 0 / U0 of the workspace-mode master plan. Make `runAiMerge` (the FN-5633 clean-room AI merge path, **already the default**) the **sole** merge path and soft-deprecate `aiMergeTask` (the "legacy `deterministic` pipeline") — deprecate the `merger.mode` setting by making its value inert (keep the type and field; see KTD2). This is a **standalone merge-consolidation refactor** with its own review/rollback story — it routes *every* task in *every* project through `runAiMerge`, not just workspace tasks, and is worth doing even if workspace mode were cancelled. It lands first so all downstream workspace work targets one canonical merge function with no dual-path forks.
|
||||
|
||||
It also installs the **R7 workspace merge-boundary guard** at every merge entry point, so that once workspace tasks can be created (later phases) one reaching merge before the per-repo loop (U6 in the master plan) is held with a clear error rather than crashing against the non-git workspace root.
|
||||
|
||||
**Scope:** dispatch collapse + the two direct CLI/dashboard callers + `@deprecated` markers + `merger.mode` setting retirement + the blast-radius audit + the R7 guard. **Out of scope:** hard deletion of `aiMergeTask` (soft-deprecate only — body retained), and any per-repo / multi-repo merge logic (master-plan U6).
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Merge is dispatched at `packages/engine/src/project-engine.ts:2275-2282`:
|
||||
|
||||
```ts
|
||||
const mergerMode = normalizeMergerMode(settings.merger?.mode); // defaults to "ai"
|
||||
return mergerMode === "ai"
|
||||
? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)
|
||||
: aiMergeTask(store, cwd, taskId, mergerOptions);
|
||||
```
|
||||
|
||||
`"ai"` is the default (`normalizeMergerMode` returns `"ai"` for anything not exactly `"deterministic"`), so `runAiMerge` is already what most tasks hit. But `aiMergeTask` (`packages/engine/src/merger.ts`, the "legacy pipeline") is still reachable two ways the engine dispatch doesn't cover:
|
||||
- `packages/cli/src/commands/dashboard.ts:1302` `onMergeImpl` (the `--no-engine` UI-only merge) calls `aiMergeTask` directly at `:1330`.
|
||||
- `packages/cli/src/commands/task.ts:847` `runTaskMerge` (the `fn task merge` CLI command) calls `aiMergeTask` directly at `:854`.
|
||||
|
||||
So collapsing only the engine dispatch leaves two live `aiMergeTask` callers. U0 unifies all three onto `runAiMerge`, soft-deprecates `aiMergeTask`, and retires the now-meaningless `merger.mode` setting.
|
||||
|
||||
Separately, the master plan's later phases add workspace tasks (`task.workspaceWorktrees` populated) whose merge must go through a per-repo loop (master U6). Until that exists, a workspace task reaching any merge path would run `runAiMerge`/`store.mergeTask`/the CLI callers against the **non-git workspace root** and crash. U0 installs a guard at every merge entry point that rejects populated-`workspaceWorktrees` tasks with a clear error naming U6 — covering the window from U0 through master-plan U6.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
> **ID namespace note:** the `KTD1–KTD4` and `U1–U4` identifiers below are **local to this U0 implementation plan**. They decompose master-plan **U0** (Phase 0) and are a **separate namespace** from the master plan's `KTD0–KTD8` / `U0–U10`. When the master plan says "U6 removes the R7 guard," that's master-plan U6 — unrelated to this plan's U-IDs.
|
||||
|
||||
### KTD1 — Soft deprecation, not deletion
|
||||
Mark `aiMergeTask` and any helpers that become unreferenced `@deprecated` with a pointer to `runAiMerge`; **retain the bodies** for a later deletion pass. Rationale: keeps the diff reviewable and reversible; deletion is a separate follow-up once no references remain.
|
||||
|
||||
### KTD2 — Keep the `merger.mode` setting and type; ignore the `"deterministic"` value
|
||||
`MergerMode` / `MergerSettings.mode` (`packages/core/src/types.ts:508-519`) is **published `@runfusion/fusion` surface**. **Keep the type and the field** (removing them would be a breaking change) — only make the *value* inert: the dispatch ignores it and always calls `runAiMerge`, and logs a **one-time** deprecation warning when a resolved `merger.mode === "deterministic"` is observed. A changeset is required (minor — behavior change + deprecation). Rationale: avoids a breaking type removal while making the setting inert. "Deprecate/retire" in this plan means *inert*, never *removed*.
|
||||
|
||||
### KTD3 — R7 guard at every merge entry point, keyed on `task.workspaceWorktrees`
|
||||
The guard is a single shared predicate (e.g. `assertNotWorkspaceTaskMerge(task)`) called at the top of each merge entry point — the engine dispatch, `store.mergeTask`, `onMergeImpl`, and `runTaskMerge` — that throws a clear, named error (`Workspace task <id> cannot merge until per-repo merge support (master-plan U6) lands`) when `task.workspaceWorktrees` is non-empty. Rationale: one predicate, all doors; prevents the non-git-root crash in the U0→U6 window. **Master-plan U6 removes this guard** when the per-repo loop becomes the gate.
|
||||
|
||||
### KTD4 — Audit, don't assert, the deterministic blast radius
|
||||
Before claiming low blast radius, grep test fixtures, CI configs, and seeded/default project settings for `merger.mode` / `"deterministic"` and `testMode`/mock interactions, and cite the result in the PR. Expectation (user-confirmed for their projects): effectively unused. The audit confirms it rather than the plan asserting it.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3.
|
||||
>
|
||||
> **Standing requirements:** `FNXC:Workspace <yyyy-MM-dd-hh:mm>` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes.
|
||||
|
||||
### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge`
|
||||
|
||||
**Goal:** Every merge entry point calls `runAiMerge`; no production code path calls `aiMergeTask`.
|
||||
|
||||
**Requirements:** KTD2.
|
||||
|
||||
**Dependencies:** none.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/project-engine.ts` (`:2275-2282` — drop the `mergerMode` ternary; always `runAiMerge`; keep computing `mergeOptionsWithSettings`)
|
||||
- `packages/cli/src/commands/dashboard.ts` (`:1302` `onMergeImpl`, the `aiMergeTask` call at `:1330` → `runAiMerge`; update the `:1294-1298` comment; import at `:44`)
|
||||
- `packages/cli/src/commands/task.ts` (`:847` `runTaskMerge`, the `aiMergeTask` call at `:854` → `runAiMerge`; import at `:2`)
|
||||
- `packages/engine/src/__tests__/` (dispatch test — assert all entry points route to `runAiMerge`)
|
||||
|
||||
**Approach:** Replace the engine dispatch ternary with an unconditional `runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)`. Update `onMergeImpl` and `runTaskMerge` to call `runAiMerge` with the equivalent option shape they pass today — feasibility confirmed parity: `aiMergeTask` and `runAiMerge` share the `MergerOptions` interface (`merger.ts:5998`), both CLI callers pass only `agentStore`/`onAgentText` (both in `MergerOptions`, both consumed by `runAiMerge`), and `runAiMerge`'s 5th `deps` param defaults to `{}`, so the 4-arg calls are safe. **U2 implements the `"deterministic"` deprecation warning** (at the dispatch point); U1 just stops branching on the mode. Do not change `runAiMerge`'s own behavior.
|
||||
|
||||
**Patterns to follow:** the existing `runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)` call already in the `"ai"` branch.
|
||||
|
||||
**Test scenarios:**
|
||||
- Engine dispatch with `settings.merger.mode` unset / `"ai"` / `"deterministic"` → all three call `runAiMerge` (spy/mock the two merge fns, assert only `runAiMerge` is invoked). (behavior unification across modes)
|
||||
- `runTaskMerge` (the `fn task merge` command) invokes `runAiMerge`, not `aiMergeTask`. (CLI caller)
|
||||
- `onMergeImpl` (UI-only `--no-engine`) invokes `runAiMerge`, not `aiMergeTask`. (dashboard caller)
|
||||
- Existing single-repo `runAiMerge` behavior is unchanged (no regression in the `runAiMerge` unit tests). (regression)
|
||||
|
||||
**Verification:** A grep for `aiMergeTask(` in non-test production code returns zero call sites; all merge entry points route to `runAiMerge`.
|
||||
|
||||
---
|
||||
|
||||
### U2. Soft-deprecate `aiMergeTask` and retire the `merger.mode` setting
|
||||
|
||||
**Goal:** Mark `aiMergeTask` `@deprecated` (body retained) and make `merger.mode` inert with a one-time deprecation warning, plus a changeset.
|
||||
|
||||
**Requirements:** KTD1, KTD2.
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/merger.ts` (`aiMergeTask` + any helpers that U1 leaves unreferenced → `@deprecated` jsdoc pointing to `runAiMerge`; bodies retained)
|
||||
- `packages/core/src/types.ts` (`:505-519` — `MergerMode`/`MergerSettings.mode` jsdoc marks `"deterministic"` deprecated; do not remove the type)
|
||||
- `packages/engine/src/project-engine.ts` (one-time deprecation warning when a resolved `merger.mode === "deterministic"` is seen)
|
||||
- `.changeset/<name>.md` (`@runfusion/fusion: minor`)
|
||||
- `packages/engine/src/__tests__/` (warning-emission test)
|
||||
|
||||
**Approach:** Add `@deprecated` jsdoc to `aiMergeTask` and the helpers U1 orphaned (do not delete). **Confirm the live-helper set first:** `runAiMerge` (`merger-ai.ts:66`) imports `captureSingleCommitLandedMetadata` (defined in `merger.ts:6059`) from `merger.js` — that helper is **shared and must NOT be `@deprecated`**. Grep `merger-ai.ts`'s imports from `merger.js` to enumerate every helper `runAiMerge` still depends on, and exclude those from deprecation; only tag what is genuinely orphaned after U1. In `types.ts`, annotate `"deterministic"` as deprecated in the `MergerMode` jsdoc without changing the enum (avoids a breaking type change). Emit a single deprecation warning (guarded so it logs once per process, e.g. a module-level flag) when the dispatch resolves `"deterministic"`. Write the changeset describing the merge-path consolidation and the `merger.mode` deprecation.
|
||||
|
||||
**Test scenarios:**
|
||||
- A project resolving `merger.mode === "deterministic"` → routed to `runAiMerge` **and** a deprecation warning is logged exactly once per process (not an error, not repeated). This warning assertion lives in U2's test, not U1's dispatch test. (migration / warn-not-error)
|
||||
- `merger.mode` unset → no warning. (no false positives)
|
||||
- `aiMergeTask` retains its body and exports (callable, just unreferenced in production). (soft-delete invariant)
|
||||
|
||||
**Verification:** `aiMergeTask` is `@deprecated` but present; `"deterministic"` logs one warning and routes to `runAiMerge`; a changeset exists.
|
||||
|
||||
---
|
||||
|
||||
### U3. R7 workspace merge-boundary guard at every merge entry point
|
||||
|
||||
**Goal:** A populated-`workspaceWorktrees` task is rejected from every merge path with a clear error naming master-plan U6, covering the window until per-repo merge support lands.
|
||||
|
||||
**Requirements:** KTD3.
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/` (new shared predicate, e.g. `assertNotWorkspaceTaskMerge(task)` — throws a named error when `task.workspaceWorktrees` is non-empty)
|
||||
- `packages/engine/src/project-engine.ts` (call it at the top of the merge dispatch)
|
||||
- `packages/core/src/store.ts` (call it at the top of `mergeTask` `:11150` — the third merge path)
|
||||
- `packages/cli/src/commands/dashboard.ts` (`onMergeImpl`), `packages/cli/src/commands/task.ts` (`runTaskMerge`)
|
||||
- `packages/engine/src/__tests__/` (guard test across entry points)
|
||||
|
||||
**Approach:** One shared predicate reused at all four entry points (dispatch, `store.mergeTask`, `onMergeImpl`, `runTaskMerge`). It throws `Workspace task <id> cannot merge until per-repo merge support (master-plan U6) lands` when `task.workspaceWorktrees` has any entry. For non-workspace tasks it is a no-op, so single-repo behavior is unchanged. Add an `FNXC:Workspace` comment explaining the U0→U6 window the guard covers and that U6 removes it.
|
||||
|
||||
**Test scenarios:**
|
||||
- A task with two `workspaceWorktrees` entries → each of the four entry points throws the named error mentioning U6; no `git checkout` runs against the root. (guard at every door)
|
||||
- A normal single-repo task (no `workspaceWorktrees`) → guard is a no-op; merge proceeds via `runAiMerge`. (no regression)
|
||||
- The thrown error names U6 / "per-repo merge support" so it's actionable. (clear messaging)
|
||||
|
||||
**Verification:** No workspace task can reach any merge path's git operations before master-plan U6; single-repo merges are unaffected.
|
||||
|
||||
---
|
||||
|
||||
### U4. Deterministic-mode blast-radius audit
|
||||
|
||||
**Goal:** Cite, not assert, that the `"deterministic"` path is effectively unused.
|
||||
|
||||
**Requirements:** KTD4.
|
||||
|
||||
**Dependencies:** none (can run in parallel with U1–U3).
|
||||
|
||||
**Files:**
|
||||
- (audit only — no source change) PR description / commit body records the result.
|
||||
|
||||
**Approach:** Grep test fixtures, CI configs (`.github/workflows/`), and seeded/default project settings for `merger.mode`, `"deterministic"`, and `testMode`/mock-provider interactions that might assert `aiMergeTask`'s deterministic (non-AI) output. Enumerate any suite that depends on the deterministic path; if found, note whether U1 reroutes it cleanly (warn + `runAiMerge`) or needs a fixture update. Cite the result in the PR.
|
||||
|
||||
**Test scenarios:** `Test expectation: none -- audit/investigation unit; output is the cited result in the PR, not a code change.`
|
||||
|
||||
**Verification:** The PR states which (if any) fixtures/CI/projects referenced `"deterministic"`, confirming the low-blast-radius claim with evidence.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** dispatch collapse, the two CLI/dashboard callers, `@deprecated` markers, `merger.mode` retirement + changeset, the R7 guard at all merge entry points, and the blast-radius audit.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- **Hard deletion of `aiMergeTask`** and its orphaned helpers (separate pass once no references remain).
|
||||
- All per-repo / multi-repo merge logic — the `runAiMerge` `landOneRepo` clean-room rework, `store.mergeTask` per-repo gating beyond the R7 guard, etc. (master-plan U6).
|
||||
- Removing the `MergerMode` type / `merger.mode` setting entirely (breaking change; revisit after the deprecation has shipped).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 — A missed `aiMergeTask` caller leaves a live legacy path.** Mitigation: U1's verification greps for zero non-test `aiMergeTask(` call sites; the dispatch test asserts all entry points route to `runAiMerge`.
|
||||
- **R2 — Deterministic-mode consumers silently switch to AI merge.** Mitigation: U4 audits before claiming low blast radius; U2 warns (not errors) on `"deterministic"`.
|
||||
- **R3 — Option-shape mismatch between `aiMergeTask` and `runAiMerge` at the CLI callers.** Mitigation: U1 confirms `runAiMerge`'s signature/options match what `onMergeImpl`/`runTaskMerge` pass today before rerouting; covered by the CLI caller tests.
|
||||
- **R4 — Published-surface change.** `merger.mode` is `@runfusion/fusion` surface. Mitigation: keep the type (KTD2), changeset required (U2).
|
||||
- **Dependency:** none external; lands before master-plan Phase A.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U0 / Phase 0, KTD0, R4, R7).
|
||||
- Codebase verification (this session): dispatch `project-engine.ts:2275-2282`; direct callers `dashboard.ts:1302/1330`, `task.ts:847/854`; `MergerMode`/`normalizeMergerMode`/`MergerSettings` `types.ts:508-519`; `store.mergeTask` `store.ts:11150`.
|
||||
- `AGENTS.md`: changeset policy (published `@runfusion/fusion`), merge-gate commands, FN-5048 slow-test rules, FN-5633 (AI merge default).
|
||||
176
docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md
Normal file
176
docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: feat
|
||||
origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase A / U1·U2·U10)
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor
|
||||
|
||||
> **ID namespace:** the `U1·U2·U3` below are **local to this Phase-A plan**. They decompose master-plan **U1, U2, U10** (a separate namespace). "Master-plan U6/U8" references point at the master plan, not these IDs.
|
||||
|
||||
## Summary
|
||||
|
||||
Phase A of the workspace-mode master plan: make a workspace task **run** (acquire → browse → edit per sub-repo), short of capture/review/merge (Phases B–D). Three units: (U1) executor session scoping so the session roots at the non-git workspace root and edits happen only in per-repo worktrees; (U2) per-repo acquisition hardening (identity guard, per-repo base SHA against the resolved integration branch, same-sub-repo exclusivity); (U3 = master U10) a dashboard "doesn't look broken" floor.
|
||||
|
||||
Builds on the **foundation** (PR #1710 — `task.workspaceWorktrees`, `fn_acquire_repo_worktree`, `acquireWorkspaceRepoWorktree`) + **U0** (PR #1711 — `runAiMerge` sole merge path, R7 guard). Settled design: **D2/D3/D5 — land-as-you-go on each repo's LOCAL integration ref** (no remote push), session-time coherence accepted. The R7 merge-boundary guard already exists at the merge chokepoint (U0); U1 must not route around it.
|
||||
|
||||
**Scope out:** capture/contamination/review (master U3/U4 = Phase B), the per-repo merge loop (master U6 = Phase C), self-healing reconcilers (master U8 = Phase D).
|
||||
|
||||
**Stacking:** this branch is off the U0 branch, so the PR diff includes foundation + U0 + Phase A and **must not merge until #1710/#1711 land**.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
In workspace mode `rootDir` is a **non-git** parent. On the current base the executor still, for every task: acquires one root worktree at `executor.ts:~7430` (`acquireTaskWorktree({rootDir})`), runs preflights (`resolveContaminationBaseRef`, `captureBaseCommitSha`, identity-guard install, `verifyWorktreeInvariants`) against that path, binds the agent session cwd to it, and tracks `activeWorktrees: Map<taskId, onePath>`. Against a non-git root, the root acquisition and every git preflight fail. The foundation gave the agent `fn_acquire_repo_worktree` (per-repo worktrees on demand) but nothing in the executor lifecycle skips the root path or hardens per-repo acquisition. Phase A closes that gap for the **run** stage.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### KTD1 — Skip root acquisition + all rootDir preflights; session cwd = workspace root (master KTD1)
|
||||
When `this.workspaceConfig` is present: skip `acquireTaskWorktree({rootDir})` and gate each intervening preflight so none runs git against the non-git root; set session cwd = `this.rootDir` (browse-only); do not set `task.worktree`; `scopePromptToWorktree` is a no-op. The non-workspace path stays byte-for-byte unchanged (branch on `workspaceConfig`).
|
||||
|
||||
### KTD2 — `activeWorktrees` becomes `taskId → Set<path>` (master KTD1) — VERIFIED consumer list
|
||||
A workspace task holds N sub-repo worktrees; liveness/owner checks must see all of them. Convert the map and update **every** consumer to membership semantics. The complete, code-verified consumer set (feasibility-checked — the earlier draft mislabeled these):
|
||||
- **Membership / owner checks:** `findActiveWorktreeOwner` (`:14491`), `hasActiveWorktreeBinding` (`:14518`), the FN-6736 phantom-binding reclaim (`~:2055`).
|
||||
- **`listWorktreeHolders` (`:14480`)** — emits one `{taskId, worktreePath}` per entry; consumed by the **FN-6782 leaked-slot reaper** (`self-healing.ts:~8310`) and `in-process-runtime.ts:~791`. A workspace task must **flat-map its Set into N holder rows**, or `maxWorktrees`-slot accounting under-counts and leaks/mis-reaps. Verify the reaper math against multi-row holders.
|
||||
- **Single-path getters — define the Set-collapse contract (KTD-decision):** `getWorktreePath(taskId): string|undefined` (`:15424`), the `verifyWorktreeInvariants` resolution `?? this.activeWorktrees.get(task.id)` (`:10461`), and the conflict-set iteration (`~:14444`, `worktreePath === conflictPath`). **Contract:** for a workspace task these single-path consumers operate per-sub-repo (the caller already has the repo/path in context) — `getWorktreePath` returns `undefined` for a multi-worktree workspace task (callers must use the per-repo `workspaceWorktrees` entry), and `verifyWorktreeInvariants` is iterated per worktree in Phase B (master U3), so its singular resolution is gated off in workspace mode here.
|
||||
- **Unregister resolvers (`:1586`/`:1603`/`:1618`)** — `deleteActiveSession`/`StepExecutor`/`WorkflowStepSession` each read one path for `activeSessionRegistry.unregisterPath`; with a Set they must unregister **every** path (loop), not one. Plus cleanup at `~:14922`.
|
||||
|
||||
Non-workspace tasks hold a one-element set — behavior unchanged. **Grep all `activeWorktrees.` sites before declaring done** (FN-5893); the list above is the verification spine, not a license to skip the grep.
|
||||
|
||||
### KTD3 — Per-repo base SHA against the *resolved* integration branch, local-first (master KTD3)
|
||||
`resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes `(worktreePath, logger?)`. Extend it to accept the integration branch as an **optional trailing param defaulting to the current `main` literal**, so the existing single-repo caller (`executor.ts:~12075`) and the 4 `base-commit-capture.real-git.test.ts` cases stay green without change. At each sub-repo acquisition capture `baseCommitSha` measured **local-first** (`merge-base HEAD <localIntegration> || origin/<integration>`), per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`.
|
||||
|
||||
> **Integration-branch resolution gotcha (feasibility-verified):** `resolveIntegrationBranch(rootDir, settings)` (`integration-branch.ts:74`) checks `resolveFromSettings(settings)` **FIRST** and returns a populated `settings.integrationBranch` before ever consulting the repo's `origin/HEAD`. So `resolveIntegrationBranch(repoAbsPath, settings)` would return the **shared** override for every sub-repo — the exact thing KTD3 forbids. **Call it with the shared override stripped:** `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })`, so each sub-repo falls through to its own `origin/HEAD`. Store as `workspaceWorktrees[repo].baseCommitSha`.
|
||||
|
||||
### KTD4 — Same-sub-repo exclusivity via `activeSessionRegistry` path-keying, not the pool (master KTD6)
|
||||
`WorktreePool` is a recycle cache (gated on `recycleWorktrees`), **not** a cross-task lock. Serialize two concurrent workspace tasks contending for the same sub-repo via a repo-path exclusivity registry built on `activeSessionRegistry` path-keying (which `runAiMerge` already uses), registered **at acquisition** (U2). Disjoint-scope contention on the same sub-repo is otherwise unprotected (file-scope leases don't catch it).
|
||||
|
||||
### KTD5 — Dashboard floor only (master U10)
|
||||
Nil-guard components that render `task.worktree`/`task.branch` so a workspace task (no `task.worktree`, populated `workspaceWorktrees`) shows a placeholder or flat per-repo list, never a crash/empty. Ceiling: "doesn't look broken" — no rich per-repo-status component (deferred registration UI). Plus a one-line non-atomic-merge-semantics note in `CONCEPTS.md`/`docs/dashboard-guide.md`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Standing requirements (every unit):** `FNXC:Workspace <yyyy-MM-dd-hh:mm>` comments at non-obvious decision points; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (narrow seams, real git only where an invariant requires it, fake timers over polling, no mock-the-world); FN-5893 surface enumeration (update every enumerated consumer, don't half-convert); merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`). Branch off the U0 branch — do not commit to `main` or the U0 branch.
|
||||
|
||||
### U1. Executor session scoping — skip root acquisition + preflights, browse-only root, activeWorktrees Set
|
||||
|
||||
**Goal:** In workspace mode the executor skips root acquisition and every rootDir git preflight, runs the session rooted at the workspace dir, and tracks per-task worktree *sets*.
|
||||
|
||||
**Requirements:** KTD1, KTD2.
|
||||
|
||||
**Dependencies:** none (foundation + U0 present on the base).
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` (acquisition `~:7430`; preflights `:7525` base capture, `:7536` contamination, identity-guard install, `verifyWorktreeInvariants`; session create `~:8443-8494`; retry session `~:8935`; `activeWorktrees` `:7667` + consumers `findActiveWorktreeOwner`/`hasActiveWorktreeBinding`/`getActiveWorktreeHolders`/FN-6736 reclaim `~:2055`/getters `~:1585`/`:14491`/`:14518`; `scopePromptToWorktree`)
|
||||
- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — replace the `vi.mock`-the-subject tests with a **real two-repo git fixture harness** reusable by U2 and later phases)
|
||||
|
||||
**Approach:** Gate the root acquisition + each preflight behind `!this.workspaceConfig`. In workspace mode set session cwd = `this.rootDir`, leave `task.worktree` unset, no-op `scopePromptToWorktree`. Convert `activeWorktrees` to `taskId → Set<path>`; update each enumerated consumer to membership semantics (a non-workspace task = a one-element set). Mirror the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`.
|
||||
|
||||
**Execution note:** Build the real two-repo fixture harness first (create temp git repos, branch, commit); the foundation's self-mocking test proves nothing. The harness is shared infrastructure for the rest of the phases.
|
||||
|
||||
**Test scenarios:**
|
||||
- Workspace config present → root `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path)
|
||||
- Non-workspace task → acquisition + every preflight called exactly as before; `cwd === worktreePath`. (regression — the singular path is untouched)
|
||||
- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths (membership, not equality). (integration)
|
||||
- Retry session in workspace mode uses `cwd === rootDir`. (edge)
|
||||
- Workspace task that acquires zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`. (edge/empty)
|
||||
|
||||
**Verification:** A workspace task starts a session at the workspace root with no root worktree and no rootDir git preflight; `activeWorktrees` reflects all acquired sub-repo paths; a single-repo task is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### U2. Per-repo acquisition hardening — identity guard, per-repo base SHA, same-repo exclusivity
|
||||
|
||||
**Goal:** Each sub-repo worktree gets identity hooks, a correct per-repo base SHA (local-first, resolved integration branch), and same-sub-repo concurrency protection — all at acquisition.
|
||||
|
||||
**Requirements:** KTD3, KTD4.
|
||||
|
||||
**Dependencies:** U1 (shares the fixture harness).
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`)
|
||||
- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it hardcodes `main`)
|
||||
- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`)
|
||||
- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD4; NOT `worktree-pool.ts`)
|
||||
- `packages/core/src/types.ts` (extend the `Task.workspaceWorktrees` entry with `baseCommitSha?`)
|
||||
- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real two-repo git fixture)
|
||||
|
||||
**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard via `installTaskWorktreeIdentityGuard`, passing the **same settings args the executor passes** at `executor.ts:14035-14040` (`commitMsgHookEnabled`, `taskPrefix`, `taskAttributionTrailerName`) for single-repo parity — note `acquireWorkspaceRepoWorktree` calls `acquireTaskWorktree` *without* a `createWorktree` override, so the default backend installs **no** guard today (this work is genuinely missing); (2) resolve the integration branch via `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })` (strip the shared override — KTD3 gotcha) and capture `baseCommitSha` via the extended `resolveCapturedBaseCommitSha(worktreePath, logger?, integrationBranch?)`; (3) persist `baseCommitSha` into `workspaceWorktrees[repo]`; (4) register same-sub-repo exclusivity in the `activeSessionRegistry` path-keyed registry — choose a **distinct registry kind/ownerKey** for the acquisition-time exclusivity entry so it does not collide with the executor's later session registration on the same sub-repo path (the registry exposes `registerPath`/`lookupByPath`/`isPathActive`/`pathsForTask`). Idempotent across `(taskId, repo)` (re-acquire returns the existing entry, no re-install/re-capture).
|
||||
|
||||
**Execution note:** Real two-repo fixture; commit-without-pushing to exercise the local-ahead-of-origin invariant.
|
||||
|
||||
**Test scenarios:**
|
||||
- Acquiring repo A captures `baseSha_A` = the local integration tip even when `origin/<integration>` is behind. Covers the inflation invariant. (happy path + regression)
|
||||
- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 correction)
|
||||
- Identity-guard hook present; a commit on a non-`fusion/<id>` branch is rejected. (integration)
|
||||
- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the exclusivity registry. (concurrency — KTD4)
|
||||
- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency)
|
||||
- Acquisition failure persists an audit event and surfaces an error (no swallowed stall). (error path)
|
||||
|
||||
**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition.
|
||||
|
||||
---
|
||||
|
||||
### U3. Dashboard "doesn't look broken" floor (master U10)
|
||||
|
||||
**Goal:** Existing task views render a workspace task (no `task.worktree`, populated `workspaceWorktrees`) without breakage.
|
||||
|
||||
**Requirements:** KTD5.
|
||||
|
||||
**Dependencies:** none (independent of U1/U2; reads the data shape the foundation already added).
|
||||
|
||||
**Files:**
|
||||
- Each `packages/dashboard/app/` component that reads `task.worktree`/`task.branch` for display (grep and enumerate during implementation — task detail view + any task-row/summary)
|
||||
- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note)
|
||||
- `packages/dashboard/app/__tests__/` (new — graceful render test)
|
||||
|
||||
**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or a flat per-repo path list when `task.worktree` is absent and `workspaceWorktrees` is populated. **Ceiling:** placeholder/flat list only — a new rich per-repo-status component crosses into the deferred registration UI. Add the one-line semantics note (workspace-task merges are non-atomic: repos land independently on local integration refs; partial-land is local + operator-resettable).
|
||||
|
||||
**Test scenarios:**
|
||||
- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list/placeholder, no crash/empty. (happy path)
|
||||
- Single-repo task → unchanged. (regression)
|
||||
|
||||
**Verification:** Workspace tasks are observable (not broken) in the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** the **run** stage — session scoping (U1), per-repo acquisition hardening (U2), dashboard breakage floor (U3).
|
||||
|
||||
### Deferred to Follow-Up Work (later master-plan phases)
|
||||
- Per-repo modified-files capture, contamination, `verifyWorktreeInvariants` iteration (master U3 = Phase B).
|
||||
- Per-repo review + `fn_task_done` completion verification (master U4 = Phase B).
|
||||
- The shared landed predicate, per-repo `runAiMerge` clean-room loop, leases (master U5/U6/U7 = Phase C).
|
||||
- Self-healing reconcilers, e2e harness (master U8/U9 = Phase D).
|
||||
- Rich dashboard per-repo status / workspace registration UI.
|
||||
|
||||
> **Contamination-window caveat (carried from the master plan):** U1 gates the root preflights off, but per-repo contamination/`verifyWorktreeInvariants` does not return until master U3 (Phase B). Do not run a workspace task for real until Phase B lands — Phase A delivers acquisition + browse, not a verified end-to-end run.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 — Half-converted `activeWorktrees` consumers (FN-5893).** Missing one consumer silently breaks liveness/owner checks for multi-repo tasks. Mitigation: KTD2 enumerates every consumer; grep all `activeWorktrees.get(`/`.has(`/`===`-on-path sites before declaring done.
|
||||
- **R2 — A preflight left un-gated runs git against the non-git root → crash.** Mitigation: U1 explicitly enumerates and gates each preflight between the workspace guard and session create; test asserts no rootDir git in workspace mode.
|
||||
- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 extends the hardcoded-`main` helper and captures local-first against the resolved branch; regression test commits-without-pushing + uses a non-`main` integration branch.
|
||||
- **R4 — Same-sub-repo concurrency unprotected.** Mitigation: KTD4 registers exclusivity at acquisition (U2), not via the recycle pool.
|
||||
- **R5 — Non-workspace regression.** The whole point of branching on `workspaceConfig` is parity for single-repo tasks. Mitigation: every unit carries a non-workspace "unchanged" regression test; the gate's existing engine-core suite must stay green.
|
||||
- **Stacking dependency:** builds on foundation #1710 + U0 #1711; the PR diff includes both and must not merge until they land.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U1/U2/U10, KTD1/KTD3/KTD6 — KTD7 is Phase B, invariant inventory, D2/D3/D5).
|
||||
- Codebase anchors (verified this session): `executor.ts` acquisition/preflight/session/`activeWorktrees`; `worktree-acquisition.ts` `acquireWorkspaceRepoWorktree`; `base-commit-capture.ts` hardcoded-`main`; `resolveIntegrationBranch`; `activeSessionRegistry` path-keying; foundation `task.workspaceWorktrees`.
|
||||
- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3 (local-first base capture).
|
||||
- `AGENTS.md`: FN-5048 slow-test rules, FN-5893 surface enumeration, changeset policy, merge gate.
|
||||
145
docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md
Normal file
145
docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: "feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: feat
|
||||
origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase B / U3·U4)
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify
|
||||
|
||||
> **ID namespace:** local `U1·U2` decompose master-plan **U3, U4**.
|
||||
> **Anchors below are feasibility-verified against the Phase-B base** (not the master plan's approximate numbers).
|
||||
|
||||
## Summary
|
||||
|
||||
Phase B makes the executor's capture / contamination / verify / review / completion paths iterate `task.workspaceWorktrees` per sub-repo, using each repo's own `baseCommitSha` (Phase A, U2). It does **not** simply "un-gate stubs" — the feasibility pass found capture/contamination/scope-leak are not gated at all today; they **silently degrade to empty** against the non-git root (git failures swallowed). Phase B adds the missing workspace branches and reuses the existing `captureModifiedFiles` machinery (whose `resolveDiffBaseRef` merge-base fallback + `filterFilesToOwnTaskCommits` contamination audit are exactly what's needed) per repo.
|
||||
|
||||
Builds on Phase A (PR #1713). **Scope out:** the merge loop (master U6 = Phase C), self-healing (master U8 = Phase D).
|
||||
|
||||
**Stacking:** off the Phase-A branch; PR diff includes the stack; must not merge until it lands.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Phase A rooted workspace sessions at the non-git workspace root and acquired per-repo worktrees, but the executor's change-capture, contamination, worktree-invariant, review, and completion-verify paths still operate on a single `task.worktree`. Against the non-git root they either are explicitly stubbed (one site) or silently produce empty results (the rest). Phase B routes each of these through every acquired sub-repo worktree, `cwd` = the sub-repo, diffing against that repo's `workspaceWorktrees[repo].baseCommitSha`, with repo-prefixed file lists so review/dashboard/later-merge keep repo context.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### KTD1 — Per-repo change capture by **reusing `captureModifiedFiles`**, not a raw diff (master KTD7)
|
||||
**Verified reality:** capture is **not** workspace-gated. The post-session call `captureModifiedFiles(worktreePath, …, "post-session")` (executor.ts **:7898**) runs ungated with `worktreePath` = the browse-only non-git root and returns `[]` only because `resolveDiffBaseRef`/`resolveContaminationBaseRef` swallow the git failure. So U1 **adds** a workspace branch at :7898 (and the sibling branch-attribution audit at **:7914**), it does not replace one.
|
||||
|
||||
Per repo, call the **existing** `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source)` — NOT a hand-built `git diff <base>..HEAD`. Reasons (all verified): (a) `repo.baseCommitSha` may be **undefined** (Phase A made base capture non-fatal); `resolveDiffBaseRef` (:~12184) handles that via a merge-base fallback. (b) the real **contamination** signal is the `filterFilesToOwnTaskCommits` raw-vs-attributed divergence audit **inside** `captureModifiedFiles` (:~12225-12246) — reusing it restores contamination for free. Prefix each repo's returned files with the repo path and aggregate into `task.modifiedFiles`.
|
||||
|
||||
> **`assertCleanBranchAtBase` is a no-op** (branch-conflicts.ts: `void`s all params — "informational only"). Do **not** add a per-repo iteration of it; it would restore zero protection. Contamination comes from per-repo `captureModifiedFiles`.
|
||||
|
||||
### KTD2 — `verifyWorktreeInvariants` iterates per acquired worktree, preserving its result union (master KTD7)
|
||||
The **one** workspace stub in this region is `verifyWorktreeInvariants` returning `{ok:true}` at executor.ts **:10508** (def **:10500**). Un-stub it: iterate every `workspaceWorktrees` entry, asserting each HEAD is on `fusion/<id>` and toplevel matches the recorded `worktreePath`. **Preserve the exact discriminated union** `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` (consumed at **:10889**; the `reason` enum drives the requeue/handoff branches at :10894-10936) — add a `repo` field to the failure shape; return the **first** failing repo.
|
||||
|
||||
### KTD3 — Per-repo review by looping the **existing single-cwd `reviewStep`** N times (master KTD7)
|
||||
**Decision (user-confirmed): accept the N× reviewer cost.** The reviewer is an **agent** spawned with `cwd` = worktree and told (in prompt text, reviewer.ts:~760) to run `git diff` itself — it does not read a diff passed in code. So per-repo review = spawning **one reviewer agent per sub-repo**. Architecture: the **callers loop** and call the existing single-cwd `reviewStep` (reviewer.ts **:122**) once per acquired worktree (cwd = repo, scope = prefix-derived subset); aggregate repo-tagged verdicts into the task's single review record as a **conjunction** (reviewed only if every repo passes). `reviewStep` itself stays single-cwd.
|
||||
|
||||
**Both review call sites iterate (user-confirmed FN-5893 coverage):**
|
||||
- `createReviewStepTool` → `reviewStep` (executor.ts **:11148**, the in-session `fn_review_step` path).
|
||||
- the **step-inversion seam** `reviewStep(worktreePath=active.worktreePath || detail.worktree || this.rootDir, …)` at executor.ts **:5668** (foreach/step-inversion path).
|
||||
|
||||
### KTD4 — `fn_task_done` completion verification iterates per repo, including the scope-leak guard (master KTD7)
|
||||
`fn_task_done` (`createTaskDoneTool` executor.ts **:10832**) must, in workspace mode: (a) call the per-repo `verifyWorktreeInvariants` (KTD2) for every acquired worktree; (b) iterate the **scope-leak guard** `evaluateTaskDoneScopeLeak` (executor.ts **:10711**, invoked at **:11009**) per repo — it currently runs `captureUncommittedModifiedFiles(worktreePath)` + `captureModifiedFiles(worktreePath, task.baseCommitSha, …)` against the singular root and silently passes; per-repo iteration (cwd = sub-repo, `repo.baseCommitSha`) restores the uncommitted-in-scope block. Block completion on any dirty/misbound repo or uncommitted in-scope change, naming the repo.
|
||||
|
||||
> **Repo-prefix derivation helper** (shared, master U5 will reuse): canonicalize → match first path segment to a configured repo → `unscoped` fallback. New `packages/engine/src/workspace-paths.ts`. Keep it minimal — no lease logic (Phase C / master U7).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Standing requirements:** `FNXC:Workspace <yyyy-MM-dd-hh:mm>` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (reuse the Phase-A `_workspace-fixture.ts` harness; real git only where the invariant requires it; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase A (already checked out: `gsxdsm/workspace-phase-b`).
|
||||
|
||||
### U1. Per-repo capture, contamination, and worktree-invariant verification (master U3)
|
||||
|
||||
**Goal:** Change-capture, contamination, and `verifyWorktreeInvariants` cover every acquired sub-repo worktree with repo context and correct cwd.
|
||||
|
||||
**Requirements:** KTD1, KTD2.
|
||||
|
||||
**Dependencies:** none beyond Phase A.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` — **add** a workspace branch at the post-session capture **:7898** (+ attribution audit **:7914**) that loops `workspaceWorktrees` calling `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …)` per repo, repo-prefixing results; **un-stub** `verifyWorktreeInvariants` **:10508** to iterate per worktree preserving the `{ok|reason|observed|expected}` union (+ `repo`).
|
||||
- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real two-repo fixture via `_workspace-fixture.ts`)
|
||||
|
||||
**Approach:** Per KTD1/KTD2. Reuse `captureModifiedFiles` (do not hand-build `git diff`); do not iterate the no-op `assertCleanBranchAtBase`. Singular non-workspace path unchanged.
|
||||
|
||||
**Execution note:** Reuse `_workspace-fixture.ts`; commit edits onto each sub-repo's `fusion/<id>` branch to exercise real diffs + the divergence audit.
|
||||
|
||||
**Test scenarios:**
|
||||
- Edits in repo A and B → `task.modifiedFiles` carries repo-prefixed paths from both, each diffed against its own `baseCommitSha`. (happy path)
|
||||
- A repo with `baseCommitSha` undefined → capture still works via the merge-base fallback (no `git diff undefined..HEAD`). (edge — Phase A non-fatal base)
|
||||
- A foreign commit in a sub-repo's range → the `filterFilesToOwnTaskCommits` divergence/contamination audit fires for that repo. (contamination)
|
||||
- A worktree HEAD drifted off `fusion/<id>` → `verifyWorktreeInvariants` returns `{ok:false, reason:'wrong_branch', repo, observed, expected}` (not `{ok:true}`); the `reason` enum is preserved for the :10889 consumer. (error path)
|
||||
- Single-repo (non-workspace) task → capture/verify byte-for-byte identical. (regression)
|
||||
|
||||
**Verification:** Capture + contamination audit + invariant verify run per acquired worktree with repo context; the result union is intact; single-repo unchanged.
|
||||
|
||||
---
|
||||
|
||||
### U2. Per-repo review (both call sites) + `fn_task_done` completion + scope-leak verification (master U4)
|
||||
|
||||
**Goal:** Review every acquired sub-repo (both review entry points) and block completion until every sub-repo passes review, invariant, and scope-leak checks.
|
||||
|
||||
**Requirements:** KTD3, KTD4, KTD2.
|
||||
|
||||
**Dependencies:** U1 (per-repo verify + capture).
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/executor.ts` — `createReviewStepTool` **:11148** and the step-inversion seam **:5668** loop `reviewStep` per acquired worktree; `createTaskDoneTool` **:10832** calls per-repo verify (U1) + iterates `evaluateTaskDoneScopeLeak` **:10711** per repo.
|
||||
- `packages/engine/src/reviewer.ts` — `reviewStep` (**:122**) stays single-cwd; callers loop. Aggregate repo-tagged verdicts (conjunction) into the task review record; reviewer findings carry the repo tag.
|
||||
- `packages/engine/src/workspace-paths.ts` (new — the repo-prefix-derivation helper; master U5 reuses)
|
||||
- `packages/engine/src/__tests__/reviewer-workspace.test.ts`, `packages/engine/src/__tests__/executor-workspace-taskdone.test.ts` (new)
|
||||
|
||||
**Approach:** Per KTD3/KTD4. Both review sites loop the existing single-cwd `reviewStep` once per sub-repo (N reviewer agents — accepted cost) and aggregate as a conjunction. `fn_task_done` per-repo verify + per-repo scope-leak.
|
||||
|
||||
**Test scenarios:**
|
||||
- Two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed only when both pass. (conjunction)
|
||||
- A reviewer finding in repo B is repo-tagged. (integration)
|
||||
- Step-inversion review seam (:5668) for a workspace task reviews each sub-repo, not the non-git root. (FN-5893 second surface)
|
||||
- `fn_task_done` with an uncommitted in-scope change in repo A → completion blocked, naming repo A (the scope-leak guard fires per-repo). (error path)
|
||||
- `fn_task_done` with a worktree off `fusion/<id>` → blocked via per-repo verify. (error path)
|
||||
- The prefix helper: `wolf-server/src/**` → repo `wolf-server`; non-matching first segment → `unscoped`. (helper)
|
||||
- Single-repo task → one review pass + singular scope-leak/verify, unchanged. (regression)
|
||||
|
||||
**Verification:** A workspace task is reviewed/complete only when every sub-repo passes review + invariant + scope-leak; both review entry points iterate; single-repo unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** per-repo capture/contamination/verify (U1); per-repo review at both call sites + `fn_task_done` verify + scope-leak (U2); the repo-prefix helper.
|
||||
|
||||
### Deferred to Follow-Up Work (later phases)
|
||||
- The per-repo merge loop, the landed predicate, the file-scope leases (master U5/U6/U7 = Phase C).
|
||||
- Self-healing reconcilers, e2e (master U8/U9 = Phase D).
|
||||
- Per-repo worktree teardown (carried Phase-A residual).
|
||||
- Store-level **atomic** per-repo `workspaceWorktrees` merge — Phase A added a re-read mitigation; the fully-atomic merge is still open and **becomes reachable in Phase B** (multi-repo acquisition first exercised here). Track for Phase C.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 — "Add a branch" vs "replace a stub" confusion.** Capture/contamination/scope-leak silently degrade (not gated); an implementer expecting a stub to replace won't find one. Mitigation: KTD1/KTD4 + U1/U2 cite the exact add sites (:7898/:7914, :10711) and the one real stub (:10508).
|
||||
- **R2 — Hand-built `git diff` breaks on undefined base.** Mitigation: KTD1 mandates reusing `captureModifiedFiles`; test covers the undefined-base repo.
|
||||
- **R3 — `verifyWorktreeInvariants` union shape.** The `reason` enum is load-bearing at :10889. Mitigation: KTD2 preserves the union; test asserts the `reason`.
|
||||
- **R4 — No-op contamination function.** Mitigation: KTD1 explicitly forbids iterating `assertCleanBranchAtBase`; contamination rides on per-repo `captureModifiedFiles`.
|
||||
- **R5 — N× reviewer cost.** Accepted (user decision). Mitigation: note in the PR; cost scales with repo count (typically 2-3).
|
||||
- **Stacking dependency:** off Phase A (#1713); diff includes the stack.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Master plan (U3/U4, KTD7, contamination-window caveat).
|
||||
- Phase B feasibility pre-check (verified anchors: capture not gated/:7898 add-site, `assertCleanBranchAtBase` no-op, undefined-base via `resolveDiffBaseRef`, verify union :10508/:10889, review agent N× cost + the :5668 second surface, scope-leak :10711, anchor corrections).
|
||||
- Phase A (#1713): per-repo `baseCommitSha`, `activeWorktrees` Set, `_workspace-fixture.ts`.
|
||||
- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`.
|
||||
151
docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md
Normal file
151
docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: "feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs)"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: feat
|
||||
origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase C / U5·U6·U7)
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs)
|
||||
|
||||
> **ID namespace:** local `U0·U1·U2·U3` decompose master-plan **U5, U6, U7** (+ a Phase-B-deferred extraction).
|
||||
> **Anchors are feasibility-pending** — a pre-check runs before implementation (as in Phases A/B). Treat `~:` numbers as approximate until verified.
|
||||
|
||||
## Summary
|
||||
|
||||
Phase C replaces U0's **R7 guard** — which currently makes every workspace-task merge *throw* `WorkspaceTaskMergeError` — with the real **per-repo merge loop**: for each acquired sub-repo, land that repo's `fusion/<id>` branch onto **that repo's LOCAL integration ref** via a repo-scoped clean-room (the `runAiMerge` mechanism, applied per repo), with no remote push. This is **land-as-you-go** (settled **D2/D5**): repos land independently; a partial land (A lands, B fails) leaves A landed locally and is operator-resettable; an unconditional operator escape hatch always exists.
|
||||
|
||||
After Phase C a workspace task can fully run → capture → review → **merge**. **Scope out:** self-healing reconcilers + e2e harness (master U8/U9 = Phase D).
|
||||
|
||||
**Stacking:** off Phase B (#1714); PR diff includes the stack; must not merge until it lands.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
`runAiMerge` (merger-ai.ts) lands **one** `task.worktree`'s `fusion/<id>` branch into a single clean-room temp worktree and advances **one** local integration ref via `update-ref` CAS (no push). U0 added the **R7 chokepoint guard** `assertNotWorkspaceTaskMerge(task)` so a `workspaceWorktrees`-bearing task fails fast rather than silently mis-merging the single root. Phase C turns that fail-fast into a real loop: iterate the acquired sub-repos, run the clean-room land per repo against that repo's own local integration ref, track which repos have landed (idempotent retry), hold a per-repo file-scope lease during each land, and aggregate a per-repo `MergeResult`. The single-repo `runAiMerge` path is untouched.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
> **OPEN FORKS — to be confirmed by the feasibility pre-check + user before implementation.** Marked `‹FORK›`. The settled semantics (D2/D5) bound them, but the code shape is to verify.
|
||||
|
||||
### KTD0 — Extract `workspace-executor.ts` FIRST (Phase-B-deferred maintainability P1)
|
||||
Before adding the merge loop, move the workspace branches Phase A/B inlined into `executor.ts` (`captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` block) into `packages/engine/src/workspace-executor.ts` as module-level functions receiving executor state as args; the `if (this.workspaceConfig)` call sites delegate. Pure move + delegate, no behavior change — its own commit, gate-green, before any Phase-C behavior. This keeps the 16k-line file from absorbing the merge loop too.
|
||||
|
||||
### KTD1 — Extract `landOneRepo` from `runAiMerge`, then loop it (master U6; D2/D5) — FORK-A RESOLVED
|
||||
**Verified:** `runAiMerge`'s land sequence (mkdtemp clean room → `git worktree add --detach` → `installWorktreeDependencies` → `mergeAndReview` → `landSquash` → the concurrent-advance CAS retry loop → `activeSessionRegistry` register/unregister) is an **un-factored inline closure** at `merger-ai.ts:1064-1216`, bound to one `projectRootDir`/`integrationBranch`/`branch`; `mergeAndReview`/`finalizeMerged` are module-private. The CAS seam `advanceIntegrationBranchRef` already takes `rootDir`/`integrationBranch` explicitly. **No remote push anywhere** — D2/D5 "no push" confirmed.
|
||||
|
||||
So U1 **extracts** an exported `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from that closure (returns a per-repo `LandResult`), leaving `runAiMerge` as the byte-for-byte single-repo caller. `landWorkspaceTask(task)` loops the acquired sub-repos calling `landOneRepo` per repo, aggregating a repo-tagged result. **`landOneRepo` stays in `merger-ai.ts`** (the private helpers live there); only the thin `landWorkspaceTask` orchestrator may sit in a new `workspace-merger.ts`.
|
||||
|
||||
**Per-repo integration branch (P1 the plan missed):** `workspaceWorktrees[repo]` does NOT store the integration branch (acquisition computes it then discards). `landOneRepo` must **re-resolve per repo** with the same override-stripping acquisition uses — `resolveIntegrationBranch(repoRoot, { ...settings, integrationBranch: undefined, baseBranch: undefined })` — so each sub-repo lands on its own `origin/HEAD`, not a shared branch.
|
||||
|
||||
**Per-sub-repo prune rooting (correctness):** `pruneExistingAiMergeWorktrees`/`cleanupStaleTempMergeWorktrees` sweep by the `fusion-ai-merge-<taskId>-` prefix; N per-repo clean rooms share the taskId. Root each sweep at the **sub-repo** (`resolveAiMergeRoot(subRepoRoot)`) so one repo's prune cannot race another repo's live clean room for the same task.
|
||||
|
||||
### KTD2 — Door table: route the engine + CLI/dashboard doors, keep the rest throwing (master U6) — RESOLVED
|
||||
Six guard sites. Per-door (FN-5893):
|
||||
1. **`project-engine.ts:~2300` engine dispatch** → route `workspaceWorktrees`-bearing tasks to `landWorkspaceTask`.
|
||||
2. **`runAiMerge:~979` chokepoint guard** → STAYS as defense-in-depth for direct single-repo callers (workspace tasks enter via `landWorkspaceTask`, not here).
|
||||
3. **`store.mergeTask:~11159`** (core, cannot import `@fusion/engine`) → STAYS throwing.
|
||||
4. **CLI `dashboard.ts:~1312` + `task.ts:~861`** → **route workspace tasks through the engine merge (`landWorkspaceTask`)** instead of `store.mergeTask`, so user-triggered `fn task merge` / the dashboard merge button work on workspace tasks **(user decision: manual merge works in Phase C)**.
|
||||
5. **`aiMergeTask` (merger.ts:~7666, deprecated)** → STAYS throwing.
|
||||
|
||||
### KTD3 — `landedSha`-only per repo; `landWorkspaceTask` finalizes once; auto-retry then park (master U5) — FORK-B RESOLVED
|
||||
**Verified:** `finalizeMerged`/`finalizeTask` are **task-global** — they write one task-level `mergeDetails` and move the WHOLE task to `done` (`merger-ai.ts:1298-1401`). So `landOneRepo` must advance the ref + record `workspaceWorktrees[repo].landedSha` **only** (no task move). `landWorkspaceTask` calls `finalizeTask`/move-done **exactly once** after every acquired repo's landed predicate is true.
|
||||
|
||||
**Landed predicate:** a repo is landed iff `entry.branch` tip is an ancestor of (or equals) its local integration ref tip (or the recorded `landedSha` is present); `landWorkspaceTask` **skips landed repos** (idempotent).
|
||||
|
||||
**Partial-land (user decision: auto-retry then park):** repo B fails after A landed → task goes to a non-done state with A's `landedSha` persisted; the failure **consumes a `mergeRetry`** and the engine **auto-retries `landWorkspaceTask`** (skipping landed A, re-attempting B) up to the existing `MAX`, then **operator-parks** (D5 escape hatch as terminal). No new partial-landed status type — `landedSha` on the entry is the only state added (`types.ts:~2256`).
|
||||
|
||||
### KTD4 — Per-repo land lease via `activeSessionRegistry` new kind (master U7) — FORK-C RESOLVED
|
||||
**Verified:** there is NO separate engine file-scope lease — `activeSessionRegistry` (path-keyed, `kind` enum) is the only mechanism (`runAiMerge` already registers the clean room under `kind:"ai-merge"`). Add a new `ActiveSessionKind` `"workspace-repo-land"` keyed on the **sub-repo absolute path**; register before `landOneRepo`, unregister in `finally`. **The lease is for serialization / clean-room-collision avoidance, not ref correctness** — `advanceIntegrationBranchRef`'s CAS already makes interleaved `update-ref` safe (concurrent-advance → rebuild). Set test expectations accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Standing requirements:** `FNXC:Workspace <yyyy-MM-dd-hh:mm>` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo git fixture via `_workspace-fixture.ts`; assert local-ref advancement with NO push; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase B (`gsxdsm/workspace-phase-c`).
|
||||
|
||||
### U0. Extract `workspace-executor.ts` (no behavior change)
|
||||
**Goal:** Move Phase A/B workspace helpers out of `executor.ts` into `workspace-executor.ts`; call sites delegate. Pure refactor.
|
||||
**Requirements:** KTD0.
|
||||
**Dependencies:** none.
|
||||
**Files:** `packages/engine/src/executor.ts`, `packages/engine/src/workspace-executor.ts` (new), existing workspace tests (imports may shift).
|
||||
**Approach:** Move `captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` body; pass `store`/`captureModifiedFiles`/etc. as args. No logic change.
|
||||
**Test scenarios:** the existing Phase A/B workspace suites pass unchanged (the move is correct iff they stay green). `Test expectation: behavior-preserving — existing suites are the oracle.`
|
||||
**Verification:** All Phase A/B workspace tests + `test:gate` green; `executor.ts` shrinks; no behavior diff.
|
||||
|
||||
### U1. Extract `landOneRepo`, loop it in `landWorkspaceTask`, route the doors (master U6)
|
||||
**Goal:** Land each acquired sub-repo's branch onto its own local integration ref (land-as-you-go, no push), via an extracted `landOneRepo`; route the engine + CLI/dashboard doors.
|
||||
**Requirements:** KTD1, KTD2.
|
||||
**Dependencies:** U0.
|
||||
**Files:** `packages/engine/src/merger-ai.ts` (extract `landOneRepo` from the `:1064-1216` closure; add `landWorkspaceTask`), `packages/engine/src/project-engine.ts` (`~:2300` dispatch → `landWorkspaceTask`), `packages/cli/src/commands/dashboard.ts` (`~:1312`) + `packages/cli/src/commands/task.ts` (`~:861`) (route workspace tasks to the engine merge), optional `packages/engine/src/workspace-merger.ts` (thin orchestrator), `packages/engine/src/__tests__/workspace-merger.test.ts` (new).
|
||||
**Approach:** Per KTD1/KTD2. **(a)** Extract `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from the inline closure — `runAiMerge` becomes its single-repo caller, byte-for-byte. **(b)** `landWorkspaceTask` loops the acquired sub-repos: re-resolve each repo's integration branch (override-stripped), root the prune at the sub-repo, call `landOneRepo`, aggregate repo-tagged results. **(c)** Route the engine dispatch + both CLI doors to `landWorkspaceTask` for `workspaceWorktrees`-bearing tasks; `store.mergeTask`/`aiMergeTask`/the `runAiMerge` chokepoint keep throwing (defense-in-depth).
|
||||
**Execution note:** Real two-repo fixture; commit on each `fusion/<id>`; assert each repo's **local** integration ref advanced and **no remote ref/push** occurred; assert per-sub-repo prune rooting.
|
||||
**Test scenarios:**
|
||||
- Two acquired repos, both clean → both local integration refs advance against each repo's own resolved branch; no push/remote ref; result tags both. (happy)
|
||||
- Repos with different integration branches → each lands on its own (override-stripping works; not a shared branch). (per-repo resolution)
|
||||
- A conflict in repo B → repo A lands (its `landedSha` recorded); B's result reports the conflict; the task is NOT moved done. (partial — D2/D5)
|
||||
- The single-repo (non-workspace) `runAiMerge` path → byte-for-byte unchanged (it calls the extracted `landOneRepo`). (regression)
|
||||
- `store.mergeTask`/`aiMergeTask` with a workspace task → still throws `WorkspaceTaskMergeError`. (defense-in-depth)
|
||||
- A workspace task via the CLI/dashboard merge door → routes to `landWorkspaceTask` (does not throw). (user-facing door)
|
||||
**Verification:** Workspace merges land per repo on local refs (no push) via `landOneRepo`; single-repo unchanged; user doors route; non-routed doors stay guarded.
|
||||
|
||||
### U2. Per-repo landed predicate + idempotent retry (master U5)
|
||||
**Goal:** Track landed repos; retry skips them.
|
||||
**Requirements:** KTD3.
|
||||
**Dependencies:** U1.
|
||||
**Files:** `packages/core/src/types.ts` (`workspaceWorktrees[repo].landedSha?`), the loop in U1, `packages/engine/src/__tests__/workspace-merger-idempotency.test.ts` (new).
|
||||
**Approach:** Per KTD3. `landOneRepo` records `workspaceWorktrees[repo].landedSha` only (no task move); `landWorkspaceTask` calls `finalizeTask`/move-done exactly once after every acquired repo's landed predicate holds. Landed predicate = ancestor check (or `landedSha` present); skip landed repos. Partial-land → non-done state with `landedSha` persisted; the failure **consumes a `mergeRetry`** and is **auto-retried up to `MAX`, then operator-parked** (user decision).
|
||||
**Test scenarios:**
|
||||
- Re-running `landWorkspaceTask` after repo A landed + repo B failed → A is skipped (not re-landed), B is retried; A's ref does not move twice. (idempotency — partial land)
|
||||
- Landed predicate true when branch tip is an ancestor of the integration tip. (predicate)
|
||||
- `finalizeTask` runs exactly once, only after ALL repos landed (not per-repo). (completion — no premature done)
|
||||
- Partial-land failure consumes one `mergeRetry`; after `MAX` retries the task is operator-parked, not silently failed. (retry/park)
|
||||
**Verification:** Partial lands are idempotent on retry; the task moves done exactly once; auto-retry then park works; no double-land.
|
||||
|
||||
### U3. Per-repo file-scope lease during land (master U7)
|
||||
**Goal:** Serialize concurrent same-sub-repo lands.
|
||||
**Requirements:** KTD4.
|
||||
**Dependencies:** U1.
|
||||
**Files:** the lease seam (FORK-C), the loop in U1, `packages/engine/src/__tests__/workspace-merger-lease.test.ts` (new).
|
||||
**Approach:** Per KTD4. Acquire a per-repo integration-ref lease before each `landOneRepo`, release in `finally`.
|
||||
**Test scenarios:**
|
||||
- Two workspace tasks landing the same sub-repo concurrently → serialized (one waits/fails-fast, no interleaved `update-ref`). (concurrency)
|
||||
- Disjoint sub-repos → land in parallel without contention. (no false serialization)
|
||||
- Lease released on land failure (no stuck lock). (cleanup)
|
||||
**Verification:** Same-sub-repo lands serialize; the lease never leaks.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** the extraction (U0), the per-repo merge loop + R7-throw replacement (U1), landed predicate + idempotent retry (U2), per-repo lease (U3).
|
||||
|
||||
### Deferred to Follow-Up Work (Phase D / master U8·U9)
|
||||
- Self-healing reconcilers for partial-landed / stuck workspace merges.
|
||||
- The e2e workspace harness.
|
||||
- Per-repo worktree teardown (carried residual).
|
||||
- Remote push of integration refs (explicitly out — D2/D5 are local-ref only).
|
||||
- Store-level atomic per-repo `workspaceWorktrees` merge (carried residual).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 — R7 throw replacement must not weaken the single-repo guard.** Mitigation: KTD2 dispatches only when `workspaceWorktrees` non-empty; untaught doors keep the throw; regression + defense-in-depth tests.
|
||||
- **R2 — Partial-land leaves inconsistent local state.** Accepted (D2/D5: local + operator-resettable). Mitigation: KTD3 idempotent retry + persisted `landedSha`; the local-ref-only design means no remote pollution.
|
||||
- **R3 — Clean-room helper reuse across the loop.** `runAiMerge`'s temp-worktree/CAS seams must be callable per repo without cross-repo state bleed. Mitigation: feasibility pre-check verifies the seams; U1 asserts no cross-repo bleed.
|
||||
- **R4 — Lease vs acquisition-exclusivity confusion.** The Phase-A/U2 acquisition lock and the Phase-C land lease are different scopes. Mitigation: KTD4 distinct kind; test both.
|
||||
- **R5 — `executor.ts` extraction regression (U0).** Mitigation: behavior-preserving; existing suites are the oracle; gate-green before U1.
|
||||
- **Stacking dependency:** off Phase B (#1714); diff includes the stack.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Master plan (U5/U6/U7, KTD2/KTD4/KTD7, D2/D5, R7).
|
||||
- This session: `runAiMerge` advances the LOCAL integration ref via `update-ref` CAS (~merger-ai.ts:817/847), no push; the R7 chokepoint guard `assertNotWorkspaceTaskMerge` (~:979) + the door guards; `store.mergeTask` (third path); `SelfHealingManager.cleanupStaleTempMergeWorktrees` prefix sweep.
|
||||
- Phase A/B (#1713/#1714): per-repo `baseCommitSha`, `activeWorktrees` Set, `workspace-paths.ts`, `_workspace-fixture.ts`, the workspace helpers U0 extracts.
|
||||
136
docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md
Normal file
136
docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "feat: Workspace mode Phase D — self-healing reconcilers + e2e harness"
|
||||
status: active
|
||||
date: 2026-06-22
|
||||
type: feat
|
||||
origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase D / U8·U9)
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: Workspace mode Phase D — self-healing reconcilers + e2e harness
|
||||
|
||||
> **ID namespace:** local `U1·U2` decompose master-plan **U8, U9**.
|
||||
> **Anchors feasibility-VERIFIED.** The pre-check found a P0 (an existing reconciler wrongly finalizes a partial-landed workspace task) and resolved all three forks — folded in below.
|
||||
|
||||
## Summary
|
||||
|
||||
Phase D closes the workspace-mode lifecycle. **The headline is not new reconcilers — it's making the EXISTING self-healing layer workspace-aware**, because Phase C's `status:"merging"` and the singular `task.worktree===null` shape make the current reconcilers either wrongly finalize or silently skip workspace tasks. Plus new reconcilers for partial-land recovery, phantom land-lease reclaim, and per-repo worktree cleanup, and an e2e harness proving the full lifecycle with no remote push. Final phase.
|
||||
|
||||
Builds on Phase C (#1717): `landWorkspaceTask`, `isRepoLanded` (exported), `workspaceWorktrees[repo].landedSha`, the `workspace-repo-land` lease, `WorkspacePartialLandError`, the canonical `isWorkspaceTask`.
|
||||
|
||||
**Stacking:** off Phase C; PR diff includes the whole stack; must not merge until it lands.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Phase C made workspace merges land-as-you-go, but the engine's self-healing reconcilers reason about a singular `task.worktree` + a single landed commit. Two are actively wrong/blind for workspace tasks, and three new states have no recovery:
|
||||
|
||||
- **(P0) `recoverInterruptedMergingTasks` (self-healing.ts:6670) + `recoverStaleMergingStatus` (:2446)** act on any `ACTIVE_MERGE_STATUSES` task; `landWorkspaceTask` sets `"merging"` (merger-ai.ts:1525). If the holder dies after repo A lands, these call the **singular** `findLandedTaskCommit` (:1620, git over the non-git workspace `rootDir`) and on a one-repo hit **finalize the whole task to done + emit `task:merged`** — marking a partial-landed workspace task fully merged.
|
||||
- **(P1) `recoverMergeableReviewTasks` (:5758)** filters on `Boolean(t.worktree)` (:5778) → a mergeable workspace task whose merge enqueue was dropped is **silently skipped forever**.
|
||||
- New states with no recovery: a **partial-landed** stuck task, a **phantom `workspace-repo-land` lease** held by a dead task, and **orphaned per-repo worktrees**.
|
||||
- **Triple-proof** (`evaluateBackwardMoveTripleProof` :820) classifies liveness via `task.worktree`/`canonicalFusionBranchName` — not workspace-aware (liveness lives across N sub-repo worktrees).
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### KTD1 — Make the EXISTING merging-status + mergeable-review reconcilers workspace-aware (P0/P1; master U8; FN-5893)
|
||||
For an `isWorkspaceTask(task)` candidate:
|
||||
- `recoverInterruptedMergingTasks` / `recoverStaleMergingStatus` must **NOT** use `findLandedTaskCommit`/single-commit finalize. Instead clear the transient `"merging"` status and decide via the **per-repo** `isRepoLanded` predicate: all repos landed → finalize once (the `finalizeWorkspaceTask` path); partial/none → re-enqueue (KTD3). Never finalize a workspace task on one repo's commit.
|
||||
- `recoverMergeableReviewTasks` must admit `isWorkspaceTask` candidates (relax the `Boolean(t.worktree)` gate to `Boolean(t.worktree) || isWorkspaceTask(t)`), so a zero-landed mergeable workspace task is re-enqueued, not skipped.
|
||||
|
||||
### KTD2 — New partial-land reconciler + workspace-aware liveness; re-enqueue via `enqueueMerge` (master U8; FORK-A resolved)
|
||||
A new reconciler finds workspace tasks in a non-done state with a stale binding and re-enqueues the merge via **`this.options.enqueueMerge?.(task.id)`** (`SelfHealingOptions.enqueueMerge` :308, wired in-process-runtime.ts:795 → `internalEnqueueMerge` → routes workspace tasks to `landWorkspaceTask`) — **NOT a direct `landWorkspaceTask` call**. `landWorkspaceTask` is idempotent (`isRepoLanded` skips landed repos). Reuse `allowsAutoMergeProcessing` (task-merge.ts:62 — the canonical FN-5147 `autoMerge:false` guard) + user-pause + a **workspace-aware liveness predicate** (any sub-repo worktree active via `activeSessionRegistry.pathsForTask(task.id)` + `isPathActive`, since triple-proof isn't workspace-aware). Emits `task:reconcile-workspace-partial-land` (+ `-no-action`).
|
||||
**FORK-A (unrecoverable):** a repo is unrecoverable iff its `fusion/<id>` branch is gone **AND** `landedSha` is unset (nothing landed, nothing to land) → park `status:"failed"`. Branch gone but `landedSha` set → already landed (`isRepoLanded` ancestor check) → skip. Otherwise retryable.
|
||||
|
||||
### KTD3 — Phantom `workspace-repo-land` lease reclaim via a new registry enumeration seam (master U8)
|
||||
`ActiveSessionRegistry` exposes only `lookupByPath`/`isPathActive`/`pathsForTask` — no enumeration by kind, and a dead task is gone from the in-progress lists (so FN-6736's iterate-tasks approach can't surface a leaked lease). **Add an enumeration seam** `entriesByKind(kind)` → `{path, taskId, kind, registeredAt}[]` (`registeredAt` already tracked, active-session-registry.ts:31). The reconciler enumerates `workspace-repo-land` entries, and for each whose owner is terminal/dead AND `registeredAt` older than a floor (reuse the FN-6736 `graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER` analog, :966), clears it + emits `task:reclaim-phantom-workspace-land-lease`.
|
||||
|
||||
### KTD4 — Per-repo worktree cleanup from the STORED paths, no directory walk (master U8; FORK-B resolved)
|
||||
**FORK-B premise was wrong** — per-repo worktrees are not anonymous: `workspaceWorktrees[repo].worktreePath` is persisted (types.ts:2276). For a done/dead workspace task, read each recorded `worktreePath` and `git worktree remove --force` it, guarded by `activeSessionRegistry.isPathActive(path)` (mirroring self-healing.ts:9955). **No temp-root readdir/walk** (AGENTS.md) — bounded by construction. Emits `task:reconcile-orphaned-workspace-worktree`.
|
||||
|
||||
### KTD5 — e2e harness placement: engine-default (`describeIfGit`), not the gate (master U9; FORK-C resolved)
|
||||
The merge gate (`engine-core`) is an explicit allow-list excluding real-git tests — a real two-repo fixture e2e cannot run there. Model the **merge + recovery** e2e on `workspace-merger.test.ts` (unmarked, `describeIfGit`, engine-default lane): drive `landWorkspaceTask` directly + invoke the U1/KTD2 reconciler method directly with fake timers; assert local-ref advancement, **no push**, and partial-land recovery. Reuse the existing `executor-workspace-capture.test.ts` / `reviewer-workspace.test.ts` direct-call tests for the capture/review legs. Reserve a single `.slow.test.ts` (engine-slow lane) only if a full ProjectEngine acquire→capture→review→merge loop must be proven.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
> **Standing requirements:** `FNXC:Workspace <yyyy-MM-dd-hh:mm>`; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo fixture; fake timers; no mock-the-world; **no unbounded temp walk**); FN-5893 (the EXISTING reconcilers are in scope, not just new ones); the merge gate. Branch off Phase C (`gsxdsm/workspace-phase-d`).
|
||||
|
||||
### U1. Workspace-aware self-healing (master U8)
|
||||
|
||||
**Goal:** Make the existing reconcilers workspace-safe (P0/P1) and add partial-land recovery, phantom-lease reclaim, and per-repo worktree cleanup — none moving a human-gated/live task backward.
|
||||
|
||||
**Requirements:** KTD1, KTD2, KTD3, KTD4.
|
||||
|
||||
**Dependencies:** Phase C.
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/self-healing.ts` — workspace-aware branches in `recoverInterruptedMergingTasks` (:6670), `recoverStaleMergingStatus` (:2446), `recoverMergeableReviewTasks` (:5758); the new partial-land reconciler (re-enqueue via `enqueueMerge`); the phantom-lease reclaim (via the new registry seam); the per-repo worktree cleanup; the workspace-aware liveness predicate.
|
||||
- `packages/engine/src/active-session-registry.ts` — new `entriesByKind(kind)` enumeration seam.
|
||||
- `packages/engine/src/run-audit.ts` — add the four literals to the `DatabaseMutationType` union (`task:reconcile-workspace-partial-land`, `-no-action`, `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`).
|
||||
- `AGENTS.md` — add the new run-audit events to the Run Audit list.
|
||||
- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new — real two-repo fixture).
|
||||
|
||||
**Approach:** Per KTD1-KTD4. Reuse `allowsAutoMergeProcessing` + the workspace-aware liveness predicate as the "safe to move backward" gate; re-enqueue via `enqueueMerge`; mirror FN-6736 for the lease floor; cleanup from stored paths.
|
||||
|
||||
**Test scenarios:**
|
||||
- A partial-landed (repo A `landedSha`, repo B not) task stuck `"merging"` with no live holder → `recoverInterruptedMergingTasks` does **NOT** finalize it done; the partial-land reconciler re-enqueues; a later land completes it (skipping A). (P0 regression + recovery)
|
||||
- A zero-landed mergeable workspace task whose merge was dropped → `recoverMergeableReviewTasks` re-enqueues it (not skipped by the `worktree` gate). (P1)
|
||||
- `autoMerge:false` / user-paused / a live sub-repo worktree (via `pathsForTask`+`isPathActive`) → `-no-action` (not moved backward). (FN-5147 guards)
|
||||
- A `workspace-repo-land` lease owned by a terminal/dead task, older than the floor → reclaimed; owned by a live merging task → untouched. (phantom reclaim)
|
||||
- A done workspace task's recorded per-repo worktrees → removed (guarded by `isPathActive`); a live task's → untouched; **no temp-root walk**. (cleanup)
|
||||
- A repo with branch gone + `landedSha` unset → parked failed; branch gone + `landedSha` set → skipped as landed. (FORK-A)
|
||||
- Single-repo (non-workspace) tasks → all reconcilers behave identically. (regression)
|
||||
|
||||
**Verification:** No reconciler wrongly finalizes/skips/moves-backward a workspace task; partial/phantom/orphan states recover; single-repo unchanged; no unbounded walk.
|
||||
|
||||
### U2. End-to-end merge + recovery harness (master U9)
|
||||
|
||||
**Goal:** Prove a real two-repo workspace task lands both repos on local refs with no push, and that partial-land recovers via U1.
|
||||
|
||||
**Requirements:** KTD5.
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:** `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — engine-default lane, `describeIfGit`, real two-repo fixture, fake timers).
|
||||
|
||||
**Approach:** Per KTD5. Drive `landWorkspaceTask` on a real two-repo fixture; assert both local integration refs advanced, **no `refs/remotes` change / no push**, `landedSha` per repo, finalize-once. Partial-land: force repo B conflict → assert A landed + task not done, then invoke the U1 partial-land reconciler (fake timers) → assert recovery. Reference the existing `executor-workspace-capture` / `reviewer-workspace` tests for the capture/review legs (don't re-drive the full engine loop unless a `.slow` test is added).
|
||||
|
||||
**Test scenarios:**
|
||||
- Two repos land → both local refs advanced, **no push**, both `landedSha`, task done once. (e2e happy + no-push invariant)
|
||||
- Partial-land → A landed, task not done → U1 reconciler → recovery completes. (e2e recovery)
|
||||
|
||||
**Verification:** Real workspace task lands end-to-end with no remote push; partial-land self-heals.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** workspace-aware existing reconcilers + the three new reconcilers (U1), the merge+recovery e2e (U2).
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Extracting `workspace-merger.ts`; per-sub-repo cwd reachability verification; store-level atomic per-repo merge (Phase-C residuals).
|
||||
- A full ProjectEngine acquire→capture→review→merge `.slow` loop test (only if needed).
|
||||
- Rich dashboard per-repo merge-status UI. Remote push of integration refs (out — D2/D5).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R1 (P0-class) — wrongly finalizing/skipping/moving-backward a workspace task.** The whole point of U1. Mitigation: KTD1 fixes the two wrong/blind reconcilers; every reconciler reuses `allowsAutoMergeProcessing` + the workspace-aware liveness predicate + triple-proof analog; tests assert the `-no-action` + no-wrong-finalize paths.
|
||||
- **R2 — unbounded temp walk.** Mitigation: KTD4 uses stored paths only; test asserts no walk.
|
||||
- **R3 — e2e lane.** Mitigation: KTD5 places it in engine-default (`describeIfGit`), not the gate.
|
||||
- **R4 — reconciler idempotency / double-act.** Mitigation: `isRepoLanded` + `enqueueMerge` idempotency.
|
||||
- **Stacking:** off Phase C (#1717).
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Master plan (U8/U9, FN-5147/FN-6736).
|
||||
- Phase-D feasibility pre-check (verified anchors: the P0 `recoverInterruptedMergingTasks`/`findLandedTaskCommit` finalize, `recoverMergeableReviewTasks` `Boolean(t.worktree)` gate :5778, `enqueueMerge` :308, no registry `entriesByKind`, stored `worktreePath`, engine-core gate allow-list, `allowsAutoMergeProcessing` :62, triple-proof :820, FN-6736 floor :966).
|
||||
- Phase C (#1717): `isRepoLanded`, `landedSha`, the lease, `landWorkspaceTask`, `isWorkspaceTask`.
|
||||
- `self-healing.ts`, `active-session-registry.ts`, `run-audit.ts`, `_workspace-fixture.ts`, `workspace-merger.test.ts` (the lane model).
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped"
|
||||
date: 2026-06-21
|
||||
category: docs/solutions/logic-errors
|
||||
module: engine (workflow store + graph executor)
|
||||
problem_type: logic_error
|
||||
component: service_object
|
||||
symptoms:
|
||||
- "Enabling a built-in optional-group (browser-verification) on a coding/stepwise task did nothing — the group's steps never ran."
|
||||
- "The default-on seed path and direct graph-executor unit tests passed, masking the bug; only user-driven enable (create-with-enable or update/toggle) failed."
|
||||
- "No error surfaced — the enabled group was silently bypassed."
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: high
|
||||
related_components:
|
||||
- workflow-store
|
||||
- graph-executor
|
||||
- optional-group
|
||||
tags:
|
||||
- optional-group
|
||||
- enabledworkflowsteps
|
||||
- per-task-override
|
||||
- id-collision
|
||||
- workflow-store
|
||||
- silent-bypass
|
||||
---
|
||||
|
||||
# Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped
|
||||
|
||||
## Problem
|
||||
|
||||
A graph-native `optional-group` workflow node is enabled per task via the `enabledWorkflowSteps` array, keyed by the group's **node id**. The graph executor runs the group only when `task.enabledWorkflowSteps.includes(node.id)`. But the store's `resolveEnabledWorkflowSteps` ran every id through the **legacy step-template materializer** (`getBuiltInWorkflowTemplate` → `ensureWorkflowStepForTemplate`). The built-in `browser-verification` group deliberately reused the template id `"browser-verification"` as its node id (for back-compat), so that id matched a `WORKFLOW_STEP_TEMPLATES` entry and was **remapped to a materialized `WorkflowStep` row id** (≠ the node id). The executor's membership check then never matched, and the enabled group was silently bypassed — the headline use case (turn the optional step on) did nothing, with no error.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Enabling `browser-verification` on a coding/stepwise task ran nothing pre-merge.
|
||||
- Direct graph-executor tests (which pass a raw `enabledWorkflowSteps: ["browser-verification"]`) and the default-on **seed** path passed — masking the defect.
|
||||
- Only the **user-driven** enable paths failed: create-with-explicit-enable and `updateTask({ enabledWorkflowSteps })` (the per-task toggle in the UI).
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Trusting the existing tests.** The unit tests used group ids like `og-on`/`og-off` that do **not** collide with any `WORKFLOW_STEP_TEMPLATES` id, so `getBuiltInWorkflowTemplate` returned undefined and the id passed through untouched — the tests were green precisely because they avoided the colliding id. The bug only fires when the group id equals a built-in template id.
|
||||
- **Assuming the executor test covered it.** The two-task divergence test enabled the group by writing `enabledWorkflowSteps` straight onto the task, bypassing the store's resolver — so it never exercised the remap. The defect lived entirely in the create/update **resolution** path, one layer above the executor.
|
||||
|
||||
## Solution
|
||||
|
||||
Pass a workflow's optional-group node ids through `resolveEnabledWorkflowSteps` **untouched** — they are executor toggle keys, not legacy step-template ids to be materialized.
|
||||
|
||||
```ts
|
||||
// NEW: enumerate every optional-group node id (regardless of defaultOn).
|
||||
export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] {
|
||||
return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); // templateId === group node id
|
||||
}
|
||||
|
||||
// store.ts — the resolver gains an optional pass-through set:
|
||||
private async resolveEnabledWorkflowSteps(
|
||||
stepIds?: string[],
|
||||
optionalGroupIds?: Set<string>,
|
||||
): Promise<string[] | undefined> {
|
||||
// ...
|
||||
// Optional-group toggle ids pass through raw — never materialized as legacy step rows.
|
||||
const template = optionalGroupIds?.has(stepId)
|
||||
? undefined
|
||||
: this.getBuiltInWorkflowTemplate(stepId);
|
||||
const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId;
|
||||
// ...
|
||||
}
|
||||
|
||||
// helper resolving the task's workflow IR → its optional-group id set:
|
||||
private async optionalGroupIdSet(workflowId?: string | null): Promise<Set<string>> {
|
||||
const wfId = workflowId ?? (await this.getDefaultWorkflowId());
|
||||
if (!wfId) return new Set();
|
||||
const def = await this.getWorkflowDefinition(wfId);
|
||||
if (!def || def.kind === "fragment") return new Set();
|
||||
return new Set(resolveAllOptionalGroupIds(def.ir));
|
||||
}
|
||||
```
|
||||
|
||||
Both user-enable call sites supply the set: create (`optionalGroupIdSet(input.workflowId)`) and update (`optionalGroupIdSet(getTaskWorkflowSelection(task.id)?.workflowId)`).
|
||||
|
||||
**Regression test** — must use a **colliding** id (`browser-verification`), since non-colliding ids never reproduce it: create-with-enable and update/toggle both assert the raw group node id survives in `enabledWorkflowSteps`.
|
||||
|
||||
## Why This Works
|
||||
|
||||
The bug is a **per-task override that is read correctly at the action site but rewritten en route**. The override (`enabledWorkflowSteps`) was consulted exactly where the action runs (the graph executor), but the value was mutated in the **resolution path** before it got there, because two id namespaces overlap: graph-native optional-group **node ids** and legacy **`WorkflowStep` template ids**. The materializer is meaningful only for the retired declaration/`workflow-step`-seam execution model; for a graph-native group it is pure harm. Marking group ids as pass-through keeps the key **identity-stable** from definition through every consumer, so the executor's `includes(node.id)` check matches.
|
||||
|
||||
(Verified the related slim-projection trap does **not** apply: the executor reads `enabledWorkflowSteps` off the `TaskDetail` snapshot it is handed, not a column-narrowed SELECT, so the array is fully hydrated.)
|
||||
|
||||
## Prevention
|
||||
|
||||
- **When introducing a new identity/key that shares a namespace with an existing one, grep every reader AND every *transformer* of that key.** A silent remap in a resolver is as fatal as a missing read — the override "survives" but as the wrong value. Demand each consumer is either re-keyed or argued identity-stable.
|
||||
- **Regression tests for namespace collisions must use a *colliding* value.** A test with a deliberately distinct id proves nothing about the collision; pick the id that actually overlaps the legacy namespace (here, a built-in template id reused as a node id).
|
||||
- **Test the path the user actually takes, not just the layer under test.** The executor-level test bypassed the store resolver where the bug lived; a create/update round-trip through the store would have caught it. Prefer at least one end-to-end seam test per per-task facet.
|
||||
- **A facet that "works on seed/default but not on toggle" is the tell.** Asymmetry between the seed path (writes raw ids) and the user-enable path (runs the resolver) localizes the defect to the resolver.
|
||||
|
||||
## Related Issues
|
||||
|
||||
This is the **id-namespace-collision variant** of the per-task/per-entity override blast-radius class. Same disease (override invisible to the user, no error), different organ (key rewritten in resolution vs. not consulted at a trigger gate):
|
||||
|
||||
- [Per-task auto-merge override ignored by trigger-layer gates](../logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md) — sibling: override dead from the user's perspective; theirs is a missed trigger gate, ours is a resolution-path key remap. Its "consult the override everywhere between definition and action" rule covers this case too.
|
||||
- [Per-entity execution-principal override: the full blast-radius checklist](../architecture-patterns/per-entity-execution-principal-override-blast-radius.md) — the generalizing checklist; closest prior art is its "validate composite node ids against the graph, never round-trip them" example. This bug is a new bullet for that checklist.
|
||||
- [Workflow-native execution through runtime primitives](../architecture-patterns/workflow-native-runtime-primitives.md) — context: the legacy-`WorkflowStep`-row vs. graph-node two-control-planes tension this collision exploits.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveGlobalDir,
|
||||
DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS,
|
||||
isWorkflowColumnsEnabled,
|
||||
isWorkspaceTask,
|
||||
resolveColumnFlags,
|
||||
BUILTIN_CODING_WORKFLOW_IR,
|
||||
mergeBuiltInZaiProviderModels,
|
||||
@@ -41,7 +42,8 @@ import {
|
||||
type RuntimeLogger,
|
||||
} from "@fusion/dashboard";
|
||||
import {
|
||||
aiMergeTask,
|
||||
runAiMerge,
|
||||
landWorkspaceTask,
|
||||
MissionAutopilot,
|
||||
MissionExecutionLoop,
|
||||
HeartbeatMonitor,
|
||||
@@ -1295,11 +1297,49 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// wrapper function while the underlying implementation is swapped when the
|
||||
// engine starts in engine mode.
|
||||
//
|
||||
// In UI-only mode: calls aiMergeTask directly (no engine, no semaphore).
|
||||
// In UI-only mode: calls runAiMerge directly (no engine, no semaphore).
|
||||
// In engine mode: replaced by engine.onMerge() after ProjectEngine starts
|
||||
// (semaphore-gated via the engine's InProcessRuntime).
|
||||
//
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified all merge
|
||||
// entry points onto runAiMerge (the FN-5633 clean-room AI merge path);
|
||||
// aiMergeTask is soft-deprecated.
|
||||
//
|
||||
const onMergeImpl = async (taskId: string) => {
|
||||
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
|
||||
// Dashboard merge button (UI-only mode). A workspace-mode task routes through
|
||||
// the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its
|
||||
// own LOCAL integration ref, no push) instead of throwing — manual merge works in
|
||||
// Phase C (user decision). U0's R7 throw is replaced here by routing; the engine
|
||||
// chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth.
|
||||
const mergeTask = await store.getTask(taskId).catch(() => null);
|
||||
// FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask`
|
||||
// (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check.
|
||||
const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask);
|
||||
if (isWorkspaceMerge) {
|
||||
const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, {
|
||||
agentStore,
|
||||
});
|
||||
const latest = await store.getTask(taskId).catch(() => mergeTask!);
|
||||
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B3):
|
||||
// landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2),
|
||||
// so the merge door must report merged=true when the workspace fully landed — mirroring
|
||||
// the engine dispatch's MergeResult. The first landed sub-repo's landedSha is the recorded
|
||||
// commitSha (same convention finalizeWorkspaceTask uses). On a partial land, merged stays
|
||||
// false and the partial-land error surfaces on the task log.
|
||||
const landedSha = workspaceResult.repos.find((r) => r.status === "landed")?.landedSha;
|
||||
return {
|
||||
task: latest ?? mergeTask!,
|
||||
branch: getTaskBranchName(taskId),
|
||||
merged: workspaceResult.allLanded,
|
||||
mergeConfirmed: workspaceResult.allLanded || undefined,
|
||||
commitSha: workspaceResult.allLanded ? landedSha : undefined,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log",
|
||||
};
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
if (getMergeStrategy(settings) === "pull-request") {
|
||||
const githubClient = new GitHubClient();
|
||||
@@ -1327,7 +1367,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
);
|
||||
|
||||
try {
|
||||
return await aiMergeTask(store, cwd, taskId, {
|
||||
return await runAiMerge(store, cwd, taskId, {
|
||||
agentStore,
|
||||
onAgentText: (delta) => streamedMergeLog.push(delta),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, 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 { runAiMerge, landWorkspaceTask } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||
@@ -851,7 +851,40 @@ export async function runTaskMerge(id: string, projectName?: string) {
|
||||
console.log(`\n Merging ${id} with AI...\n`);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, projectPath, id, {
|
||||
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
|
||||
// User-triggered `fn task merge`. A workspace-mode task routes through the
|
||||
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
|
||||
// LOCAL integration ref, no push) instead of throwing — manual merge works in
|
||||
// Phase C (user decision). U0's R7 throw is replaced here by routing; the
|
||||
// engine chokepoint + store.mergeTask/aiMergeTask keep throwing.
|
||||
const mergeTaskRecord = await store.getTask(id).catch(() => null);
|
||||
// FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask`
|
||||
// (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check.
|
||||
const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord);
|
||||
if (isWorkspaceMerge) {
|
||||
const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
console.log();
|
||||
for (const repo of workspaceResult.repos) {
|
||||
const label =
|
||||
repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}`
|
||||
: repo.status === "empty" ? "no net changes"
|
||||
: `failed: ${repo.error ?? "unknown"}`;
|
||||
console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`);
|
||||
}
|
||||
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B3):
|
||||
// landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2),
|
||||
// so report it as merged rather than "remains in review until U2". A partial land leaves
|
||||
// the task in review (landed repos stay landed locally) and exits non-zero.
|
||||
console.log(
|
||||
`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed — task finalized to done" : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`,
|
||||
);
|
||||
if (!workspaceResult.allLanded) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runAiMerge(store, projectPath, id, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertNotWorkspaceTaskMerge } from "../types.js";
|
||||
|
||||
// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0).
|
||||
// This shared predicate is called at all four merge entry points (engine
|
||||
// dispatch, store.mergeTask, CLI onMergeImpl, CLI runTaskMerge). Workspace-mode
|
||||
// tasks (populated workspaceWorktrees) must be held until per-repo merge support
|
||||
// lands (master-plan U6); single-repo tasks are a no-op.
|
||||
describe("assertNotWorkspaceTaskMerge (R7 workspace merge-boundary guard)", () => {
|
||||
it("is a no-op for a single-repo task (no workspaceWorktrees)", () => {
|
||||
expect(() => assertNotWorkspaceTaskMerge({ id: "FN-1" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("is a no-op when workspaceWorktrees is an empty record", () => {
|
||||
expect(() =>
|
||||
assertNotWorkspaceTaskMerge({ id: "FN-1", workspaceWorktrees: {} }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws a U6-named error for a populated workspace task", () => {
|
||||
expect(() =>
|
||||
assertNotWorkspaceTaskMerge({
|
||||
id: "FN-WS",
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" },
|
||||
"repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" },
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
"Workspace task FN-WS cannot merge until per-repo merge support (master-plan U6) lands",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws even with a single workspace worktree entry", () => {
|
||||
expect(() =>
|
||||
assertNotWorkspaceTaskMerge({
|
||||
id: "FN-WS1",
|
||||
workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/a", branch: "b" } },
|
||||
}),
|
||||
).toThrow(/master-plan U6/);
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,33 @@ describe("builtin coding workflow ir", () => {
|
||||
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
|
||||
.map((node) => String(node.config?.seam ?? ""))
|
||||
.filter((seam) => seam.length > 0);
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review"]));
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "review"]));
|
||||
// U6: the `workflow-step` seam was replaced by the browser-verification
|
||||
// optional-group; no node declares the legacy seam anymore.
|
||||
expect(seams).not.toContain("workflow-step");
|
||||
expect(seams).not.toContain("merge");
|
||||
expect(seams).not.toContain("triage");
|
||||
});
|
||||
|
||||
it("expresses pre-merge browser-verification as a default-off optional-group (U6)", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
const group = byId.get("browser-verification");
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
expect(group?.config?.name).toBe("Browser Verification");
|
||||
expect(group?.config?.defaultOn).toBe(false);
|
||||
// execute → browser-verification → review on the success path; failure → end.
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }),
|
||||
expect.objectContaining({ from: "browser-verification", to: "review", condition: "success" }),
|
||||
expect.objectContaining({ from: "browser-verification", to: "end", condition: "failure" }),
|
||||
]),
|
||||
);
|
||||
// The legacy optionalSteps declaration is gone (the group replaces it).
|
||||
expect("optionalSteps" in BUILTIN_CODING_WORKFLOW_IR).toBe(false);
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -73,16 +95,17 @@ describe("builtin coding workflow ir", () => {
|
||||
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("workflow-step")?.column).toBe("in-progress");
|
||||
// U6: browser-verification optional-group replaces the workflow-step seam.
|
||||
expect(byId.get("browser-verification")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge-gate")?.column).toBe("in-review");
|
||||
expect(byId.get("merge-attempt")?.column).toBe("in-review");
|
||||
});
|
||||
|
||||
it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => {
|
||||
it("assigns descriptive names to execute/review seam nodes and the browser-verification group", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.config?.name).toBe("Execute");
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
});
|
||||
|
||||
@@ -94,9 +117,8 @@ describe("builtin coding workflow ir", () => {
|
||||
expect(config.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("merge-attempt")?.config?.maxReworkCycles).toBe(3);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,11 @@ describe("built-in workflows", () => {
|
||||
|
||||
const byId = new Map(ir.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("workflow-step")?.column).toBe("in-progress");
|
||||
// U6: the legacy `workflow-step` seam is replaced by the pre-merge
|
||||
// `browser-verification` optional-group, placed in the implementation column.
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
expect(byId.get("browser-verification")?.kind).toBe("optional-group");
|
||||
expect(byId.get("browser-verification")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
// Merge is the native primitive region (FN-6035), placed in in-review.
|
||||
expect(byId.get("merge")).toBeUndefined();
|
||||
@@ -312,9 +316,12 @@ describe("built-in workflows", () => {
|
||||
expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(candidate.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
// U6: pre-merge browser-verification is an optional-group (default OFF),
|
||||
// not the legacy `workflow-step` seam.
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
expect(byId.get("browser-verification")?.kind).toBe("optional-group");
|
||||
expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
// The merge lifecycle is no longer a single `merge` seam node (FN-6035): it
|
||||
// is expressed as the merge-gate/merge-attempt/branch-group primitive region.
|
||||
@@ -600,17 +607,22 @@ describe("built-in workflows", () => {
|
||||
description: "implicit builtin default",
|
||||
});
|
||||
|
||||
// U6: builtin:coding now carries the `browser-verification` optional-group
|
||||
// (an interpreter-deferred construct), so its DEFAULT-workflow materialization
|
||||
// falls back to no legacy WorkflowStep rows and records no selection row —
|
||||
// identical to the stepwise built-in below. The group is defaultOn:false, so
|
||||
// enabledWorkflowSteps stays empty.
|
||||
await store.setDefaultWorkflowId("builtin:coding");
|
||||
const codingTask = await store.createTask({ description: "default builtin coding" });
|
||||
expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
expect(store.getTaskWorkflowSelection(codingTask.id)).toBeUndefined();
|
||||
|
||||
const reservedCodingTask = await store.createTaskWithReservedId(
|
||||
{ description: "reserved default builtin coding" },
|
||||
{ taskId: "reserved-default-builtin-coding" },
|
||||
);
|
||||
expect((await store.getTask(reservedCodingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toBeUndefined();
|
||||
|
||||
await store.setDefaultWorkflowId("builtin:stepwise-coding");
|
||||
const stepwiseTask = await store.createTask({ description: "default builtin stepwise" });
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
|
||||
import {
|
||||
compileWorkflowToSteps,
|
||||
MERGE_REGION_NODE_KINDS,
|
||||
validateLinearity,
|
||||
WorkflowCompileError,
|
||||
WORKFLOW_INTERPRETER_DEFERRED_SUFFIX,
|
||||
@@ -127,33 +126,25 @@ describe("compileWorkflowToSteps (U2)", () => {
|
||||
expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i);
|
||||
});
|
||||
|
||||
it("validates builtin workflow linearity while preserving stepwise interpreter deferral", () => {
|
||||
expect(validateLinearity(BUILTIN_CODING_WORKFLOW_IR)).toBeNull();
|
||||
it("defers both builtin coding and stepwise to the interpreter (U6: coding now carries an optional-group)", () => {
|
||||
// U6: builtin:coding gained the `browser-verification` optional-group on its
|
||||
// pre-merge path — a branching, single-pass container the linear WorkflowStep
|
||||
// runner cannot lower. Like stepwise, coding is now interpreter-deferred.
|
||||
const codingErr = validateLinearity(BUILTIN_CODING_WORKFLOW_IR);
|
||||
expect(codingErr).toBeInstanceOf(WorkflowCompileError);
|
||||
expect(codingErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
|
||||
|
||||
const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
|
||||
expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError);
|
||||
expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
|
||||
});
|
||||
|
||||
it("compiles the builtin coding workflow without merge-region steps", () => {
|
||||
const steps = compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR);
|
||||
const mergeRegionNodeIds = BUILTIN_CODING_WORKFLOW_IR.nodes
|
||||
.filter((node) => MERGE_REGION_NODE_KINDS.has(node.kind))
|
||||
.map((node) => node.id);
|
||||
|
||||
expect(steps.map((step) => step.name)).toEqual([]);
|
||||
expect(mergeRegionNodeIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
"merge-gate",
|
||||
"merge-retry",
|
||||
"merge-manual-hold",
|
||||
"branch-group-member-integration",
|
||||
"branch-group-promotion",
|
||||
"merge-attempt",
|
||||
"recovery-router",
|
||||
]),
|
||||
);
|
||||
expect(steps.some((step) => mergeRegionNodeIds.includes(step.name))).toBe(false);
|
||||
it("defers compiling the builtin coding workflow to the interpreter (U6)", () => {
|
||||
// The browser-verification optional-group makes the graph non-linear, so
|
||||
// compileWorkflowToSteps throws the interpreter-deferred error rather than
|
||||
// producing a (previously empty) linear pre-merge step list.
|
||||
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(WorkflowCompileError);
|
||||
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(/interpreter \(deferred\)/i);
|
||||
});
|
||||
|
||||
it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => {
|
||||
|
||||
154
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
154
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js";
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
U1 validation contract for the `optional-group` container node — the single-pass,
|
||||
toggle-gated subgraph that replaces the declaration-based optional-steps model.
|
||||
Mirrors the loop validation suite minus loop-specific exit config.
|
||||
*/
|
||||
|
||||
const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }];
|
||||
|
||||
function groupTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
|
||||
{ id: "report", kind: "prompt", config: { prompt: "report" } },
|
||||
],
|
||||
edges: [{ from: "verify", to: "report" }],
|
||||
};
|
||||
}
|
||||
|
||||
function groupIr(config: Record<string, unknown> = {}): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-test",
|
||||
columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "browser-verification",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
name: "Browser Verification",
|
||||
defaultOn: false,
|
||||
template: groupTemplate(),
|
||||
...config,
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "browser-verification" },
|
||||
{ from: "browser-verification", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("optional-group validation", () => {
|
||||
it("parses and round-trips a valid optional-group node", () => {
|
||||
const parsed = parseWorkflowIr(groupIr()) as WorkflowIrV2;
|
||||
const group = parsed.nodes.find((n) => n.id === "browser-verification");
|
||||
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
|
||||
});
|
||||
|
||||
it("does not require defaultOn (defaults to off via the resolver)", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ defaultOn: undefined }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean defaultOn", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ defaultOn: "yes" as unknown as boolean }))).toThrow(
|
||||
/defaultOn must be a boolean/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty template", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/);
|
||||
});
|
||||
|
||||
it("rejects a missing template", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ template: undefined }))).toThrow(
|
||||
/must declare a template/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate template node ids", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes.push({ id: "verify", kind: "script" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/duplicate node ids/);
|
||||
});
|
||||
|
||||
it("rejects template edges that leave the template", () => {
|
||||
const template = groupTemplate();
|
||||
template.edges.push({ from: "report", to: "end" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/references a node outside/);
|
||||
});
|
||||
|
||||
it("rejects rework edges inside the template (single-pass guarantee)", () => {
|
||||
const template = groupTemplate();
|
||||
template.edges.push({ from: "report", to: "verify", kind: "rework" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/);
|
||||
});
|
||||
|
||||
it("rejects failure-condition edges inside the template (single-pass bails before routing them)", () => {
|
||||
const template = groupTemplate();
|
||||
// A parallel failure edge that the single-pass walk would silently never take.
|
||||
template.edges.push({ from: "verify", to: "report", condition: "failure" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain failure-condition edges/);
|
||||
});
|
||||
|
||||
it("rejects nested loop/foreach/optional-group regions", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes.push({
|
||||
id: "nested",
|
||||
kind: "optional-group",
|
||||
config: { template: groupTemplate() },
|
||||
});
|
||||
template.edges.push({ from: "report", to: "nested" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(
|
||||
/nested loop\/foreach\/optional-group/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects more than one entry node", () => {
|
||||
const template: ReturnType<typeof groupTemplate> = {
|
||||
nodes: [
|
||||
{ id: "a", kind: "prompt", config: { prompt: "a" } },
|
||||
{ id: "b", kind: "prompt", config: { prompt: "b" } },
|
||||
{ id: "join", kind: "prompt", config: { prompt: "join" } },
|
||||
],
|
||||
// a and b both have no incoming edge → two entries.
|
||||
edges: [
|
||||
{ from: "a", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
],
|
||||
};
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/exactly one entry node/);
|
||||
});
|
||||
|
||||
it("rejects a template node id colliding with a top-level node id", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes[0] = { id: "start", kind: "prompt", config: { prompt: "collide" } };
|
||||
template.edges = [{ from: "start", to: "report" }];
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/collides with a top-level node id/);
|
||||
});
|
||||
|
||||
it("leaves graphs without optional-group nodes byte-identical", () => {
|
||||
const ir: WorkflowIrV2 = {
|
||||
version: "v2",
|
||||
name: "plain",
|
||||
columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
const parsed = parseWorkflowIr(ir);
|
||||
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
|
||||
});
|
||||
});
|
||||
@@ -155,7 +155,12 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — optionalSteps", () => {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy `optionalSteps` declaration field is retired. A legacy persisted
|
||||
// `optionalSteps` key on an old v2 row is now TOLERATED — no longer validated or
|
||||
// required — so old rows still parse as v2 (optional steps are graph-native
|
||||
// `optional-group` nodes now).
|
||||
describe("parseWorkflowIr — legacy optionalSteps tolerated", () => {
|
||||
const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
||||
const base = (): WorkflowIrV2 => v2(
|
||||
columns,
|
||||
@@ -166,27 +171,25 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
||||
[{ from: "start", to: "end" }],
|
||||
);
|
||||
|
||||
it("parses and serializes optionalSteps deterministically", () => {
|
||||
const ir: WorkflowIrV2 = {
|
||||
it("parses a legacy v2 row carrying an optionalSteps key without throwing", () => {
|
||||
const ir = {
|
||||
...base(),
|
||||
// Legacy declaration shapes — including ones the old validator rejected —
|
||||
// are now ignored, not validated.
|
||||
optionalSteps: [
|
||||
{ templateId: "browser-verification" },
|
||||
{ templateId: "plugin:example:step", defaultOn: true },
|
||||
{ defaultOn: "yes" },
|
||||
"nope",
|
||||
],
|
||||
};
|
||||
} as unknown as WorkflowIr;
|
||||
|
||||
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||
const parsed = parseWorkflowIr(ir);
|
||||
expect(parsed).toEqual(ir);
|
||||
expect(parsed.version).toBe("v2");
|
||||
// The key passes through untouched (round-trips through serialize/parse).
|
||||
expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir);
|
||||
});
|
||||
|
||||
it("rejects malformed optionalSteps", () => {
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/);
|
||||
});
|
||||
|
||||
it("upgrades v1 graphs without optionalSteps", () => {
|
||||
const parsed = parseWorkflowIr({
|
||||
version: "v1",
|
||||
@@ -196,7 +199,7 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
||||
});
|
||||
expect(parsed.version).toBe("v2");
|
||||
if (parsed.version !== "v2") throw new Error("expected v2");
|
||||
expect(parsed.optionalSteps).toBeUndefined();
|
||||
expect((parsed as { optionalSteps?: unknown }).optionalSteps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
|
||||
import { resolveWorkflowOptionalSteps } from "../workflow-optional-steps.js";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
import {
|
||||
resolveDefaultOnOptionalGroupIds,
|
||||
resolveWorkflowOptionalSteps,
|
||||
} from "../workflow-optional-steps.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrV2,
|
||||
WorkflowOptionalGroupConfig,
|
||||
} from "../workflow-ir-types.js";
|
||||
|
||||
const v1: WorkflowIr = {
|
||||
version: "v1",
|
||||
@@ -14,105 +22,125 @@ const v1: WorkflowIr = {
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
|
||||
function v2(optionalSteps?: WorkflowIrV2["optionalSteps"]): WorkflowIrV2 {
|
||||
/** Build an optional-group node with a trivial single-prompt template. */
|
||||
function optionalGroupNode(
|
||||
id: string,
|
||||
config: Partial<WorkflowOptionalGroupConfig>,
|
||||
): WorkflowIrNode {
|
||||
return {
|
||||
id,
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: {
|
||||
...config,
|
||||
template: config.template ?? {
|
||||
nodes: [{ id: `${id}-inner`, kind: "prompt" }],
|
||||
edges: [],
|
||||
},
|
||||
} satisfies WorkflowOptionalGroupConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function v2(extraNodes: WorkflowIrNode[] = []): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
...extraNodes,
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
optionalSteps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveWorkflowOptionalSteps", () => {
|
||||
it("resolves the builtin coding browser verification optional step", () => {
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual([
|
||||
describe("resolveWorkflowOptionalSteps (optional-group nodes)", () => {
|
||||
it("resolves two optional-group nodes with names + defaultOn from node config", () => {
|
||||
const ir = v2([
|
||||
optionalGroupNode("og-browser", { name: "Browser Verification", defaultOn: false }),
|
||||
optionalGroupNode("og-security", { name: "Security Audit", defaultOn: true }),
|
||||
]);
|
||||
|
||||
expect(resolveWorkflowOptionalSteps(ir)).toEqual([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
templateId: "og-browser",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
templateId: "og-security",
|
||||
name: "Security Audit",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the builtin stepwise-coding browser verification optional step", () => {
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
]);
|
||||
it("falls back to the node id when the group config omits a name", () => {
|
||||
const ir = v2([optionalGroupNode("og-unnamed", { defaultOn: true })]);
|
||||
const [resolved] = resolveWorkflowOptionalSteps(ir);
|
||||
expect(resolved.templateId).toBe("og-unnamed");
|
||||
expect(resolved.name).toBe("og-unnamed");
|
||||
expect(resolved.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("places a single workflow-step seam node between steps and review in stepwise", () => {
|
||||
const ir = BUILTIN_STEPWISE_CODING_WORKFLOW_IR;
|
||||
if (ir.version !== "v2") throw new Error("expected v2");
|
||||
const seamNodes = ir.nodes.filter(
|
||||
(n) => n.kind === "prompt" && n.config?.seam === "workflow-step",
|
||||
);
|
||||
expect(seamNodes).toHaveLength(1);
|
||||
// success path: steps -> workflow-step -> review
|
||||
expect(ir.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: "steps", to: "workflow-step", condition: "success" }),
|
||||
expect.objectContaining({ from: "workflow-step", to: "review", condition: "success" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips unknown template ids", () => {
|
||||
expect(
|
||||
resolveWorkflowOptionalSteps(v2([
|
||||
{ templateId: "missing" },
|
||||
{ templateId: "browser-verification" },
|
||||
])),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns an empty array for v1 and v2 workflows without optional steps", () => {
|
||||
it("returns an empty array for v1 and v2 workflows without optional-group nodes", () => {
|
||||
expect(resolveWorkflowOptionalSteps(v1)).toEqual([]);
|
||||
expect(resolveWorkflowOptionalSteps(v2())).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves declaration order and resolves plugin templates", () => {
|
||||
const result = resolveWorkflowOptionalSteps(
|
||||
v2([
|
||||
{ templateId: "plugin:demo:first", defaultOn: true },
|
||||
{ templateId: "browser-verification" },
|
||||
]),
|
||||
[
|
||||
{
|
||||
id: "plugin:demo:first",
|
||||
name: "Plugin First",
|
||||
description: "Plugin optional verification",
|
||||
prompt: "Run plugin verification",
|
||||
category: "Quality",
|
||||
icon: "plug",
|
||||
phase: "post-merge",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.map((step) => step.templateId)).toEqual([
|
||||
"plugin:demo:first",
|
||||
"browser-verification",
|
||||
it("ignores a malformed (config-less) optional-group node without crashing", () => {
|
||||
// A stale/partial optional-group node must not throw; it resolves to a
|
||||
// defaultOn:false entry keyed by its id rather than breaking workflow loading.
|
||||
const ir = v2([{ id: "og-bare", kind: "optional-group", column: "todo" }]);
|
||||
expect(resolveWorkflowOptionalSteps(ir)).toEqual([
|
||||
{
|
||||
templateId: "og-bare",
|
||||
name: "og-bare",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
]);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "Plugin First",
|
||||
icon: "plug",
|
||||
phase: "post-merge",
|
||||
defaultOn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the built-in coding/stepwise browser-verification optional-group (U6)", () => {
|
||||
// U6 migrated both built-ins: `browser-verification` is now an optional-group
|
||||
// node (default OFF), so the resolver advertises exactly one toggle entry per
|
||||
// built-in, keyed by the group node id `browser-verification`.
|
||||
const expected = [
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual(expected);
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultOnOptionalGroupIds (task-creation seeding)", () => {
|
||||
it("returns exactly the defaultOn:true group ids", () => {
|
||||
const ir = v2([
|
||||
optionalGroupNode("og-off", { defaultOn: false }),
|
||||
optionalGroupNode("og-on-a", { defaultOn: true }),
|
||||
optionalGroupNode("og-on-b", { defaultOn: true }),
|
||||
]);
|
||||
expect(resolveDefaultOnOptionalGroupIds(ir)).toEqual(["og-on-a", "og-on-b"]);
|
||||
});
|
||||
|
||||
it("seeds an empty set when no optional-group has defaultOn (or none exist)", () => {
|
||||
expect(resolveDefaultOnOptionalGroupIds(v2())).toEqual([]);
|
||||
expect(
|
||||
resolveDefaultOnOptionalGroupIds(v2([optionalGroupNode("og-off", { defaultOn: false })])),
|
||||
).toEqual([]);
|
||||
expect(resolveDefaultOnOptionalGroupIds(v1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,130 @@ describe("TaskStore workflow selection (U3)", () => {
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:30: a new task seeds
|
||||
// `enabledWorkflowSteps` with exactly the defaultOn:true optional-group ids of
|
||||
// its selected workflow (U3, R3), alongside the compiled workflow step ids.
|
||||
describe("optional-group defaultOn seeding (U3/R3)", () => {
|
||||
/** v2 workflow whose success path threads through two optional-group nodes. */
|
||||
function optionalGroupIr(): WorkflowIr {
|
||||
const groupTemplate = (id: string) => ({
|
||||
nodes: [{ id: `${id}-inner`, kind: "prompt" as const, config: { prompt: "x" } }],
|
||||
edges: [],
|
||||
});
|
||||
return {
|
||||
version: "v2",
|
||||
name: "og-wf",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{
|
||||
id: "og-on",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: { name: "On Group", defaultOn: true, template: groupTemplate("og-on") },
|
||||
},
|
||||
{
|
||||
id: "og-off",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: { name: "Off Group", defaultOn: false, template: groupTemplate("og-off") },
|
||||
},
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "og-on", condition: "success" },
|
||||
{ from: "og-on", to: "og-off", condition: "success" },
|
||||
{ from: "og-off", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("seeds the defaultOn:true group id at creation from the default workflow", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "OG Default", ir: optionalGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "seeded" });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("og-on");
|
||||
expect(detail.enabledWorkflowSteps).not.toContain("og-off");
|
||||
});
|
||||
|
||||
it("seeds an empty set when the workflow has no optional groups", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "No OG", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "no groups" });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps ?? []).not.toContain("og-on");
|
||||
});
|
||||
|
||||
it("a stale optional-group id in enabledWorkflowSteps does not crash resolution", async () => {
|
||||
// Group since removed from the workflow: the toggle resolver ignores the
|
||||
// stale id rather than throwing, keeping create/edit surfaces alive.
|
||||
const task = await store.createTask({
|
||||
description: "stale",
|
||||
enabledWorkflowSteps: ["og-removed"],
|
||||
});
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("og-removed");
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: code-review P1 regression. A
|
||||
// built-in optional-group id deliberately equals a WORKFLOW_STEP_TEMPLATES id
|
||||
// (the browser-verification migration). Enabling it on a task must keep the
|
||||
// RAW group node id in enabledWorkflowSteps — not a materialized WorkflowStep
|
||||
// row id — or the executor's `enabledWorkflowSteps.includes(node.id)` check
|
||||
// silently bypasses the group. (The og-on/og-off ids above don't collide, so
|
||||
// only a colliding id exercises the remap bug.)
|
||||
function collidingGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "bv-wf",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{
|
||||
id: "browser-verification",
|
||||
kind: "optional-group",
|
||||
column: "todo",
|
||||
config: {
|
||||
name: "Browser Verification",
|
||||
defaultOn: false,
|
||||
template: { nodes: [{ id: "bv-inner", kind: "prompt", config: { prompt: "verify" } }], edges: [] },
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "todo" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps a built-in-colliding optional-group id unremapped on create-with-enable", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({
|
||||
description: "enable bv",
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
});
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("browser-verification");
|
||||
});
|
||||
|
||||
it("keeps a built-in-colliding optional-group id unremapped on update/toggle", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
const task = await store.createTask({ description: "toggle bv" });
|
||||
await store.updateTask(task.id, { enabledWorkflowSteps: ["browser-verification"] });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toContain("browser-verification");
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit enabledWorkflowSteps overrides the project default", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
|
||||
79
packages/core/src/builtin-browser-verification-group.ts
Normal file
79
packages/core/src/builtin-browser-verification-group.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { WorkflowIrNode } from "./workflow-ir-types.js";
|
||||
import { WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
Both the built-in coding and stepwise-coding workflows express the optional
|
||||
`browser-verification` step as an `optional-group` container node on the pre-merge
|
||||
path (default OFF), REPLACING the legacy `optionalSteps: [{ templateId:
|
||||
"browser-verification" }]` declaration + the hidden `workflow-step` seam node (U6).
|
||||
Enabled (task's `enabledWorkflowSteps` includes the group id) → the browser-
|
||||
verification step runs ONCE pre-merge between implementation and review. Disabled →
|
||||
the group passes through (byte-inert), exactly preserving the prior runtime behavior
|
||||
where the step only ran when toggled on.
|
||||
|
||||
The group node id `browser-verification` is the STABLE per-task enable key (KTD-2):
|
||||
keeping it identical to the prior `optionalSteps` templateId preserves any persisted
|
||||
`enabledWorkflowSteps` entry. The inner template node carries a DISTINCT id
|
||||
(`browser-verification-step`) because a template node id may not collide with the
|
||||
group/top-level node id (U1 validation).
|
||||
|
||||
The inner node mirrors the dashboard's `stepTemplateToNode` projection of the
|
||||
canonical `browser-verification` WORKFLOW_STEP_TEMPLATE: a `prompt` node carrying the
|
||||
template's prompt, `toolMode` (coding), and `gateMode` (advisory default). Sourcing
|
||||
prompt/toolMode from the catalog keeps the built-in byte-identical to the template a
|
||||
human would insert from the palette (KTD-5).
|
||||
*/
|
||||
|
||||
function resolveBrowserVerificationTemplate() {
|
||||
const tpl = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "browser-verification");
|
||||
if (!tpl) {
|
||||
throw new Error("browser-verification WORKFLOW_STEP_TEMPLATE is missing");
|
||||
}
|
||||
return tpl;
|
||||
}
|
||||
|
||||
const BROWSER_VERIFICATION_TEMPLATE = resolveBrowserVerificationTemplate();
|
||||
|
||||
/** Stable per-task enable key + group node id (preserved from the prior templateId). */
|
||||
export const BROWSER_VERIFICATION_GROUP_ID = "browser-verification";
|
||||
|
||||
/** Inner template node id — distinct from the group id (template-node-id collision rule, U1). */
|
||||
export const BROWSER_VERIFICATION_STEP_NODE_ID = "browser-verification-step";
|
||||
|
||||
/**
|
||||
* Build the `browser-verification` optional-group node placed on a workflow's
|
||||
* pre-merge path. `column` matches where the legacy `workflow-step` seam sat
|
||||
* (in-progress) so the editor renders the group in the implementation column.
|
||||
*
|
||||
* Mirrors `stepTemplateToNode(browser-verification)`: a single `prompt` node whose
|
||||
* config carries the catalog prompt + `toolMode: "coding"` + `gateMode: "advisory"`.
|
||||
*/
|
||||
export function browserVerificationOptionalGroupNode(column: string): WorkflowIrNode {
|
||||
const tpl = BROWSER_VERIFICATION_TEMPLATE;
|
||||
return {
|
||||
id: BROWSER_VERIFICATION_GROUP_ID,
|
||||
kind: "optional-group",
|
||||
column,
|
||||
config: {
|
||||
name: tpl.name,
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [
|
||||
{
|
||||
id: BROWSER_VERIFICATION_STEP_NODE_ID,
|
||||
kind: "prompt",
|
||||
config: {
|
||||
name: tpl.name,
|
||||
description: tpl.description,
|
||||
prompt: tpl.prompt ?? "",
|
||||
toolMode: tpl.toolMode === "coding" ? "coding" : "readonly",
|
||||
gateMode: tpl.gateMode ?? "advisory",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
||||
import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
|
||||
|
||||
/**
|
||||
* The built-in default workflow as a v2 IR. Its six columns have ids that are
|
||||
@@ -20,9 +21,16 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
*
|
||||
* The lifecycle seam nodes are placed in their columns. Planning is explicit so
|
||||
* the built-in workflow owns the specification phase rather than relying on
|
||||
* triage code that runs outside the graph; workflow-step keeps the legacy
|
||||
* pre-merge quality gate between implementation and review; execute/review/
|
||||
* merge keep the same observable pipeline and failure routing.
|
||||
* triage code that runs outside the graph; execute/review/merge keep the same
|
||||
* observable pipeline and failure routing.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
* The pre-merge optional `browser-verification` step is now an `optional-group`
|
||||
* container node (default OFF) sitting on the success path between execute and
|
||||
* review — REPLACING the legacy `workflow-step` seam node + the execution-inert
|
||||
* `optionalSteps: [{ templateId: "browser-verification" }]` declaration (U6). A
|
||||
* task whose `enabledWorkflowSteps` includes the group id runs browser
|
||||
* verification pre-merge exactly as before; a task with it off bypasses it.
|
||||
*/
|
||||
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
version: "v2",
|
||||
@@ -65,12 +73,8 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
column: "in-progress",
|
||||
config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 },
|
||||
},
|
||||
{
|
||||
id: "workflow-step",
|
||||
kind: "prompt",
|
||||
column: "in-progress",
|
||||
config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"),
|
||||
},
|
||||
// Pre-merge optional browser-verification (optional-group, default OFF).
|
||||
browserVerificationOptionalGroupNode("in-progress"),
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
|
||||
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
|
||||
@@ -94,10 +98,10 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
edges: [
|
||||
{ from: "start", to: "planning" },
|
||||
{ from: "planning", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
// execute → browser-verification (optional-group) → review. When the group is
|
||||
// disabled it passes through with outcome=success and routes straight to review.
|
||||
{ from: "execute", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "review", condition: "success" },
|
||||
{ from: "review", to: "merge-gate", condition: "success" },
|
||||
{ from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" },
|
||||
{ from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" },
|
||||
@@ -113,14 +117,13 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
{ from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" },
|
||||
{ from: "planning", to: "end", condition: "failure" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
{ from: "merge-attempt", to: "end", condition: "failure" },
|
||||
],
|
||||
// Workflow-settings (U1, R4): declare the full moved-key catalog with defaults
|
||||
// byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3.
|
||||
settings: BUILTIN_WORKFLOW_SETTINGS,
|
||||
optionalSteps: [{ templateId: "browser-verification" }],
|
||||
};
|
||||
|
||||
export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
||||
import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js";
|
||||
|
||||
/**
|
||||
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
|
||||
@@ -123,16 +124,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
},
|
||||
// KTD-5: rework exhaustion escalates to a manual hold (a human releases it).
|
||||
{ id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } },
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// The stepwise workflow must actually run a task's enabled optional steps (e.g.
|
||||
// browser verification), so it needs the same pre-merge workflow-step seam the
|
||||
// coding workflow has — declaring the optional step without this node would be a
|
||||
// dead toggle. Pre-merge workflow-step seam (parity with builtin-coding-workflow-ir):
|
||||
// the ONLY node that makes the graph invoke `runWorkflowSteps`, so a per-task
|
||||
// `enabledWorkflowSteps` (e.g. the optional browser-verification step declared
|
||||
// below) actually executes. Runs ONCE after the foreach completes, between
|
||||
// implementation and review — not per step-instance.
|
||||
{ id: "workflow-step", kind: "prompt", column: "in-progress", config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps") },
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
// Pre-merge optional browser-verification as an `optional-group` container
|
||||
// (default OFF), parity with builtin-coding-workflow-ir (U6). It REPLACES the
|
||||
// prior `workflow-step` seam + `optionalSteps` declaration. R-3 run-once
|
||||
// guarantee: the group sits on the post-foreach success path (steps → here →
|
||||
// review), so when enabled the browser-verification step runs EXACTLY ONCE
|
||||
// after every step-instance completes — never per step-instance — and when
|
||||
// disabled the group passes through inert. Both the normal foreach-success path
|
||||
// and the rework-exhausted manual-release path flow through this node.
|
||||
browserVerificationOptionalGroupNode("in-progress"),
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
|
||||
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
|
||||
@@ -163,17 +164,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
{ from: "parse", to: "steps", condition: "outcome:no-steps" },
|
||||
{ from: "parse", to: "end", condition: "failure" },
|
||||
{ from: "parse", to: "end", condition: "outcome:parse-error" },
|
||||
// Implementation complete → pre-merge workflow-step seam → review. Both the
|
||||
// normal foreach-success path and the rework-exhausted manual-release path flow
|
||||
// through the seam so enabled workflow steps run regardless of route.
|
||||
{ from: "steps", to: "workflow-step", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the seam.
|
||||
// Implementation complete → pre-merge browser-verification optional-group →
|
||||
// review. Both the normal foreach-success path and the rework-exhausted
|
||||
// manual-release path flow through the group so an enabled task runs the step
|
||||
// ONCE after the foreach (R-3), and a disabled task passes through to review.
|
||||
{ from: "steps", to: "browser-verification", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the group.
|
||||
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
|
||||
{ from: "rework-hold", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "rework-hold", to: "browser-verification", condition: "success" },
|
||||
{ from: "browser-verification", to: "review", condition: "success" },
|
||||
{ from: "browser-verification", to: "end", condition: "failure" },
|
||||
{ from: "steps", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "merge-gate", condition: "success" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
@@ -193,9 +193,6 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
],
|
||||
// Workflow-settings (U1, R4): same moved-key catalog as the default builtin.
|
||||
settings: BUILTIN_WORKFLOW_SETTINGS,
|
||||
// Optional browser-verification step, parity with builtin-coding-workflow-ir.
|
||||
// Default OFF; runnable because the workflow-step seam node above is present.
|
||||
optionalSteps: [{ templateId: "browser-verification" }],
|
||||
};
|
||||
|
||||
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(
|
||||
|
||||
@@ -181,11 +181,15 @@ export async function loadWorkspaceConfig(rootDir: string): Promise<WorkspaceCon
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
// FNXC:Workspace 2026-06-22-09:30 (Phase C review nit): validate that `repos` is an array
|
||||
// OF STRINGS, not merely an array. A malformed config (`{ repos: [123, null] }`) would
|
||||
// otherwise pass and feed non-string values into path joins downstream.
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
"repos" in parsed &&
|
||||
Array.isArray((parsed as { repos: unknown }).repos)
|
||||
Array.isArray((parsed as { repos: unknown }).repos) &&
|
||||
(parsed as { repos: unknown[] }).repos.every((r) => typeof r === "string")
|
||||
) {
|
||||
const rawRepos = (parsed as { repos: unknown[] }).repos;
|
||||
const repos = rawRepos.filter((entry): entry is string => isInRootRelativePath(entry, pathMod));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, 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, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
|
||||
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, 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, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, 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, CliAgentSettings } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
|
||||
export {
|
||||
resolveEntryPointBranchAssignment,
|
||||
sanitizeBranchSegment,
|
||||
@@ -86,13 +86,13 @@ export type {
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowLoopExitCondition,
|
||||
WorkflowOptionalGroupConfig,
|
||||
WorkflowIrArtifact,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowFieldOption,
|
||||
WorkflowFieldRender,
|
||||
// Workflow-settings (U1): typed setting declaration IR types.
|
||||
WorkflowOptionalStep,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowSettingType,
|
||||
WorkflowSettingOption,
|
||||
@@ -119,7 +119,10 @@ export type {
|
||||
} from "./column-agent-resolver.js";
|
||||
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
|
||||
export { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
|
||||
export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js";
|
||||
export {
|
||||
resolveWorkflowOptionalSteps,
|
||||
resolveDefaultOnOptionalGroupIds,
|
||||
} from "./workflow-optional-steps.js";
|
||||
export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js";
|
||||
export {
|
||||
applyPromptOverridesToIr,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js";
|
||||
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
|
||||
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey, assertNotWorkspaceTaskMerge } from "./types.js";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
|
||||
import {
|
||||
MOVED_SETTINGS_KEYS,
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
WorkflowNodeLayout,
|
||||
} from "./workflow-definition-types.js";
|
||||
import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js";
|
||||
import { resolveDefaultOnOptionalGroupIds, resolveAllOptionalGroupIds } from "./workflow-optional-steps.js";
|
||||
import {
|
||||
BUILTIN_WORKFLOWS,
|
||||
getBuiltinWorkflow,
|
||||
@@ -4286,7 +4287,26 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveEnabledWorkflowSteps(stepIds?: string[]): Promise<string[] | undefined> {
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-16:30:
|
||||
`optionalGroupIds` are the optional-group node ids of the task's workflow. They are executor toggle keys (matched by node id in `enabledWorkflowSteps`), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately collide with a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"); without this pass-through the colliding id is materialized into a step row whose id differs from the group node id, so the executor's `enabledWorkflowSteps.includes(node.id)` check fails and an enabled group is silently bypassed (P1 from code review). Editor-authored group ids never collide (they come from `newNodeId()`), so they already passed through; this guards the built-in collision.
|
||||
*/
|
||||
/** Optional-group node ids for a workflow (its `enabledWorkflowSteps` toggle
|
||||
* keys). Falls back to the project default workflow when `workflowId` is
|
||||
* nullish; empty for missing/fragment workflows. Used to keep group ids out of
|
||||
* the legacy step-template materialization in {@link resolveEnabledWorkflowSteps}. */
|
||||
private async optionalGroupIdSet(workflowId?: string | null): Promise<Set<string>> {
|
||||
const wfId = workflowId ?? (await this.getDefaultWorkflowId());
|
||||
if (!wfId) return new Set();
|
||||
const def = await this.getWorkflowDefinition(wfId);
|
||||
if (!def || def.kind === "fragment") return new Set();
|
||||
return new Set(resolveAllOptionalGroupIds(def.ir));
|
||||
}
|
||||
|
||||
private async resolveEnabledWorkflowSteps(
|
||||
stepIds?: string[],
|
||||
optionalGroupIds?: Set<string>,
|
||||
): Promise<string[] | undefined> {
|
||||
if (!stepIds?.length) return undefined;
|
||||
|
||||
const resolved: string[] = [];
|
||||
@@ -4304,7 +4324,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = this.getBuiltInWorkflowTemplate(stepId);
|
||||
// Optional-group toggle ids pass through raw — never materialized as legacy step rows.
|
||||
const template = optionalGroupIds?.has(stepId)
|
||||
? undefined
|
||||
: this.getBuiltInWorkflowTemplate(stepId);
|
||||
const resolvedId = template
|
||||
? (await this.ensureWorkflowStepForTemplate(stepId)).id
|
||||
: stepId;
|
||||
@@ -4444,7 +4467,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
|
||||
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
? await this.resolveEnabledWorkflowSteps(
|
||||
input.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(input.workflowId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// When a project default workflow is configured, new tasks inherit it
|
||||
@@ -4639,7 +4665,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
|
||||
const title = input.title?.trim() || undefined;
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
? await this.resolveEnabledWorkflowSteps(
|
||||
input.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(input.workflowId),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
|
||||
@@ -8712,7 +8741,14 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
task.nextRecoveryAt = updates.nextRecoveryAt;
|
||||
}
|
||||
if (updates.enabledWorkflowSteps !== undefined) {
|
||||
task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(updates.enabledWorkflowSteps);
|
||||
// Pass the task's own workflow optional-group ids through untouched so a
|
||||
// toggled built-in group id (e.g. "browser-verification") is not remapped
|
||||
// to a materialized step row the executor never matches (code-review P1).
|
||||
const taskWorkflowId = this.getTaskWorkflowSelection(task.id)?.workflowId;
|
||||
task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(
|
||||
updates.enabledWorkflowSteps,
|
||||
await this.optionalGroupIdSet(taskWorkflowId),
|
||||
);
|
||||
}
|
||||
if (updates.noCommitsExpected === null) {
|
||||
task.noCommitsExpected = undefined;
|
||||
@@ -11251,6 +11287,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
// FNXC:Workspace 2026-06-21-19:05:
|
||||
// R7 merge-boundary guard (master-plan U0). Reject workspace-mode tasks
|
||||
// BEFORE any git checkout/squash — they need the per-repo merge loop that
|
||||
// lands in master-plan U6, which removes this guard. See the predicate's
|
||||
// FNXC:Workspace note in @fusion/core types.
|
||||
assertNotWorkspaceTaskMerge(task);
|
||||
const branch = task.branch || `fusion/${id.toLowerCase()}`;
|
||||
// Branch is derived from the task id (already validated at create time),
|
||||
// but assert as defense-in-depth against future id-format changes.
|
||||
@@ -15946,11 +15988,17 @@ ${stepsSection}`;
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:20: seed `enabledWorkflowSteps`
|
||||
// with the ids of `optional-group` nodes whose `defaultOn` is true, mirroring
|
||||
// the prior `optionalStep.defaultOn ?? false` precedence (U3, R3). These group
|
||||
// ids are NOT WorkflowStep rows — they are toggle keys the executor reads at
|
||||
// the optional-group seam — so they ride alongside the compiled step ids.
|
||||
const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir);
|
||||
if (isBuiltinWorkflowId(workflowId) && inputs.length === 0) {
|
||||
return { workflowId, stepIds: [] };
|
||||
return { workflowId, stepIds: defaultGroupIds };
|
||||
}
|
||||
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
||||
return { workflowId, stepIds };
|
||||
return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] };
|
||||
}
|
||||
|
||||
/** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized
|
||||
@@ -15971,11 +16019,15 @@ ${stepsSection}`;
|
||||
try {
|
||||
inputs = compileWorkflowToSteps(def.ir);
|
||||
} catch (err) {
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return { workflowId, stepIds: [] };
|
||||
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err))
|
||||
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir) };
|
||||
throw err;
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:20: same defaultOn-group seeding as
|
||||
// the default-workflow path, for an explicitly requested create-time workflow.
|
||||
const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir);
|
||||
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
||||
return { workflowId, stepIds };
|
||||
return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -512,8 +512,15 @@ export const MERGER_MODES = ["ai", "deterministic"] as const;
|
||||
* an AI agent merges the task branch and an AI reviewer audits it (with
|
||||
* corrective retries) before a fast-forward landing. Bypasses the legacy
|
||||
* scaffolding entirely.
|
||||
* - "deterministic": the legacy `aiMergeTask` pipeline (prerebase /
|
||||
* conflict-strategy ladder / post-merge audit / transient self-heal).
|
||||
* - "deterministic": **DEPRECATED (master-plan U0, 2026-06-21) and INERT.** Once
|
||||
* routed to the legacy `aiMergeTask` pipeline; now ignored — every merge uses
|
||||
* the unified "ai" path (`runAiMerge`). The value is retained (not removed) to
|
||||
* avoid a breaking `@runfusion/fusion` type change, and the engine logs a
|
||||
* one-time deprecation warning when it observes a resolved "deterministic".
|
||||
*
|
||||
* FNXC:MergerUnification 2026-06-21-19:05: `merger.mode` is published surface, so
|
||||
* the type and the `MergerSettings.mode` field stay; only the "deterministic"
|
||||
* VALUE is deprecated/inert. Removing the type is a separate breaking change.
|
||||
*/
|
||||
export type MergerMode = (typeof MERGER_MODES)[number];
|
||||
|
||||
@@ -525,7 +532,12 @@ export function normalizeMergerMode(value: unknown): MergerMode {
|
||||
|
||||
/** Settings for the AI merge path (FN-5633). */
|
||||
export interface MergerSettings {
|
||||
/** Which merge path to use. Default: "ai". */
|
||||
/**
|
||||
* Which merge path to use. Default: "ai".
|
||||
* @deprecated master-plan U0 (2026-06-21): the value is inert — every merge now
|
||||
* uses the unified AI merge path (`runAiMerge`). Field retained as published
|
||||
* surface; "deterministic" only triggers a one-time deprecation warning.
|
||||
*/
|
||||
mode?: MergerMode;
|
||||
/** How many AI corrective rounds before landing the best result (advisory) or
|
||||
* hard-failing (blocking). Default: 3. The reviewer uses the project's
|
||||
@@ -1843,6 +1855,17 @@ export interface MergeDetails {
|
||||
* `task.mergeRetries`, which counts in-cycle aiMergeTask retries.
|
||||
*/
|
||||
transientRecoveryCount?: number;
|
||||
/**
|
||||
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
|
||||
* Workspace-mode aggregate landed map: sub-repo relative path → the squash sha
|
||||
* that landed on that repo's local integration ref. Set ONLY by
|
||||
* `landWorkspaceTask`'s finalize-once after EVERY acquired repo's landed
|
||||
* predicate holds; the task-level `commitSha` points at one representative
|
||||
* landed sha (the first sorted landed repo) so the existing `task:merged`
|
||||
* consumer (which reads `mergeDetails.commitSha`) is satisfied. Empty/absent
|
||||
* for single-repo tasks.
|
||||
*/
|
||||
workspaceLandedShas?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Represents an agent's checkout lease on a task. */
|
||||
@@ -2244,8 +2267,23 @@ export interface Task {
|
||||
/**
|
||||
* Workspace mode only. Keyed by repo path relative to workspace rootDir.
|
||||
* Each entry records the on-disk worktree path and git branch for one sub-repo.
|
||||
*
|
||||
* FNXC:Workspace 2026-06-21-20:10:
|
||||
* `baseCommitSha` is the per-repo fork-point captured at acquisition (U2/KTD3)
|
||||
* against that sub-repo's RESOLVED integration branch, local-first. It is the
|
||||
* per-repo analogue of the single-repo base-commit capture and prevents
|
||||
* cross-repo files-changed inflation when local integration is ahead of origin.
|
||||
*
|
||||
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
|
||||
* `landedSha` is the per-repo "this repo's branch has landed on its local
|
||||
* integration ref" marker, set by `landWorkspaceTask` after a sub-repo's squash
|
||||
* advances that repo's ref. It is the ONLY partial-land state added (no new
|
||||
* status type): a re-run's landed predicate skips a repo whose `landedSha` is
|
||||
* present AND whose recorded value is an ancestor of (or equals) the repo's
|
||||
* integration tip, so an interrupted multi-repo land retries only the un-landed
|
||||
* repos and never re-advances an already-landed ref (idempotent retry).
|
||||
*/
|
||||
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string }>;
|
||||
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string }>;
|
||||
steps: TaskStep[];
|
||||
currentStep: number;
|
||||
/**
|
||||
@@ -2598,6 +2636,64 @@ export interface Task {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-19:05:
|
||||
R7 workspace merge-boundary guard (master-plan U0). Workspace-mode tasks populate
|
||||
`task.workspaceWorktrees` (one git worktree per sub-repo); their merge must run a
|
||||
per-repo loop that does NOT exist yet — it lands in master-plan U6. Until then, a
|
||||
workspace task reaching ANY merge entry point (engine dispatch, store.mergeTask,
|
||||
the CLI `onMergeImpl` / `runTaskMerge` callers) would run git operations against
|
||||
the NON-GIT workspace root and crash. This single shared predicate is called at the
|
||||
top of every merge door, BEFORE any git work, so the task is held with a clear,
|
||||
actionable error instead. It lives in @fusion/core so all four call sites — including
|
||||
store.mergeTask, which cannot import from @fusion/engine — share ONE implementation.
|
||||
The guard throws a NAMED `WorkspaceTaskMergeError` so callers (e.g. the engine merge
|
||||
dispatch catch) can distinguish this permanent config error from a transient merge
|
||||
failure and avoid burning mergeRetries. Master-plan U6 REMOVES this guard when the
|
||||
per-repo merge loop becomes the gate.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error thrown by {@link assertNotWorkspaceTaskMerge} when a workspace-mode task
|
||||
* reaches a merge path. Named so callers can branch on it (e.g. park without
|
||||
* burning mergeRetries) rather than treating it as a transient merge failure.
|
||||
*/
|
||||
export class WorkspaceTaskMergeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkspaceTaskMergeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws {@link WorkspaceTaskMergeError} when `task.workspaceWorktrees` has at least
|
||||
* one entry (a workspace-mode task). No-op for single-repo tasks. See the
|
||||
* FNXC:Workspace note above.
|
||||
* @param task the task about to enter a merge path
|
||||
*/
|
||||
export function assertNotWorkspaceTaskMerge(task: Pick<Task, "id" | "workspaceWorktrees">): void {
|
||||
if (isWorkspaceTask(task)) {
|
||||
throw new WorkspaceTaskMergeError(
|
||||
`Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-05:10 (Phase C review B5/B7-dep — canonical workspace predicate):
|
||||
A workspace-mode task is identified by having at least one `workspaceWorktrees` entry
|
||||
(one git worktree per sub-repo). This single predicate replaces the inlined
|
||||
`!!task.workspaceWorktrees && Object.keys(task.workspaceWorktrees).length > 0` that was
|
||||
copy-pasted across the engine merge dispatch and the merge-confirmed reachability fast-path
|
||||
(B2). It lives in @fusion/core so the engine, store, and CLI doors share ONE definition.
|
||||
The dashboard keeps its own local `isWorkspaceTask` (WorkspaceWorktreesSummary, UI-only) —
|
||||
this core export is for engine/CLI use.
|
||||
*/
|
||||
export function isWorkspaceTask(task: Pick<Task, "workspaceWorktrees">): boolean {
|
||||
const worktrees = task.workspaceWorktrees;
|
||||
return !!worktrees && Object.keys(worktrees).length > 0;
|
||||
}
|
||||
|
||||
export type RetrySummary = {
|
||||
stuckKill: number;
|
||||
recovery: number;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type WorkflowIrNodeKind =
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "optional-group"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code"
|
||||
@@ -164,6 +165,26 @@ export interface WorkflowLoopConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
An `optional-group` node is a container (mirroring `foreach`/`loop`) whose `template` subgraph the executor runs ONCE when the group is enabled for the task and passes through (skips) when disabled.
|
||||
Enable state reuses the per-task `enabledWorkflowSteps` facet keyed by the group node id, seeded from `defaultOn` at task creation — this replaces the execution-inert declaration-based optional-steps model (`WorkflowOptionalStep`/`optionalSteps`).
|
||||
Single pass only: no iteration, no rework budget. Rework edges are forbidden inside the template so the single-pass guarantee is unambiguous (validated in `validateOptionalGroup`).
|
||||
*/
|
||||
/** Config for an `optional-group` container node. `defaultOn` seeds the per-task
|
||||
* enable set at creation; the `template` is the subgraph run once when enabled.
|
||||
* Unlike `foreach`/`loop`, there is no iteration or rework — a single pass. */
|
||||
export interface WorkflowOptionalGroupConfig {
|
||||
/** Workflow-author default for whether new tasks enable this group. */
|
||||
defaultOn?: boolean;
|
||||
/** Display name for the group (editor + per-task toggle surfaces). */
|
||||
name?: string;
|
||||
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 {
|
||||
@@ -311,13 +332,10 @@ export interface WorkflowIrV1 {
|
||||
edges: WorkflowIrEdge[];
|
||||
}
|
||||
|
||||
/** Workflow-declared optional step backed by a workflow-step template.
|
||||
* Execution-inert: consumed by create/edit UI to seed per-task
|
||||
* `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */
|
||||
export interface WorkflowOptionalStep {
|
||||
templateId: string;
|
||||
defaultOn?: boolean;
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
Retired the legacy declaration-based optional-steps model. The `WorkflowOptionalStep` interface and the `WorkflowIrV2.optionalSteps` field are removed — optional steps are now graph-native `optional-group` NODES (see `WorkflowOptionalGroupConfig` above), resolved by `resolveWorkflowOptionalSteps`. A legacy persisted `optionalSteps` key on an old v2 row is TOLERATED at parse (ignored, not validated) so old rows still load as v2.
|
||||
*/
|
||||
|
||||
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
|
||||
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
|
||||
@@ -333,9 +351,6 @@ export interface WorkflowIrV2 {
|
||||
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
|
||||
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
|
||||
settings?: WorkflowSettingDefinition[];
|
||||
/** Optional workflow-step templates tasks may independently enable/disable via
|
||||
* `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */
|
||||
optionalSteps?: WorkflowOptionalStep[];
|
||||
}
|
||||
|
||||
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
|
||||
|
||||
@@ -9,11 +9,11 @@ import type {
|
||||
WorkflowHoldRelease,
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowOptionalGroupConfig,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowSettingType,
|
||||
WorkflowOptionalStep,
|
||||
} from "./workflow-ir-types.js";
|
||||
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
||||
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
|
||||
@@ -602,6 +602,120 @@ function validateLoop(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
Validate an `optional-group` container template, mirroring `validateLoop` minus the loop's exit/iteration config.
|
||||
The template runs once when enabled, so rework edges (and any cycles) are forbidden inside, single entry/exit is required, and nested foreach/loop groups are rejected — keeping the single-pass guarantee unambiguous.
|
||||
`defaultOn` must be boolean when present; `name` must be a string when present.
|
||||
*/
|
||||
function validateOptionalGroup(
|
||||
node: WorkflowIrNode,
|
||||
topLevelNodeIds: Set<string>,
|
||||
columnIds: Set<string>,
|
||||
): void {
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
||||
const template = cfg?.template;
|
||||
if (
|
||||
!cfg ||
|
||||
!template ||
|
||||
!Array.isArray(template.nodes) ||
|
||||
!Array.isArray(template.edges)
|
||||
) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' must declare a template with nodes and edges arrays`,
|
||||
);
|
||||
}
|
||||
if (template.nodes.length === 0) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template must be non-empty`);
|
||||
}
|
||||
if (cfg.defaultOn !== undefined && typeof cfg.defaultOn !== "boolean") {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' defaultOn must be a boolean`);
|
||||
}
|
||||
if (cfg.name !== undefined && typeof cfg.name !== "string") {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`);
|
||||
}
|
||||
|
||||
const templateNodes = template.nodes;
|
||||
const templateIds = new Set(templateNodes.map((n) => n.id));
|
||||
if (templateIds.size !== templateNodes.length) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template has duplicate node ids`);
|
||||
}
|
||||
for (const inner of templateNodes) {
|
||||
if (inner.kind === "loop" || inner.kind === "foreach" || inner.kind === "optional-group") {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template may not contain nested loop/foreach/optional-group ('${inner.id}')`,
|
||||
);
|
||||
}
|
||||
if (isStepExecuteNode(inner)) {
|
||||
throw new WorkflowIrError(
|
||||
`step-execute seam node '${inner.id}' is only legal inside a foreach template`,
|
||||
);
|
||||
}
|
||||
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const edge of template.edges) {
|
||||
const fromInside = templateIds.has(edge.from);
|
||||
const toInside = templateIds.has(edge.to);
|
||||
if (!fromInside || !toInside) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
||||
);
|
||||
}
|
||||
if (isReworkEdge(edge)) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`);
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk
|
||||
// (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome
|
||||
// and bails before evaluating that node's edges — so a `failure`-condition
|
||||
// edge inside the template would silently never execute. Reject it as a typed
|
||||
// authoring error; failure routing belongs on the group's OUTER edges.
|
||||
// (Code review: Greptile P2.)
|
||||
if (edge.condition === "failure") {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template may not contain failure-condition edges — ` +
|
||||
`a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = new Map<string, number>();
|
||||
const outgoingCount = new Map<string, number>();
|
||||
for (const edge of template.edges) {
|
||||
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(
|
||||
`optional-group node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
||||
);
|
||||
}
|
||||
if (exits.length !== 1) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
||||
const templateOutgoing = buildOutgoing(template.edges);
|
||||
validateNoIllegalCycles(templateNodes, templateOutgoing);
|
||||
validateParallelism(templateNodes, templateOutgoing, templateById);
|
||||
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false);
|
||||
|
||||
for (const id of templateIds) {
|
||||
if (topLevelNodeIds.has(id)) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group 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.) */
|
||||
@@ -1071,29 +1185,6 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void {
|
||||
if (optionalSteps === undefined) return;
|
||||
if (!Array.isArray(optionalSteps)) {
|
||||
throw new WorkflowIrError("Workflow IR optionalSteps must be an array");
|
||||
}
|
||||
for (const optionalStep of optionalSteps) {
|
||||
if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) {
|
||||
throw new WorkflowIrError("Workflow optional step must be an object");
|
||||
}
|
||||
if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") {
|
||||
throw new WorkflowIrError("Workflow optional step must have a non-empty templateId");
|
||||
}
|
||||
if (
|
||||
optionalStep.defaultOn !== undefined &&
|
||||
typeof optionalStep.defaultOn !== "boolean"
|
||||
) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateColumns(ir: WorkflowIrV2): void {
|
||||
if (!Array.isArray(ir.columns)) {
|
||||
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
||||
@@ -1265,6 +1356,7 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
for (const node of ir.nodes) {
|
||||
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
|
||||
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);
|
||||
if (node.kind === "optional-group") validateOptionalGroup(node, topLevelIds, columnIds);
|
||||
}
|
||||
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
|
||||
validateParseStepsNodes(ir);
|
||||
@@ -1272,7 +1364,11 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
validateNotifyNodes(ir.nodes);
|
||||
validateFields(ir.fields);
|
||||
validateSettings(ir.settings);
|
||||
validateOptionalSteps(ir.optionalSteps);
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy `optionalSteps` declaration field is retired (optional steps are
|
||||
// now graph-native `optional-group` nodes). A legacy persisted `optionalSteps`
|
||||
// key on an old v2 row is TOLERATED — no longer validated/required — so old
|
||||
// rows still parse as v2.
|
||||
|
||||
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
|
||||
// generalized the bounded-rework mechanism to the top-level walk — for a
|
||||
@@ -1381,12 +1477,20 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
|
||||
}
|
||||
|
||||
// Step-inversion declarations (artifacts/fields), workflow settings (U1), and
|
||||
// optional workflow-step declarations are v2-only features.
|
||||
// any legacy persisted optional-step declarations are v2-only features.
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00):
|
||||
// `optionalSteps` is no longer a typed IR field (retired declaration model), but
|
||||
// a legacy v2 row may still carry the key. Read it via an untyped cast so such a
|
||||
// row is still treated as v2 (kept on v2, never silently downgraded). The mere
|
||||
// PRESENCE of the key — including an empty `[]` — is the v2 signal: an author
|
||||
// who wrote the key intended v2, and downgrading an `optionalSteps: []` row to
|
||||
// v1 would still mutate its persisted shape. (Code review: CodeRabbit.)
|
||||
const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps;
|
||||
if (
|
||||
(ir.artifacts && ir.artifacts.length > 0) ||
|
||||
(ir.fields && ir.fields.length > 0) ||
|
||||
(ir.settings && ir.settings.length > 0) ||
|
||||
(ir.optionalSteps && ir.optionalSteps.length > 0)
|
||||
legacyOptionalSteps !== undefined
|
||||
) {
|
||||
return ir;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowStepTemplate } from "./types.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrNode,
|
||||
WorkflowOptionalGroupConfig,
|
||||
} from "./workflow-ir-types.js";
|
||||
import type { WorkflowStepTemplate } from "./types.js";
|
||||
|
||||
export interface ResolvedWorkflowOptionalStep {
|
||||
templateId: string;
|
||||
@@ -10,37 +14,74 @@ export interface ResolvedWorkflowOptionalStep {
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
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`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank.
|
||||
*/
|
||||
|
||||
function isOptionalGroupNode(
|
||||
node: WorkflowIrNode,
|
||||
): node is WorkflowIrNode & { config: WorkflowOptionalGroupConfig } {
|
||||
return node.kind === "optional-group";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workflow-declared optional step template ids into display metadata.
|
||||
* 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.
|
||||
*
|
||||
* The declaration is intentionally execution-inert: it only advertises which
|
||||
* template-backed workflow steps a task may toggle into `enabledWorkflowSteps`.
|
||||
* Unknown template ids are skipped so stale/custom declarations never render
|
||||
* blank UI rows or break workflow loading.
|
||||
* 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[] = [],
|
||||
_pluginTemplates: WorkflowStepTemplate[] = [],
|
||||
): ResolvedWorkflowOptionalStep[] {
|
||||
if (ir.version !== "v2" || !ir.optionalSteps?.length) return [];
|
||||
|
||||
const templates = new Map<string, WorkflowStepTemplate>();
|
||||
for (const template of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) {
|
||||
templates.set(template.id, template);
|
||||
}
|
||||
if (ir.version !== "v2" || !Array.isArray(ir.nodes)) return [];
|
||||
|
||||
const resolved: ResolvedWorkflowOptionalStep[] = [];
|
||||
for (const optionalStep of ir.optionalSteps) {
|
||||
const template = templates.get(optionalStep.templateId);
|
||||
if (!template) continue;
|
||||
for (const node of ir.nodes) {
|
||||
if (!isOptionalGroupNode(node)) continue;
|
||||
const config = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
|
||||
resolved.push({
|
||||
templateId: optionalStep.templateId,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
icon: template.icon,
|
||||
phase: template.phase ?? "pre-merge",
|
||||
defaultOn: optionalStep.defaultOn ?? template.defaultOn ?? false,
|
||||
// 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: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: config.defaultOn === true,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 can deliberately equal a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"), so the store must pass these through `resolveEnabledWorkflowSteps` untouched instead of materializing them into a step row whose id the executor would never match.
|
||||
*/
|
||||
export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] {
|
||||
return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
/* Must sit above floating dashboard panels. */
|
||||
z-index: 1200;
|
||||
/* Must sit above floating dashboard panels and the shared floating-window stack (10100+). */
|
||||
z-index: 11000;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone.
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__copy .devserver-preview-url-badge {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-launcher__description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2));
|
||||
max-height: calc(100vh - var(--space-xl) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__titlebar h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
flex: 1;
|
||||
min-height: min(60vh, calc(var(--space-2xl) * 14));
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Legacy selector compatibility for static CSS tests */
|
||||
.dev-server-preview-fallback {
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
|
||||
@@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone.
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone.
|
||||
max-height: calc(var(--space-2xl) * 3);
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: stretch;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - var(--space-md) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
min-height: calc(var(--space-2xl) * 7);
|
||||
}
|
||||
|
||||
.dev-server-config {
|
||||
max-height: min(48vh, calc(var(--space-2xl) * 13));
|
||||
}
|
||||
@@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.devserver-preview-panel {
|
||||
.devserver-preview-panel,
|
||||
.devserver-preview-modal-launcher {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
@@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.devserver-preview-modal-overlay {
|
||||
align-items: stretch;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.devserver-preview-modal {
|
||||
width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2));
|
||||
max-height: calc(100vh - var(--space-md) * 2);
|
||||
}
|
||||
|
||||
.devserver-preview-modal__body .devserver-preview-container {
|
||||
min-height: calc(var(--space-2xl) * 7);
|
||||
}
|
||||
|
||||
.devserver-preview-url-badge {
|
||||
order: 2;
|
||||
flex: 1 1 100%;
|
||||
@@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
|
||||
}
|
||||
|
||||
.dev-server-logs,
|
||||
.devserver-preview-container,
|
||||
.devserver-preview-iframe {
|
||||
.devserver-preview-panel .devserver-preview-container,
|
||||
.devserver-preview-panel .devserver-preview-iframe {
|
||||
min-height: calc(var(--space-2xl) * 4 + var(--space-md));
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import "./DevServerView.css";
|
||||
import type { DetectedDevServerCommand } from "../api";
|
||||
import { useDevServer } from "../hooks/useDevServer";
|
||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DevServerLogViewer } from "./DevServerLogViewer";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
@@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480;
|
||||
|
||||
function isTrueMobileViewport(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia("(max-width: 768px)").matches;
|
||||
}
|
||||
|
||||
function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null {
|
||||
if (element.closest(".right-dock-expand-modal__body")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = element.parentElement;
|
||||
if (!parent?.classList.contains("right-dock__body")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
function readHostInlineSize(host: HTMLElement): number {
|
||||
if (host.clientWidth > 0) {
|
||||
return host.clientWidth;
|
||||
}
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
return rect.width;
|
||||
}
|
||||
|
||||
function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean {
|
||||
if (!root || isTrueMobileViewport()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = getDirectRightDockBodyHost(root);
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD;
|
||||
}
|
||||
|
||||
function useNarrowRightDockPreviewMode(rootRef: RefObject<HTMLDivElement | null>): boolean {
|
||||
const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
setIsNarrowRightDockPreviewMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = getDirectRightDockBodyHost(root);
|
||||
const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root));
|
||||
|
||||
updateMode();
|
||||
|
||||
if (!host || typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", updateMode);
|
||||
return () => window.removeEventListener("resize", updateMode);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateMode);
|
||||
observer.observe(host);
|
||||
window.addEventListener("resize", updateMode);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", updateMode);
|
||||
};
|
||||
}, [rootRef]);
|
||||
|
||||
return isNarrowRightDockPreviewMode;
|
||||
}
|
||||
|
||||
let devServerViewWasPreviouslyInactive = false;
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
@@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const effectivePreviewUrl = previewUrl;
|
||||
const selectedSource = session?.config?.cwd ?? null;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef);
|
||||
|
||||
/*
|
||||
FNXC:DevServer 2026-06-23-00:00:
|
||||
The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column.
|
||||
The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews.
|
||||
*/
|
||||
const [showCandidates, setShowCandidates] = useState(true);
|
||||
const [commandInput, setCommandInput] = useState("");
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
@@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
}, [executingTasks, selectedTaskId]);
|
||||
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const previewModalLauncherRef = useRef<HTMLButtonElement>(null);
|
||||
const previewModalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
||||
const {
|
||||
@@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
setPreviewInput(effectivePreviewUrl ?? "");
|
||||
}, [effectivePreviewUrl]);
|
||||
|
||||
const closePreviewModal = useCallback(() => {
|
||||
setIsPreviewModalOpen(false);
|
||||
window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus());
|
||||
}, []);
|
||||
const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewModalRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closePreviewModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(
|
||||
previewModalRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [],
|
||||
).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true");
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
if (!firstElement || !lastElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePreviewModal, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) {
|
||||
setIsPreviewModalOpen(false);
|
||||
}
|
||||
}, [isNarrowRightDockPreviewMode, isPreviewModalOpen]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (!effectivePreviewUrl) {
|
||||
return;
|
||||
@@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
||||
const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null;
|
||||
|
||||
const renderPreviewContent = () => (
|
||||
<>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dev-server-view" data-testid="dev-server-view">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="dev-server-view"
|
||||
data-testid="dev-server-view"
|
||||
data-narrow-right-dock-preview={isNarrowRightDockPreviewMode ? "true" : "false"}
|
||||
>
|
||||
{/*
|
||||
FNXC:DevServer 2026-06-22-01:00:
|
||||
Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding.
|
||||
@@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
{isNarrowRightDockPreviewMode ? (
|
||||
<section
|
||||
className="dev-server-panel devserver-preview-modal-launcher"
|
||||
data-testid="devserver-preview-modal-launcher"
|
||||
aria-label={t("devserver.previewLabel", "Dev server preview")}
|
||||
>
|
||||
<div className="devserver-preview-modal-launcher__copy">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
<p className="devserver-preview-modal-launcher__description">
|
||||
{effectivePreviewUrl
|
||||
? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.")
|
||||
: t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
ref={previewModalLauncherRef}
|
||||
onClick={() => setIsPreviewModalOpen(true)}
|
||||
data-testid="devserver-preview-modal-open"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{t("devserver.openPreview", "Open preview")}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
{renderPreviewContent()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
{isNarrowRightDockPreviewMode && isPreviewModalOpen && (
|
||||
<div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
|
||||
<div
|
||||
className="modal devserver-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="devserver-preview-modal-title"
|
||||
tabIndex={-1}
|
||||
ref={previewModalRef}
|
||||
data-testid="devserver-preview-modal"
|
||||
>
|
||||
<div className="devserver-preview-modal__titlebar">
|
||||
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={closePreviewModal}
|
||||
aria-label={t("devserver.closePreviewModal", "Close preview modal")}
|
||||
data-testid="devserver-preview-modal-close"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="devserver-preview-modal__body">
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsMobile 2026-06-23-09:02:
|
||||
Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract.
|
||||
*/
|
||||
.settings-section-heading {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* First heading inside the section drops top padding to remove a redundant
|
||||
|
||||
@@ -1851,17 +1851,22 @@ export function SettingsModal({
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
/*
|
||||
FNXC:Notifications 2026-06-23-08:49:
|
||||
Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test.
|
||||
*/
|
||||
const currentNtfyConfig = {
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
};
|
||||
const config = providerId === "ntfy"
|
||||
? {
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
}
|
||||
? currentNtfyConfig
|
||||
: providerId === "ntfy-message"
|
||||
? { messageEventType: "message:agent-to-user" }
|
||||
? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" }
|
||||
: providerId === "ntfy-room"
|
||||
? { messageEventType: "message:room" }
|
||||
? { ...currentNtfyConfig, messageEventType: "message:room" }
|
||||
: {
|
||||
webhookUrl: form.webhookUrl,
|
||||
webhookFormat: form.webhookFormat || "generic",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ".
|
||||
import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
import { useRetryWarning } from "../context/RetryWarningContext";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
|
||||
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
|
||||
* on the task payload for the parallel-window badge (U9). */
|
||||
@@ -625,6 +626,17 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.blockedBy === nextTask.blockedBy &&
|
||||
previousTask.overlapBlockedBy === nextTask.overlapBlockedBy &&
|
||||
previousTask.worktree === nextTask.worktree &&
|
||||
// FNXC:Workspace 2026-06-21-22:30: re-render the card when a workspace task acquires/
|
||||
// releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3).
|
||||
// F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one
|
||||
// repo released, a different one acquired) keeps the count but must still re-render,
|
||||
// otherwise the placeholder shows a stale repo set.
|
||||
// FNXC:Workspace 2026-06-22-09:00: compare full VALUES, not only the key set. A
|
||||
// pool-reclaim re-acquire keeps the same repo key but produces a different
|
||||
// worktreePath/branch; a key-set-only check would leave the card showing stale path
|
||||
// text. Whole-map JSON compare covers keys and values at negligible cost for small N.
|
||||
JSON.stringify(previousTask.workspaceWorktrees ?? null) ===
|
||||
JSON.stringify(nextTask.workspaceWorktrees ?? null) &&
|
||||
previousTask.branch === nextTask.branch &&
|
||||
previousTask.baseBranch === nextTask.baseBranch &&
|
||||
previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks &&
|
||||
@@ -2186,6 +2198,10 @@ function TaskCardComponent({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.branch,
|
||||
so the branch-metadata row below renders nothing. Surface the acquired sub-repos
|
||||
as a compact "N repos acquired" placeholder so the card isn't blank (U3/KTD5). */}
|
||||
{isWorkspaceTask(task) && <WorkspaceWorktreesSummary task={task} compact />}
|
||||
{hasBranchMetadata && (
|
||||
<div className="card-branch-row" aria-label={t("tasks.branchMetadata", "Branch metadata")}>
|
||||
{branchMetadata.branch && (
|
||||
|
||||
@@ -2327,3 +2327,39 @@ Narrow mobile task detail surfaces from both Board and List must allow horizonta
|
||||
color: var(--color-error);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-00:00:
|
||||
Flat read-only per-sub-repo worktree list for a workspace task (U3/KTD5 dashboard floor).
|
||||
Read-only list/placeholder only — not the deferred rich per-repo-status component.
|
||||
*/
|
||||
.workspace-worktrees-summary {
|
||||
margin: var(--space-sm) 0 0;
|
||||
}
|
||||
.workspace-worktrees-placeholder {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, inherit);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
.workspace-worktrees-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.workspace-worktrees-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-sm);
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.workspace-worktrees-repo {
|
||||
font-weight: 600;
|
||||
}
|
||||
.workspace-worktrees-branch {
|
||||
color: var(--color-text-secondary, inherit);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { TaskChatTab } from "./TaskChatTab";
|
||||
import { TaskReviewTab } from "./TaskReviewTab";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
@@ -3144,6 +3145,14 @@ export function TaskDetailContent({
|
||||
{task.branchContext?.groupId && (
|
||||
<BranchGroupCard groupId={task.branchContext.groupId} projectId={projectId} />
|
||||
)}
|
||||
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular
|
||||
task.worktree/task.branch; surface their acquired per-sub-repo worktrees
|
||||
as a flat read-only list so the detail view isn't blank (U3/KTD5). */}
|
||||
{/* FNXC:Workspace 2026-06-22-09:00: gate/render off the hydrated
|
||||
workingTask, not the sparse task row. workspaceWorktrees is only
|
||||
present in fetched detail, so keying off task renders blank on the
|
||||
optimistic-open path before the detail fetch resolves. */}
|
||||
{isWorkspaceTask(workingTask) && <WorkspaceWorktreesSummary task={workingTask} />}
|
||||
</>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
|
||||
@@ -1171,6 +1171,80 @@ Built-in workflow prompts need visible override state and a reset action without
|
||||
color: var(--ws-warning);
|
||||
}
|
||||
|
||||
/* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ───────────
|
||||
* Collapsible <details> teaching what the selected node does, how to
|
||||
* configure it, and its inputs/outputs/edges. Sits under the heading,
|
||||
* collapsed by default so it never pushes config fields below the fold. */
|
||||
.wf-inspector-help {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Engine-managed badge for graph-only policy nodes (read-only lifecycle). */
|
||||
.wf-inspector-help-badge {
|
||||
margin-left: auto;
|
||||
padding: 1px var(--space-xs);
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wf-inspector-help-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-sm) var(--space-sm);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-inspector-help-summary-text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 2px var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl dt {
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wf-inspector-help-dl dd {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-field--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflows,
|
||||
@@ -56,6 +56,7 @@ import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
||||
import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary";
|
||||
import { nodeHelpForData } from "./nodes/node-help";
|
||||
import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
@@ -63,11 +64,11 @@ import {
|
||||
emptyWorkflowLayout,
|
||||
copyIrWithFreshIds,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
columnsOf,
|
||||
fieldsOf,
|
||||
settingsOf,
|
||||
optionalStepsOf,
|
||||
columnsToBandNodes,
|
||||
reconcileNodeColumns,
|
||||
strictColumnForY,
|
||||
@@ -91,7 +92,6 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
@@ -103,7 +103,9 @@ import {
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions";
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
|
||||
// panel — the declaration authoring surface is retired (optional-group nodes now).
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
@@ -190,7 +192,6 @@ function serializeGraph(
|
||||
columns: WorkflowIrColumn[],
|
||||
fields: WorkflowFieldDefinition[],
|
||||
settings: WorkflowSettingDefinition[],
|
||||
optionalSteps: WorkflowOptionalStep[],
|
||||
): string {
|
||||
const { ir, layout } = flowToIr(
|
||||
name,
|
||||
@@ -199,7 +200,6 @@ function serializeGraph(
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
return JSON.stringify({ name, description, ir, layout });
|
||||
}
|
||||
@@ -269,6 +269,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
// Step-inversion (KTD-3/4/12/15).
|
||||
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
|
||||
{ kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } },
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise.
|
||||
{ kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } },
|
||||
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
|
||||
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
@@ -320,6 +322,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"notify",
|
||||
@@ -783,7 +786,10 @@ function InnerEditor({
|
||||
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
||||
// managed by the panel's Values tab, not this declaration array.
|
||||
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
|
||||
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
The legacy optional-step DECLARATION authoring state/panel is removed. Optional
|
||||
steps are graph-native `optional-group` nodes authored through the canvas; the
|
||||
editor no longer carries a separate `optionalSteps` declaration array. */
|
||||
// FNXC:WorkflowEditor 2026-06-21-20:06:
|
||||
// Built-in workflow graph structure remains read-only, but prompt/gate node prompts need a separate per-project override state so editing prompts does not mark structural graph edits dirty or use the read-only workflow PATCH authority.
|
||||
const [promptOverrides, setPromptOverrides] = useState<WorkflowPromptOverridesPayload | null>(null);
|
||||
@@ -834,7 +840,6 @@ function InnerEditor({
|
||||
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
||||
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
||||
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
||||
const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed";
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(sidebarCollapsedStorageKey) === "1";
|
||||
@@ -863,13 +868,6 @@ function InnerEditor({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(sidebarCollapsedStorageKey, sidebarCollapsed ? "1" : "0");
|
||||
@@ -898,13 +896,6 @@ function InnerEditor({
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [settingsCollapsed]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [optionalStepsCollapsed]);
|
||||
// React Flow instance for programmatic viewport control (auto-layout on load).
|
||||
const { setViewport } = useReactFlow();
|
||||
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
||||
@@ -1092,10 +1083,10 @@ function InnerEditor({
|
||||
if (isBuiltin) return false;
|
||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||
return (
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !==
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings) !==
|
||||
loadedSnapshotRef.current
|
||||
);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1243,7 +1234,6 @@ function InnerEditor({
|
||||
setColumns([]);
|
||||
setFields([]);
|
||||
setSettings([]);
|
||||
setOptionalSteps([]);
|
||||
setPromptOverrides(null);
|
||||
setPromptOverrideSavingNodeId(null);
|
||||
setName("");
|
||||
@@ -1255,7 +1245,6 @@ function InnerEditor({
|
||||
const loadedColumns = columnsOf(activeWorkflow);
|
||||
const loadedFields = fieldsOf(activeWorkflow);
|
||||
const loadedSettings = settingsOf(activeWorkflow);
|
||||
const loadedOptionalSteps = optionalStepsOf(activeWorkflow);
|
||||
// Auto-layout on load: compute tidy positions and apply them before the
|
||||
// first render so nodes are visible in the top-left viewport.
|
||||
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
||||
@@ -1265,7 +1254,6 @@ function InnerEditor({
|
||||
setColumns(loadedColumns);
|
||||
setFields(loadedFields);
|
||||
setSettings(loadedSettings);
|
||||
setOptionalSteps(loadedOptionalSteps);
|
||||
setName(activeWorkflow.name);
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingName(false);
|
||||
@@ -1280,7 +1268,6 @@ function InnerEditor({
|
||||
loadedColumns,
|
||||
loadedFields,
|
||||
loadedSettings,
|
||||
loadedOptionalSteps,
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
@@ -1455,16 +1442,19 @@ function InnerEditor({
|
||||
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
|
||||
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
|
||||
|
||||
if (kind === "foreach" || kind === "loop") {
|
||||
if (kind === "foreach" || kind === "loop" || kind === "optional-group") {
|
||||
// Template groups render as React Flow group nodes. Foreach seeds the
|
||||
// required step-execute seam; loop seeds a regular prompt so authors can
|
||||
// wire the repeated body immediately. The group node must precede its
|
||||
// child for React Flow's parent extent to apply.
|
||||
// required step-execute seam; loop + optional-group seed a regular prompt
|
||||
// so authors can wire the body immediately. The group node must precede
|
||||
// its child for React Flow's parent extent to apply.
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is authored exactly like a foreach/loop region — drop nodes inside; the subgraph runs once when the task enables the group.
|
||||
const childId = foreachChildFlowId(id, newNodeId());
|
||||
const childLabel =
|
||||
kind === "foreach"
|
||||
? t("workflowNodes.stepExecuteLabel", "Step execute")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
: kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
|
||||
setNodes((ns) => [
|
||||
...ns,
|
||||
@@ -1521,6 +1511,34 @@ function InnerEditor({
|
||||
[isBuiltin, addNode],
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:32:
|
||||
"Insert as optional group" (U5/R5): drop an add-on already wrapped in an `optional-group` container in
|
||||
one action, seeding the group's `defaultOn` from the template's `defaultOn`. Reuses `stepTemplateToNode`
|
||||
(KTD-5 — the catalog stays flat) to project the add-on to a prompt/script node, then `optionalGroupFragmentIr`
|
||||
to wrap it and the EXISTING `insertFragment` path to remap ids + expand the group's template child — so two
|
||||
inserts of the same add-on never collide. The group name carries the template name so the per-task toggle
|
||||
surfaces label it.
|
||||
*/
|
||||
const handleInsertStepTemplateAsOptionalGroup = useCallback(
|
||||
(tpl: WorkflowStepTemplate) => {
|
||||
if (isBuiltin) return;
|
||||
const { kind, config } = stepTemplateToNode(tpl);
|
||||
const fragmentIr = optionalGroupFragmentIr(
|
||||
{ kind: kind as WorkflowIrNodeKind, config },
|
||||
{ name: tpl.name, defaultOn: tpl.defaultOn ?? false },
|
||||
);
|
||||
const result = insertFragment(nodes, edges, fragmentIr, {
|
||||
x: 240,
|
||||
y: 200 + (nodes.length % 4) * 40,
|
||||
});
|
||||
setNodes(result.nodes);
|
||||
setEdges(result.edges);
|
||||
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
|
||||
},
|
||||
[isBuiltin, nodes, edges, setNodes, setEdges],
|
||||
);
|
||||
|
||||
// U9/R8: insert a fragment definition's body into the active graph. Pre-validates
|
||||
// seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent
|
||||
// inline error inside the Templates section and does NOT insert. Otherwise
|
||||
@@ -1619,11 +1637,12 @@ function InnerEditor({
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
// Hydrate settings + optionalSteps on the fragment/generate path too — it
|
||||
// previously dropped both, which silently lost the declarations on the next
|
||||
// save (the round-trip data loss U2 fixes for the primary load path).
|
||||
// Hydrate settings on the fragment/generate path too — it previously dropped
|
||||
// them, which silently lost the declarations on the next save (the round-trip
|
||||
// data loss U2 fixes for the primary load path). Optional steps need no
|
||||
// separate hydration: they are graph-native `optional-group` nodes carried by
|
||||
// the node/edge mapping above (FNXC:WorkflowOptionalGroup 2026-06-21-18:00).
|
||||
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
@@ -1996,7 +2015,6 @@ function InnerEditor({
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
// Include name/description in the PATCH only when they changed from the
|
||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||
@@ -2014,7 +2032,6 @@ function InnerEditor({
|
||||
columns,
|
||||
fields,
|
||||
settings,
|
||||
optionalSteps,
|
||||
);
|
||||
setName(updated.name);
|
||||
setDescription(updated.description ?? "");
|
||||
@@ -2084,7 +2101,7 @@ function InnerEditor({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
|
||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||
// nodes and any node the server flagged (seam-in-branch). One component
|
||||
@@ -2101,11 +2118,14 @@ function InnerEditor({
|
||||
let errorBadge: string | undefined;
|
||||
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
|
||||
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
|
||||
const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
|
||||
const isTemplateGroup =
|
||||
n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group";
|
||||
const emptyHint =
|
||||
n.data.kind === "loop"
|
||||
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
: n.data.kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupEmptyHint", "Drag optional steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined;
|
||||
if (
|
||||
errorBadge === n.data.errorBadge &&
|
||||
@@ -2129,6 +2149,8 @@ function InnerEditor({
|
||||
* The structural start node needs an inspector because its entry column is editable and persisted in the workflow IR. Keep end structural-only until it has a meaningful editable property.
|
||||
*/
|
||||
const selectedNodeHasInspector = selectedNode !== null && selectedNode.data.kind !== "end";
|
||||
// FNXC:WorkflowEditor 2026-06-21-10:00: Help content for the inspector, keyed by the node's effective kind (preserved IR kind when a graph-only policy node collapsed onto a generic merge/gate/hold shape).
|
||||
const selectedNodeHelp = selectedNode !== null ? nodeHelpForData(selectedNode.data) : null;
|
||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
|
||||
const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed;
|
||||
const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null;
|
||||
@@ -2726,26 +2748,9 @@ function InnerEditor({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-sidebar-section-toggle"
|
||||
aria-expanded={!optionalStepsCollapsed}
|
||||
data-testid="wf-sidebar-optional-steps-toggle"
|
||||
onClick={() => setOptionalStepsCollapsed((c) => !c)}
|
||||
>
|
||||
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
|
||||
</button>
|
||||
{!optionalStepsCollapsed && (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: The optional-step
|
||||
DECLARATION authoring sidebar section is removed. Optional steps
|
||||
are authored as graph-native `optional-group` nodes on the canvas. */}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
@@ -2881,7 +2886,6 @@ function InnerEditor({
|
||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||
["settings", t("workflowSettings.title", "Settings")],
|
||||
["fields", t("workflowFields.title", "Fields")],
|
||||
["optional-steps", t("workflowOptionalSteps.title", "Optional steps")],
|
||||
["columns", t("workflowColumns.title", "Columns")],
|
||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||
@@ -2992,19 +2996,37 @@ function InnerEditor({
|
||||
{templateGroups.stepEntries.length > 0 && (
|
||||
<div className="wf-mobile-template-group">
|
||||
<h4>{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}</h4>
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-14:38: mobile mirrors the desktop two-variant insert (node / optional group). */}
|
||||
{templateGroups.stepEntries.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<div key={s.id} className="wf-mobile-template-option-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-mobile-template-option-optional"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}-optional-group`}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplateAsOptionalGroup",
|
||||
"Insert {{name}} as optional group",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplateAsOptionalGroup(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{t("workflowNodes.asOptionalGroup", "as optional group")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -3060,16 +3082,6 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "optional-steps" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "columns" && (
|
||||
<div className="wf-mobile-destination">
|
||||
@@ -3396,22 +3408,48 @@ function InnerEditor({
|
||||
{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
|
||||
</h4>
|
||||
<div className="wf-templates-entries">
|
||||
{/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:36:
|
||||
Each built-in add-on surfaces TWO insert variants: the row inserts as a single node
|
||||
(today's behavior), and a small secondary "as optional group" affordance wraps it in
|
||||
an `optional-group` container (U5/R5). Both keep the established `wf-tpl-step-*` testid
|
||||
convention (the wrap variant suffixes `-optional-group`).
|
||||
*/}
|
||||
{templateGroups.stepEntries.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="wf-templates-entry"
|
||||
data-testid={`wf-tpl-step-${s.id}`}
|
||||
disabled={isBuiltin}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplate",
|
||||
"Insert template {{name}}",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplate(s)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<div key={s.id} className="wf-templates-entry-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-templates-entry"
|
||||
data-testid={`wf-tpl-step-${s.id}`}
|
||||
disabled={isBuiltin}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplate",
|
||||
"Insert template {{name}}",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplate(s)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-templates-entry-optional"
|
||||
data-testid={`wf-tpl-step-${s.id}-optional-group`}
|
||||
disabled={isBuiltin}
|
||||
title={t(
|
||||
"workflowNodes.insertAsOptionalGroup",
|
||||
"Insert as optional group",
|
||||
)}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplateAsOptionalGroup",
|
||||
"Insert {{name}} as optional group",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplateAsOptionalGroup(s)}
|
||||
>
|
||||
{t("workflowNodes.asOptionalGroup", "as optional group")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3587,7 +3625,8 @@ function InnerEditor({
|
||||
!(compactLayoutEnabled && !isMobileMode) && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
|
||||
<div className="wf-inspector-heading">
|
||||
<h3>{t("workflowNodes.nodeInspector", "Node")}</h3>
|
||||
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Heading shows the node-kind title (from the help registry) so the pane names what is selected, falling back to the generic "Node" label. */}
|
||||
<h3>{selectedNodeHelp?.title ?? t("workflowNodes.nodeInspector", "Node")}</h3>
|
||||
{isMobileMode && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3607,6 +3646,37 @@ function InnerEditor({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Per-node Help — what the node does, how to configure it, and its inputs/outputs/edges. Collapsed by default so it never pushes config fields below the fold; remembered open/closed within the session is intentionally not persisted (cheap to reopen). Engine-managed graph-only nodes (merge gate, branch-group integration/promotion, PR/recovery nodes) get an "Engine-managed" badge since they are read-only. */}
|
||||
{selectedNodeHelp && (
|
||||
<details className="wf-inspector-help" data-testid="wf-node-help">
|
||||
<summary className="wf-inspector-help-summary">
|
||||
<HelpCircle size={13} aria-hidden />
|
||||
<span>{t("workflowNodes.helpTitle", "What does this node do?")}</span>
|
||||
{selectedNodeHelp.graphOnly && (
|
||||
<span className="wf-inspector-help-badge" data-testid="wf-node-help-engine-managed">
|
||||
{t("workflowNodes.helpEngineManaged", "Engine-managed")}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
<div className="wf-inspector-help-body">
|
||||
<p className="wf-inspector-help-summary-text">{selectedNodeHelp.summary}</p>
|
||||
<dl className="wf-inspector-help-dl">
|
||||
{selectedNodeHelp.configure && (
|
||||
<>
|
||||
<dt>{t("workflowNodes.helpConfigure", "Configure")}</dt>
|
||||
<dd>{selectedNodeHelp.configure}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t("workflowNodes.helpInputs", "Inputs")}</dt>
|
||||
<dd>{selectedNodeHelp.inputs}</dd>
|
||||
<dt>{t("workflowNodes.helpOutputs", "Outputs")}</dt>
|
||||
<dd>{selectedNodeHelp.outputs}</dd>
|
||||
<dt>{t("workflowNodes.helpEdges", "Edges")}</dt>
|
||||
<dd>{selectedNodeHelp.edges}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{isBuiltin && (
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t("workflowNodes.readOnlyDuplicateToEdit", "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.")}
|
||||
@@ -4371,6 +4441,27 @@ function InnerEditor({
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-11:30: The optional-group inspector exposes the workflow-author `defaultOn` default (whether new tasks enable the group). The group name reuses the shared Name field above; the body is authored by dropping nodes inside, identical to foreach/loop. */}
|
||||
{selectedNode.data.kind === "optional-group" ? (
|
||||
<>
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="wf-optional-group-default-on"
|
||||
checked={Boolean(selectedNode.data.config?.defaultOn)}
|
||||
onChange={(e) => updateSelectedData({ config: { defaultOn: e.target.checked } })}
|
||||
/>
|
||||
<span>{t("workflowNodes.optionalGroupDefaultOn", "Enabled by default for new tasks")}</span>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.optionalGroupNote",
|
||||
"Runs the steps inside this group once when the task enables it (seeded from this default), and skips them when disabled. Drop the optional steps into the region.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "step-review" ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout
|
||||
* so the optional-steps panel reads consistently alongside Fields/Settings. */
|
||||
|
||||
.wf-optional-steps-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.wf-optional-steps-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-hint,
|
||||
.wf-optional-steps-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-optional-step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.wf-optional-step-item.is-unknown {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wf-optional-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-optional-step-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-name--unknown {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wf-optional-step-description {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-default {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove:hover:not(:disabled) {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.wf-optional-steps-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wf-optional-steps-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
* Workflow authors need to declare which step templates are optional and set each
|
||||
* one's defaultOn from the visual editor (persisted on the IR's `optionalSteps`
|
||||
* array) so optional steps are authorable without hand-editing IR.
|
||||
*
|
||||
* WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring
|
||||
* surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives
|
||||
* alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's
|
||||
* `optionalSteps` array through the same state/save flow (preserved across the
|
||||
* round-trip by `flowToIr`).
|
||||
*
|
||||
* A declaration is just `{ templateId, defaultOn? }`. Display metadata
|
||||
* (name/description/phase) is resolved from the built-in step-template catalog at
|
||||
* render time — never duplicated into the IR — so the resolver stays the single
|
||||
* source of truth. Unknown/stale template ids render a muted, still-removable row
|
||||
* rather than being silently dropped.
|
||||
*/
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import "./WorkflowOptionalStepsPanel.css";
|
||||
|
||||
interface WorkflowOptionalStepsPanelProps {
|
||||
optionalSteps: WorkflowOptionalStep[];
|
||||
onChange: (next: WorkflowOptionalStep[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Plugin-contributed templates, merged into the catalog when available. */
|
||||
pluginTemplates?: WorkflowStepTemplate[];
|
||||
}
|
||||
|
||||
export function WorkflowOptionalStepsPanel({
|
||||
optionalSteps,
|
||||
onChange,
|
||||
readOnly,
|
||||
pluginTemplates = [],
|
||||
}: WorkflowOptionalStepsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const templatesById = useMemo(() => {
|
||||
const map = new Map<string, WorkflowStepTemplate>();
|
||||
for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl);
|
||||
return map;
|
||||
}, [pluginTemplates]);
|
||||
|
||||
const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]);
|
||||
|
||||
// Catalog entries not already declared — the "Add optional step" picker source.
|
||||
const available = useMemo(
|
||||
() => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)),
|
||||
[templatesById, declaredIds],
|
||||
);
|
||||
|
||||
const addStep = useCallback(
|
||||
(templateId: string) => {
|
||||
if (!templateId || declaredIds.has(templateId)) return;
|
||||
onChange([...optionalSteps, { templateId, defaultOn: false }]);
|
||||
},
|
||||
[optionalSteps, onChange, declaredIds],
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
const toggleDefaultOn = useCallback(
|
||||
(templateId: string, defaultOn: boolean) =>
|
||||
onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
|
||||
<header className="wf-optional-steps-header">
|
||||
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
|
||||
<p className="wf-optional-steps-hint">
|
||||
{t(
|
||||
"workflowOptionalSteps.hint",
|
||||
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{optionalSteps.length === 0 ? (
|
||||
<p className="wf-optional-steps-empty">
|
||||
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="wf-optional-steps-list">
|
||||
{optionalSteps.map((step) => {
|
||||
const tpl = templatesById.get(step.templateId);
|
||||
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
|
||||
return (
|
||||
<li
|
||||
key={step.templateId}
|
||||
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
|
||||
data-testid={`wf-optional-step-${step.templateId}`}
|
||||
>
|
||||
<div className="wf-optional-step-head">
|
||||
<div className="wf-optional-step-title">
|
||||
{tpl ? (
|
||||
<>
|
||||
<span className="wf-optional-step-name">{tpl.name}</span>
|
||||
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
|
||||
</>
|
||||
) : (
|
||||
<span className="wf-optional-step-name wf-optional-step-name--unknown">
|
||||
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-optional-step-remove"
|
||||
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
|
||||
disabled={readOnly}
|
||||
onClick={() => removeStep(step.templateId)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{tpl?.description && (
|
||||
<p className="wf-optional-step-description">{tpl.description}</p>
|
||||
)}
|
||||
<label className="wf-optional-step-default">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaultOn}
|
||||
disabled={readOnly}
|
||||
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
|
||||
name: tpl?.name ?? step.templateId,
|
||||
})}
|
||||
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
|
||||
/>
|
||||
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="wf-optional-steps-add">
|
||||
{/* Picker resets to placeholder after each add (value stays ""). */}
|
||||
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
|
||||
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
|
||||
</label>
|
||||
<select
|
||||
id="wf-optional-steps-add-select"
|
||||
data-testid="wf-optional-steps-add-select"
|
||||
value=""
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
addStep(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
|
||||
</option>
|
||||
{available.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOptionalStepsPanel;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-00:00:
|
||||
Dashboard "doesn't look broken" floor (Phase A U3 / master U10, KTD5).
|
||||
A workspace-mode task has NO singular `task.worktree`/`task.branch`; instead it carries
|
||||
`task.workspaceWorktrees` — one acquired git worktree per sub-repo, keyed by repo path
|
||||
relative to the workspace root. Existing display surfaces (TaskCard branch row, TaskDetail
|
||||
metadata) key off the singular `task.branch`, so a workspace task would render an EMPTY
|
||||
branch area — looking broken. This guard renders a static placeholder ("N repos acquired")
|
||||
plus a flat read-only per-repo path/branch list so the task is observable, never crashing
|
||||
and never blank.
|
||||
|
||||
Scope ceiling: flat read-only list / placeholder ONLY. A rich per-repo-status component
|
||||
(live diff/lease/merge state per repo) is the deferred registration UI — out of scope here.
|
||||
Single-repo rendering is untouched: callers only mount this when `isWorkspaceTask(task)`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when the task is a workspace-mode task: no singular `worktree` recorded
|
||||
* and at least one acquired per-sub-repo worktree in `workspaceWorktrees`.
|
||||
* Single-repo tasks (populated `worktree`, no `workspaceWorktrees`) return false,
|
||||
* keeping their existing rendering byte-for-byte unchanged.
|
||||
*/
|
||||
export function isWorkspaceTask(task: Pick<Task, "worktree" | "workspaceWorktrees">): boolean {
|
||||
if (task.worktree) return false;
|
||||
const entries = task.workspaceWorktrees;
|
||||
return Boolean(entries && Object.keys(entries).length > 0);
|
||||
}
|
||||
|
||||
interface WorkspaceWorktreesSummaryProps {
|
||||
task: Pick<Task, "worktree" | "workspaceWorktrees">;
|
||||
/** Compact variant for the dense TaskCard surface (placeholder only). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only summary of a workspace task's acquired sub-repo worktrees.
|
||||
*
|
||||
* - `compact` (TaskCard): renders just the "N repos acquired" placeholder chip.
|
||||
* - default (TaskDetail): renders the placeholder plus a flat per-repo list of
|
||||
* `repo → worktreePath (branch)`.
|
||||
*
|
||||
* Renders nothing for non-workspace tasks; mount only behind `isWorkspaceTask`.
|
||||
*/
|
||||
export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWorktreesSummaryProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const entries = task.workspaceWorktrees;
|
||||
if (!isWorkspaceTask(task) || !entries) return null;
|
||||
|
||||
const repos = Object.entries(entries);
|
||||
const placeholder = t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length });
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="card-branch-row" aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}>
|
||||
<span className="card-branch-chip" data-testid="workspace-worktrees-placeholder" title={placeholder}>
|
||||
<span className="card-branch-label">{t("tasks.workspace", "Workspace")}</span>
|
||||
<span className="card-branch-value">{placeholder}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workspace-worktrees-summary"
|
||||
data-testid="workspace-worktrees-summary"
|
||||
aria-label={t("tasks.workspaceWorktrees", "Workspace repos")}
|
||||
>
|
||||
<div className="workspace-worktrees-placeholder" data-testid="workspace-worktrees-placeholder">
|
||||
{placeholder}
|
||||
</div>
|
||||
<ul className="workspace-worktrees-list">
|
||||
{repos.map(([repoRelPath, info]) => (
|
||||
<li key={repoRelPath} className="workspace-worktrees-item">
|
||||
<span className="workspace-worktrees-repo" title={repoRelPath}>
|
||||
{repoRelPath}
|
||||
</span>
|
||||
<span className="workspace-worktrees-path" title={info.worktreePath}>
|
||||
{info.worktreePath}
|
||||
</span>
|
||||
<span className="workspace-worktrees-branch" title={info.branch}>
|
||||
{info.branch}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => {
|
||||
const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? [];
|
||||
const mobileCss = mobileBlockMatch.join("\n");
|
||||
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length;
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length;
|
||||
expect(headerRuleCount).toBe(1);
|
||||
expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/);
|
||||
expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)");
|
||||
expect(containerStart).toBeGreaterThan(-1);
|
||||
const containerCss = css.slice(containerStart);
|
||||
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/);
|
||||
expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/);
|
||||
|
||||
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/);
|
||||
expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/);
|
||||
});
|
||||
|
||||
it("renders preview header elements and keeps URL badge outside preview actions", () => {
|
||||
mockUseDevServer.mockReturnValue(createDevServerHookState());
|
||||
mockUseDevServerLogs.mockReturnValue({
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({
|
||||
Search: () => <span data-testid="icon-search" />,
|
||||
ShieldAlert: () => <span data-testid="icon-shield-alert" />,
|
||||
Square: () => <span data-testid="icon-square" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
||||
@@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderInRightDock(width: number) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "right-dock__body";
|
||||
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
||||
document.body.appendChild(host);
|
||||
|
||||
return render(<DevServerView addToast={addToast} projectId="project-a" />, { container: host });
|
||||
}
|
||||
|
||||
it("activates narrow right-dock preview mode only below the dock threshold", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const narrow = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
narrow.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
renderInRightDock(640);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({
|
||||
entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }],
|
||||
total: 1,
|
||||
}));
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000");
|
||||
expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
const modal = await screen.findByTestId("devserver-preview-modal");
|
||||
expect(modal).toHaveAttribute("role", "dialog");
|
||||
expect(modal).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps preview modes and fallback actions inside the narrow dock modal", async () => {
|
||||
const retry = vi.fn();
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
const { rerender } = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({
|
||||
embedStatus: "blocked",
|
||||
isBlocked: true,
|
||||
embedContext: "The server may block iframe embedding...",
|
||||
retry,
|
||||
});
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Preview blocked")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry"));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle"));
|
||||
|
||||
expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab"));
|
||||
expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const mobile = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
|
||||
mobile.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
const expandedHost = document.createElement("div");
|
||||
expandedHost.className = "right-dock-expand-modal__body";
|
||||
Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 });
|
||||
document.body.appendChild(expandedHost);
|
||||
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />, { container: expandedHost });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows start-empty state when server is not configured", () => {
|
||||
|
||||
@@ -4895,6 +4895,54 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends unsaved ntfy form config before saving", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
await user.click(screen.getByText("Advanced"));
|
||||
await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//");
|
||||
await user.type(screen.getByLabelText("Access token (optional)"), "override-token");
|
||||
await user.click(screen.getByRole("button", { name: /Test notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
expect.objectContaining({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ntfy test disabled until the current form has a valid topic", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
const testButton = screen.getByRole("button", { name: /Test notification/ });
|
||||
expect(testButton).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!");
|
||||
expect(testButton).toBeDisabled();
|
||||
expect(mockTestNotification).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(screen.getByLabelText("ntfy Topic"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
expect(testButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
@@ -4929,7 +4977,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:agent-to-user" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -4953,7 +5005,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:room" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
|
||||
import { parseWorkflowIr, type WorkflowDefinition, type Settings } from "@fusion/core";
|
||||
import { parseWorkflowIr, WORKFLOW_STEP_TEMPLATES, type WorkflowDefinition, type Settings } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import {
|
||||
irToFlow,
|
||||
@@ -170,13 +170,9 @@ function v2Def(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function v2DefWithOptional(): WorkflowDefinition {
|
||||
const base = v2Def();
|
||||
return {
|
||||
...base,
|
||||
ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"],
|
||||
};
|
||||
}
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its
|
||||
// optional-step DECLARATION hydration/save test are removed — the declaration
|
||||
// authoring panel is retired (optional-group nodes now).
|
||||
|
||||
function builtinDef(): WorkflowDefinition {
|
||||
return {
|
||||
@@ -392,12 +388,13 @@ describe("workflow-flow-mapping", () => {
|
||||
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
|
||||
const { edges } = edgeRenderableAssertion(builtinDef());
|
||||
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place.
|
||||
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
|
||||
"browser-verification",
|
||||
"execute",
|
||||
"merge-attempt",
|
||||
"planning",
|
||||
"review",
|
||||
"workflow-step",
|
||||
]);
|
||||
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
|
||||
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
|
||||
@@ -806,34 +803,6 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(start?.column).toBe("done");
|
||||
});
|
||||
|
||||
it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
||||
...v2DefWithOptional(),
|
||||
...(updates as object),
|
||||
}));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
// The declared optional step is hydrated into the panel (optionalStepsOf).
|
||||
const row = await screen.findByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
|
||||
// Toggling defaultOn must mark the editor dirty (serializeGraph threading) so
|
||||
// the Save button enables and persists the change.
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as {
|
||||
optionalSteps?: { templateId: string; defaultOn?: boolean }[];
|
||||
};
|
||||
expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
@@ -850,6 +819,25 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(within(inspector).queryByLabelText("Name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FNXC:WorkflowEditor 2026-06-21-10:00: Every node's detail pane carries a Help section describing what it does and its inputs/outputs/edges.
|
||||
it("renders a Help section in the node detail pane", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(await screen.findByTestId("wf-node-start"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
const help = within(inspector).getByTestId("wf-node-help");
|
||||
expect(help).toHaveTextContent("What does this node do?");
|
||||
expect(help).toHaveTextContent("Inputs");
|
||||
expect(help).toHaveTextContent("Outputs");
|
||||
expect(help).toHaveTextContent("Edges");
|
||||
// Editor (non-policy) nodes are not flagged engine-managed.
|
||||
expect(within(inspector).queryByTestId("wf-node-help-engine-managed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps built-in start node entry-column controls read-only", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
|
||||
@@ -1696,6 +1684,51 @@ function stepwiseDef(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/** A v2 workflow with an optional-group container (defaultOn:false) holding one
|
||||
* template child, so the editor's optional-group surfaces have something to
|
||||
* render, toggle, and delete. */
|
||||
function optionalGroupDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-OPT",
|
||||
kind: "workflow",
|
||||
name: "Optional",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Optional",
|
||||
columns: [
|
||||
{ id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {},
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
@@ -1752,6 +1785,80 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
expect(template.nodes[0].config?.seam).toBe("step-execute");
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group must be
|
||||
// authorable like a foreach/loop — added from the palette as a registered group
|
||||
// container (not react-flow__node-default), filled with nodes, named, toggled
|
||||
// for defaultOn, and deleted with its children cascaded.
|
||||
it("adds an optional-group from the palette and round-trips its template on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Optional group").closest("button")!);
|
||||
// Renders via the registered group component (wf-node-optional-group), NOT
|
||||
// React Flow's default fallback.
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(), { timeout: 5000 });
|
||||
// No empty hint — the palette seeded an optional step inside.
|
||||
expect(screen.queryByTestId("wf-optional-group-empty")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { id: string; kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const group = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(group).toBeTruthy();
|
||||
const template = group!.config!.template as { nodes: unknown[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("toggles optional-group defaultOn, marks the editor dirty, and persists on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
fireEvent.click(group);
|
||||
|
||||
const toggle = await screen.findByTestId("wf-optional-group-default-on");
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
fireEvent.click(toggle);
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const opt = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(opt!.config!.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes an optional-group and removes its parentId children (no orphans)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
// The seeded template child renders as a parented flow node.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(group);
|
||||
fireEvent.click(await screen.findByTestId("wf-delete-node"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-node-optional-group")).not.toBeInTheDocument());
|
||||
// The template child is gone too (cascade) — no orphaned parentId node.
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
@@ -2995,11 +3102,13 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
|
||||
const filter = await screen.findByTestId("wf-template-filter");
|
||||
// All 8 step entries present pre-filter.
|
||||
expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(8);
|
||||
// All 8 step entries present pre-filter. Match only the primary "insert as
|
||||
// node" buttons, excluding the sibling "-optional-group" insert variant.
|
||||
const primaryStep = /^wf-tpl-step-(?!.*-optional-group$).*/;
|
||||
expect(screen.getAllByTestId(primaryStep).length).toBe(8);
|
||||
// Filter to "Step 3" → only that step survives.
|
||||
fireEvent.change(filter, { target: { value: "Step 3" } });
|
||||
await waitFor(() => expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(1));
|
||||
await waitFor(() => expect(screen.getAllByTestId(primaryStep).length).toBe(1));
|
||||
expect(screen.getByTestId("wf-tpl-step-s-3")).toBeInTheDocument();
|
||||
// Fragment (name "Lint fragment") no longer matches.
|
||||
expect(screen.queryByTestId("wf-tpl-fragment-WF-FRAG-A")).not.toBeInTheDocument();
|
||||
@@ -3038,6 +3147,123 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
expect(screen.getByTestId("wf-tpl-step-qa-check")).toBeDisabled();
|
||||
expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled();
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:50: All seven built-in add-ons must
|
||||
// surface in the palette and insert two ways — as a single node (today's
|
||||
// behavior, reusing stepTemplateToNode) and wrapped in an optional-group
|
||||
// container (reusing insertFragment). These tests pin U5/R5.
|
||||
it("surfaces all seven built-in add-ons in the palette", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
|
||||
// Every add-on id is present as a primary "insert as node" button AND offers
|
||||
// the "as optional group" sibling variant.
|
||||
for (const tpl of WORKFLOW_STEP_TEMPLATES) {
|
||||
expect(screen.getByTestId(`wf-tpl-step-${tpl.id}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(`wf-tpl-step-${tpl.id}-optional-group`),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(WORKFLOW_STEP_TEMPLATES).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("inserts an add-on as a single node carrying its template config", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
const before = screen.queryAllByTestId("wf-node-prompt").length;
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-documentation-review"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-prompt").length).toBe(before + 1),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const docTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "documentation-review")!;
|
||||
const inserted = ir.nodes.find((n) => n.config?.name === docTpl.name);
|
||||
expect(inserted).toBeTruthy();
|
||||
expect(inserted!.kind).toBe(docTpl.mode === "script" ? "script" : "prompt");
|
||||
});
|
||||
|
||||
it("inserts an add-on as an optional-group whose template holds the projected node and defaultOn matches", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
// The wrapped add-on renders as a registered optional-group container.
|
||||
await waitFor(
|
||||
() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const secTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "security-audit")!;
|
||||
const group = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(group).toBeTruthy();
|
||||
expect(group!.config!.defaultOn).toBe(secTpl.defaultOn ?? false);
|
||||
const template = group!.config!.template as { nodes: { kind: string; config?: Record<string, unknown> }[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
expect(template.nodes[0].config?.name).toBe(secTpl.name);
|
||||
});
|
||||
|
||||
it("remaps ids when the same add-on subgraph is inserted twice (no collision)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(1),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(2),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { id: string; kind: string }[] } }).ir;
|
||||
const groupIds = ir.nodes.filter((n) => n.kind === "optional-group").map((n) => n.id);
|
||||
expect(groupIds).toHaveLength(2);
|
||||
expect(new Set(groupIds).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── U10: Design-with-AI editor affordances ──────────────────────────────────
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import type { WorkflowOptionalStep } from "@fusion/core";
|
||||
import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel";
|
||||
|
||||
// Controlled host mirroring how WorkflowNodeEditor drives the panel.
|
||||
function Host({
|
||||
initial,
|
||||
readOnly = false,
|
||||
onState,
|
||||
}: {
|
||||
initial: WorkflowOptionalStep[];
|
||||
readOnly?: boolean;
|
||||
onState?: (s: WorkflowOptionalStep[]) => void;
|
||||
}) {
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>(initial);
|
||||
return (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
setOptionalSteps(next);
|
||||
onState?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowOptionalStepsPanel", () => {
|
||||
it("renders the empty state and an add picker when no steps are declared", () => {
|
||||
render(<Host initial={[]} />);
|
||||
expect(screen.getByText(/No optional steps/i)).toBeTruthy();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
// browser-verification is in the catalog and not yet declared → available.
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a step from the picker (defaultOn false) and removes it from the picker", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[]} onState={onState} />);
|
||||
fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), {
|
||||
target: { value: "browser-verification" },
|
||||
});
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]);
|
||||
// The declared row is shown with the resolved template name…
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
// …and the picker no longer offers it.
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles defaultOn for a declared step", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("removes a declared step and returns it to the picker", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an unknown/stale templateId as a muted, still-removable row", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-does-not-exist");
|
||||
expect(row.className).toContain("is-unknown");
|
||||
expect(within(row).getByText(/Unknown step/i)).toBeTruthy();
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(onState).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("disables editing when readOnly", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "../WorkspaceWorktreesSummary";
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-00:00:
|
||||
U3/KTD5 dashboard "doesn't look broken" floor. Asserts the invariant across both surfaces
|
||||
the summary serves (FN-5893):
|
||||
- happy path: workspace task (no task.worktree, two workspaceWorktrees entries) renders a
|
||||
flat per-repo list + "N repos acquired" placeholder — no crash, not blank.
|
||||
- regression: single-repo task (task.worktree set, no workspaceWorktrees) renders nothing
|
||||
from this guard, so its existing rendering stays unchanged.
|
||||
Narrow seam: tests the presentational component directly, no API / SSE / timers (FN-5048).
|
||||
*/
|
||||
|
||||
const workspaceTask = {
|
||||
worktree: undefined,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/wt/repo-a", branch: "fusion/fn-1-a" },
|
||||
"repo-b": { worktreePath: "/wt/repo-b", branch: "fusion/fn-1-b" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const singleRepoTask = {
|
||||
worktree: "/wt/single",
|
||||
workspaceWorktrees: undefined,
|
||||
} as const;
|
||||
|
||||
describe("isWorkspaceTask", () => {
|
||||
it("is true when worktree is absent and workspaceWorktrees has entries", () => {
|
||||
expect(isWorkspaceTask(workspaceTask)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a single-repo task (worktree set)", () => {
|
||||
expect(isWorkspaceTask(singleRepoTask)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when workspaceWorktrees is an empty record", () => {
|
||||
expect(isWorkspaceTask({ worktree: undefined, workspaceWorktrees: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers the singular worktree even if workspaceWorktrees is populated", () => {
|
||||
expect(
|
||||
isWorkspaceTask({ worktree: "/wt/x", workspaceWorktrees: workspaceTask.workspaceWorktrees }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceWorktreesSummary", () => {
|
||||
it("renders a flat per-repo list and placeholder for a two-repo workspace task (no crash, not empty)", () => {
|
||||
render(<WorkspaceWorktreesSummary task={workspaceTask} />);
|
||||
|
||||
// Placeholder reflects the repo count.
|
||||
expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2");
|
||||
expect(screen.getByText(/2 repos acquired/i)).toBeTruthy();
|
||||
|
||||
// Flat per-repo list: each repo path, worktree path, and branch is shown.
|
||||
const summary = screen.getByTestId("workspace-worktrees-summary");
|
||||
expect(summary).toBeTruthy();
|
||||
expect(screen.getByText("repo-a")).toBeTruthy();
|
||||
expect(screen.getByText("repo-b")).toBeTruthy();
|
||||
expect(screen.getByText("/wt/repo-a")).toBeTruthy();
|
||||
expect(screen.getByText("/wt/repo-b")).toBeTruthy();
|
||||
expect(screen.getByText("fusion/fn-1-a")).toBeTruthy();
|
||||
expect(screen.getByText("fusion/fn-1-b")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders only the compact placeholder in compact mode", () => {
|
||||
render(<WorkspaceWorktreesSummary task={workspaceTask} compact />);
|
||||
expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos");
|
||||
// Compact variant omits the full per-repo list.
|
||||
expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull();
|
||||
expect(screen.queryByText("/wt/repo-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a single-repo task, leaving existing rendering unchanged", () => {
|
||||
const { container } = render(<WorkspaceWorktreesSummary task={singleRepoTask} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-worktrees-placeholder")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when workspaceWorktrees is empty", () => {
|
||||
const { container } = render(
|
||||
<WorkspaceWorktreesSummary task={{ worktree: undefined, workspaceWorktrees: {} }} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
copyIrWithFreshIds,
|
||||
columnsOf,
|
||||
optionalStepsOf,
|
||||
columnForY,
|
||||
bandTop,
|
||||
columnsToBandNodes,
|
||||
@@ -766,6 +766,104 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group's template
|
||||
// subgraph must round-trip through the editor's parentId-child rendering exactly
|
||||
// like foreach/loop — irToFlow renders the template as parented children;
|
||||
// flowToIr reassembles them into config.template, preserving defaultOn/name.
|
||||
it("round-trips an optional-group template (children partitioned by parentId) losslessly", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: true,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
|
||||
{ id: "check", kind: "gate", config: { prompt: "ok?" } },
|
||||
],
|
||||
edges: [{ from: "verify", to: "check", condition: "success" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const def = makeDef(optionalIr);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const columns = columnsOf(def);
|
||||
|
||||
// The optional-group renders via the registered group component (type
|
||||
// "optional-group", NOT react-flow__node-default) with parented children.
|
||||
const group = nodes.find((n) => n.id === "opt");
|
||||
expect(group?.type).toBe("optional-group");
|
||||
expect(group?.data.kind).toBe("optional-group");
|
||||
// The group node keeps defaultOn/name; the template is stripped onto children.
|
||||
expect(group?.data.config?.defaultOn).toBe(true);
|
||||
expect((group?.data.config as Record<string, unknown>)?.template).toBeUndefined();
|
||||
const children = nodes.filter((n) => n.parentId === "opt");
|
||||
expect(children.map((c) => templateNodeIdFromChild("opt", c.id)).sort()).toEqual(["check", "verify"]);
|
||||
|
||||
const { ir: out } = flowToIr("optional", nodes, edges, columns);
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
const opt = out.nodes.find((n) => n.id === "opt");
|
||||
expect(opt?.kind).toBe("optional-group");
|
||||
const cfg = opt?.config as Record<string, unknown>;
|
||||
expect(cfg.defaultOn).toBe(true);
|
||||
expect(cfg.name).toBe("Browser verification");
|
||||
const template = cfg.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] };
|
||||
expect(template.nodes.map((n) => n.id)).toEqual(["verify", "check"]);
|
||||
expect(template.edges).toEqual([{ from: "verify", to: "check", condition: "success" }]);
|
||||
// Top-level edges exclude the intra-template ones.
|
||||
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
||||
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
|
||||
it("cascade-deletes an optional-group's template children", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional-del",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const { nodes, edges } = irToFlow(makeDef(optionalIr));
|
||||
expect(nodes.some((n) => n.parentId === "opt")).toBe(true);
|
||||
const result = cascadeDelete(nodes, edges, ["opt"]);
|
||||
expect(result.nodes.some((n) => n.id === "opt")).toBe(false);
|
||||
expect(result.nodes.some((n) => n.parentId === "opt")).toBe(false);
|
||||
});
|
||||
|
||||
it("inserts loop fragments with their template children intact", () => {
|
||||
const fragment: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
@@ -1338,6 +1436,46 @@ describe("insertFragment", () => {
|
||||
expect(template?.nodes).toHaveLength(2);
|
||||
expect(template?.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:55: optionalGroupFragmentIr wraps a
|
||||
// projected add-on node in an optional-group; insertFragment must expand its
|
||||
// template child and round-trip it via flowToIr, and two inserts must not collide.
|
||||
it("wraps an add-on node in an optional-group fragment that round-trips with defaultOn", () => {
|
||||
const fragmentIr = optionalGroupFragmentIr(
|
||||
{ kind: "prompt", config: { name: "Security Audit", prompt: "audit it" } },
|
||||
{ name: "Security Audit", defaultOn: true },
|
||||
);
|
||||
|
||||
const existing = irToFlow(u8ChainDef());
|
||||
const first = insertFragment(existing.nodes, existing.edges, fragmentIr, { x: 400, y: 200 });
|
||||
const second = insertFragment(first.nodes, first.edges, fragmentIr, { x: 700, y: 200 });
|
||||
|
||||
// Two optional-group containers, each with its template child expanded.
|
||||
const groups = second.nodes.filter((n) => n.data.kind === "optional-group");
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const g of groups) {
|
||||
expect(second.nodes.some((n) => n.parentId === g.id)).toBe(true);
|
||||
}
|
||||
// All ids disjoint across both inserts.
|
||||
const allIds = second.nodes.map((n) => n.id);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
|
||||
// Round-trip: BOTH inserted groups carry defaultOn + a single-node template,
|
||||
// so a regression that breaks the second insert can't pass on the first.
|
||||
const { ir: out } = flowToIr("wf", second.nodes, second.edges);
|
||||
// An optional-group is a v2-only kind: its presence forces v2 serialization
|
||||
// even with no columns/fields/settings, or it would serialize as v1 and fail
|
||||
// parse. (Code review: CodeRabbit.)
|
||||
expect(out.version).toBe("v2");
|
||||
const ogs = out.nodes.filter((n) => n.kind === "optional-group");
|
||||
expect(ogs).toHaveLength(2);
|
||||
for (const og of ogs) {
|
||||
expect(og.config?.defaultOn).toBe(true);
|
||||
const template = (og.config as { template?: { nodes: { config?: Record<string, unknown> }[] } }).template;
|
||||
expect(template?.nodes).toHaveLength(1);
|
||||
expect(template?.nodes[0].config?.name).toBe("Security Audit");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fragmentSeamConflicts", () => {
|
||||
@@ -1505,58 +1643,12 @@ describe("copyIrWithFreshIds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalSteps round-trip (U2)", () => {
|
||||
const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) =>
|
||||
makeDef(
|
||||
parseWorkflowIr({
|
||||
version: "v2",
|
||||
name: "wf-opt",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
...(optionalSteps ? { optionalSteps } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const read = optionalStepsOf(def);
|
||||
expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
// mutating the result does not mutate the source IR
|
||||
read[0].defaultOn = false;
|
||||
expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => {
|
||||
const v1 = makeDef({
|
||||
version: "v1",
|
||||
name: "legacy",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
});
|
||||
expect(optionalStepsOf(v1)).toEqual([]);
|
||||
expect(optionalStepsOf(v2WithOptional())).toEqual([]);
|
||||
});
|
||||
|
||||
it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def));
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification", defaultOn: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf`
|
||||
// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional
|
||||
// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping.
|
||||
describe("optionalSteps declaration authoring removed (U7)", () => {
|
||||
it("flowToIr never emits a legacy optionalSteps key", () => {
|
||||
const { ir: out } = flowToIr(
|
||||
"opt-only",
|
||||
[
|
||||
@@ -1567,21 +1659,7 @@ describe("optionalSteps round-trip (U2)", () => {
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ templateId: "browser-verification" }],
|
||||
);
|
||||
expect(out.version).toBe("v2");
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => {
|
||||
const def = v2WithOptional();
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []);
|
||||
expect("optionalSteps" in out).toBe(false);
|
||||
// and with the arg omitted entirely
|
||||
const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def));
|
||||
expect("optionalSteps" in out2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell } from "lucide-react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { nodeConfigSummary } from "./node-summary";
|
||||
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
|
||||
@@ -28,6 +28,7 @@ export type WorkflowEditorNodeKind =
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "optional-group"
|
||||
| WorkflowNodeKindStepReview
|
||||
| WorkflowNodeKindParseSteps
|
||||
| "code"
|
||||
@@ -65,6 +66,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
|
||||
join: Merge,
|
||||
foreach: Repeat,
|
||||
loop: Repeat,
|
||||
"optional-group": ToggleRight,
|
||||
[WORKFLOW_NODE_KIND_STEP_REVIEW]: ClipboardCheck,
|
||||
[WORKFLOW_NODE_KIND_PARSE_STEPS]: ListChecks,
|
||||
code: Code2,
|
||||
@@ -197,6 +199,42 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` renders as a React Flow group container (mirroring `ForeachGroupNode`/`LoopGroupNode`): template nodes are children (parentId = group id). The header shows the group name plus a `defaultOn` badge ("default on" / "default off") so an author can see, at a glance, whether new tasks enable this group. An unregistered kind falls back to `react-flow__node-default` with missing children — registration in `workflowNodeTypes` (below) is what keeps the container rendering with its body.
|
||||
*/
|
||||
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
const { t } = useTranslation("app");
|
||||
const defaultOn = data.config?.defaultOn === true;
|
||||
const isEmpty = data.templateEmpty === true;
|
||||
return (
|
||||
<div
|
||||
className={`wf-foreach-group wf-optional-group${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
data-testid="wf-node-optional-group"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<ToggleRight size={14} aria-hidden />
|
||||
</span>
|
||||
<span className="wf-node-label">{data.label || "optional-group"}</span>
|
||||
<span className="wf-node-badge" data-testid="wf-optional-group-default-badge">
|
||||
{defaultOn
|
||||
? t("workflowNodes.optionalGroupDefaultOn", "default on")
|
||||
: t("workflowNodes.optionalGroupDefaultOff", "default off")}
|
||||
</span>
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="wf-foreach-empty" data-testid="wf-optional-group-empty">
|
||||
{data.emptyHint || "Drag optional steps here"}
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
|
||||
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
|
||||
@@ -209,6 +247,7 @@ export const workflowNodeTypes = {
|
||||
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
|
||||
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
loop: ({ data }: NodeProps) => <LoopGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"optional-group": ({ data }: NodeProps) => <OptionalGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
|
||||
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
|
||||
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectiveNodeKind, nodeHelpFor, nodeHelpForData } from "../node-help";
|
||||
import type { WorkflowFlowNodeData } from "../WorkflowNodeTypes";
|
||||
|
||||
/** All editor kinds plus the graph-only IR kinds the help registry must cover.
|
||||
* Kept inline (not imported from core) so a missing entry fails loudly here. */
|
||||
const EDITOR_KINDS = [
|
||||
"start",
|
||||
"end",
|
||||
"prompt",
|
||||
"script",
|
||||
"gate",
|
||||
"merge",
|
||||
"hold",
|
||||
"split",
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
"notify",
|
||||
] as const;
|
||||
|
||||
const GRAPH_ONLY_KINDS = [
|
||||
"merge-gate",
|
||||
"merge-attempt",
|
||||
"manual-merge-hold",
|
||||
"retry-backoff",
|
||||
"recovery-router",
|
||||
"branch-group-member-integration",
|
||||
"branch-group-promotion",
|
||||
"pr-create",
|
||||
"pr-respond",
|
||||
"pr-merge",
|
||||
] as const;
|
||||
|
||||
describe("nodeHelpFor", () => {
|
||||
it("returns help for every editor node kind", () => {
|
||||
for (const kind of EDITOR_KINDS) {
|
||||
const help = nodeHelpFor(kind);
|
||||
expect(help, `missing help for editor kind ${kind}`).not.toBeNull();
|
||||
// Every node documents what it does and its I/O + edges.
|
||||
expect(help!.title).toBeTruthy();
|
||||
expect(help!.summary).toBeTruthy();
|
||||
expect(help!.inputs).toBeTruthy();
|
||||
expect(help!.outputs).toBeTruthy();
|
||||
expect(help!.edges).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns help for every graph-only policy node kind, flagged engine-managed", () => {
|
||||
for (const kind of GRAPH_ONLY_KINDS) {
|
||||
const help = nodeHelpFor(kind);
|
||||
expect(help, `missing help for graph-only kind ${kind}`).not.toBeNull();
|
||||
expect(help!.graphOnly).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("editor kinds are not flagged engine-managed", () => {
|
||||
for (const kind of EDITOR_KINDS) {
|
||||
expect(nodeHelpFor(kind)!.graphOnly).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for an unknown kind", () => {
|
||||
expect(nodeHelpFor("not-a-kind")).toBeNull();
|
||||
});
|
||||
|
||||
it("describes branch-group promotion's single-managed-PR idempotency", () => {
|
||||
const help = nodeHelpFor("branch-group-promotion")!;
|
||||
expect(help.summary).toMatch(/single managed PR/i);
|
||||
expect(help.summary).toMatch(/never creates a second PR/i);
|
||||
expect(help.edges).toMatch(/merge attempt/i);
|
||||
});
|
||||
|
||||
it("distinguishes member integration (off-switch exempt) from promotion (gated)", () => {
|
||||
expect(nodeHelpFor("branch-group-member-integration")!.summary).toMatch(/even when global auto-merge is off/i);
|
||||
expect(nodeHelpFor("branch-group-promotion")!.summary).toMatch(/[Gg]ated by group\/global auto-merge/);
|
||||
});
|
||||
|
||||
it("merge gate documents its auto-on / auto-off routing", () => {
|
||||
const help = nodeHelpFor("merge-gate")!;
|
||||
expect(help.edges).toMatch(/auto-on/);
|
||||
expect(help.edges).toMatch(/auto-off/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveNodeKind / nodeHelpForData", () => {
|
||||
function data(kind: WorkflowFlowNodeData["kind"], irKind?: string): WorkflowFlowNodeData {
|
||||
return { kind, label: kind, ...(irKind ? { irKind } : {}) };
|
||||
}
|
||||
|
||||
it("prefers the preserved IR kind over the collapsed editor kind", () => {
|
||||
// A branch-group-promotion node renders as a generic "merge" shape but
|
||||
// preserves its IR kind so the help stays specific.
|
||||
const d = data("merge", "branch-group-promotion");
|
||||
expect(effectiveNodeKind(d)).toBe("branch-group-promotion");
|
||||
expect(nodeHelpForData(d)!.title).toBe("Branch group · promotion");
|
||||
});
|
||||
|
||||
it("falls back to the editor kind when no IR kind is preserved", () => {
|
||||
const d = data("merge");
|
||||
expect(effectiveNodeKind(d)).toBe("merge");
|
||||
expect(nodeHelpForData(d)!.title).toBe("Merge boundary");
|
||||
});
|
||||
});
|
||||
306
packages/dashboard/app/components/nodes/node-help.ts
Normal file
306
packages/dashboard/app/components/nodes/node-help.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "./WorkflowNodeTypes";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowEditor 2026-06-21-10:00:
|
||||
The node detail pane must teach, not just edit. Every workflow node — including the engine-managed graph-only policy nodes (merge gate, branch-group member integration / promotion, PR nodes, recovery/retry) — needs an in-editor Help section describing what it does, how to configure it, and its inputs/outputs/edges. This was prompted by a user unable to tell what "branch-group-member-integration", "branch-group-promotion", and "merge gate" meant in the editor.
|
||||
|
||||
Help is keyed by the node's EFFECTIVE kind: the preserved original IR kind (`data.irKind`) when present, else the editor kind (`data.kind`). Graph-only IR kinds collapse to merge/gate/hold editor shapes via GRAPH_ONLY_EDITOR_KIND, so without the preserved kind the branch-group/PR/merge nodes would all read as a generic "merge"/"gate".
|
||||
|
||||
Per-node body text is English reference documentation (analogous to node-summary's raw, untranslated config values); only the repeated structural section labels are routed through i18n by the inspector. Keep this content in sync when node config fields or edge routing change.
|
||||
*/
|
||||
|
||||
/** A node's effective kind for help lookup: the preserved original IR kind when
|
||||
* the editor collapsed a graph-only policy node onto a generic shape, else the
|
||||
* editor kind. Mirrors workflow-flow-mapping's `preservedIrKind`. */
|
||||
export function effectiveNodeKind(data: WorkflowFlowNodeData): string {
|
||||
return typeof data.irKind === "string" ? data.irKind : data.kind;
|
||||
}
|
||||
|
||||
export interface NodeHelp {
|
||||
/** Human title for the node kind (the inspector heading reuses this). */
|
||||
title: string;
|
||||
/** One- to two-sentence description of what the node does. */
|
||||
summary: string;
|
||||
/** How to configure it. Omitted for structural nodes with no config. */
|
||||
configure?: string;
|
||||
/** What arrives at the node (incoming edges / available context). */
|
||||
inputs: string;
|
||||
/** What the node produces / passes downstream. */
|
||||
outputs: string;
|
||||
/** Outgoing edges and the conditions/outcomes that route them. */
|
||||
edges: string;
|
||||
/** Engine-managed policy node: surfaced read-only, not hand-authored. The
|
||||
* inspector shows an "Engine-managed" badge for these. */
|
||||
graphOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Help content keyed by effective node kind. Covers every editor kind plus the
|
||||
* graph-only IR kinds (merge lifecycle, branch groups, PR mode, recovery). */
|
||||
const NODE_HELP: Record<string, NodeHelp> = {
|
||||
// ── Editor (user-authored) kinds ──────────────────────────────────────────
|
||||
start: {
|
||||
title: "Start",
|
||||
summary: "Marks where a task enters the workflow. Every workflow has exactly one start node.",
|
||||
configure:
|
||||
"Set the Entry column to choose which board column a task lands in when it enters (v2 workflows). Leave on Auto to use the first column.",
|
||||
inputs: "None — this is the entry point.",
|
||||
outputs: "Hands the task to the first downstream node.",
|
||||
edges: "One outgoing edge (success). No incoming edges.",
|
||||
},
|
||||
end: {
|
||||
title: "End",
|
||||
summary: "A terminal state. A task that reaches an end node is finished on that path.",
|
||||
inputs: "One or more incoming edges.",
|
||||
outputs: "None — the task stops here.",
|
||||
edges: "Incoming edges only; no outgoing edges.",
|
||||
},
|
||||
prompt: {
|
||||
title: "Prompt (agent step)",
|
||||
summary:
|
||||
"Runs a unit of work against the task — an AI model, a named agent, a skill, or a CLI command. The workhorse node for executing, planning, and reviewing.",
|
||||
configure:
|
||||
"Write the Prompt, then pick an Executor (model, agent, skill, CLI, or CLI-agent) and its options (model, agent, skill, or command). Optionally set Gate mode (advisory vs blocking), Max retries, Auto-approve, or Wait for user input.",
|
||||
inputs: "The task plus any prior step output and context.",
|
||||
outputs: "The step's result, passed downstream; may record a gate verdict.",
|
||||
edges: "success / failure outgoing edges. As a blocking gate it can stop the task on failure.",
|
||||
},
|
||||
script: {
|
||||
title: "Script",
|
||||
summary: "Runs a named project script (defined in project settings) as a workflow step.",
|
||||
configure:
|
||||
"Set Script name to a script from project settings. Set Gate mode to choose whether a non-zero exit blocks the task. The node prompt is passed to the script via FUSION_NODE_PROMPT.",
|
||||
inputs: "The task; the node prompt via FUSION_NODE_PROMPT.",
|
||||
outputs: "The script's exit status and output.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
gate: {
|
||||
title: "Gate",
|
||||
summary:
|
||||
"A decision checkpoint that evaluates a prompt and routes the task by its verdict, optionally blocking progress.",
|
||||
configure:
|
||||
"Write the gate Prompt. Set Gate mode to Advisory (records a verdict but never blocks) or Gate (blocks the task on failure).",
|
||||
inputs: "The task plus prior context.",
|
||||
outputs: "A pass/fail (or outcome) verdict.",
|
||||
edges: "success / failure; a blocking gate holds the task on failure.",
|
||||
},
|
||||
merge: {
|
||||
title: "Merge boundary",
|
||||
summary:
|
||||
"A marker separating pre-merge from post-merge steps. Steps before it run before the branch merges; steps after run after.",
|
||||
configure: "No fields to set — placement is what matters. Position it where the merge happens in your pipeline.",
|
||||
inputs: "The task after upstream steps complete.",
|
||||
outputs: "Passes the task to post-merge steps.",
|
||||
edges: "One outgoing edge (success).",
|
||||
},
|
||||
hold: {
|
||||
title: "Hold",
|
||||
summary:
|
||||
"Pauses the task until a release condition is met — a manual promote, a timer, downstream capacity, a dependency, or an external event.",
|
||||
configure:
|
||||
"Pick a Release condition: Manual promote, Timer, Downstream capacity, Dependency complete, or External event.",
|
||||
inputs: "The task arriving from upstream.",
|
||||
outputs: "Releases the task downstream once the condition is satisfied.",
|
||||
edges: "One outgoing edge (success), taken once released.",
|
||||
},
|
||||
split: {
|
||||
title: "Split (parallel branch)",
|
||||
summary:
|
||||
"Fans the task out into multiple branches that run concurrently. Pair with a Join downstream to recombine them.",
|
||||
configure: "No fields to set — connect multiple outgoing edges; each becomes a parallel branch.",
|
||||
inputs: "A single task path.",
|
||||
outputs: "Multiple concurrent branches.",
|
||||
edges: "Multiple outgoing edges, one per branch. Recombine with a Join.",
|
||||
},
|
||||
join: {
|
||||
title: "Join",
|
||||
summary: "Waits for parallel branches (from a Split) and recombines them according to a join policy.",
|
||||
configure:
|
||||
"Set Join mode: All branches, Any branch, or Quorum (n) with a count. Set On branch failure to Collect (wait for all) or Fail-fast (cancel siblings).",
|
||||
inputs: "Multiple parallel branches.",
|
||||
outputs: "A single resumed path once the join policy is satisfied.",
|
||||
edges: "One outgoing edge (success), taken when the join condition is met.",
|
||||
},
|
||||
foreach: {
|
||||
title: "For-each",
|
||||
summary:
|
||||
"Runs a template of steps once per item (e.g. per parsed step), sequentially or in parallel. Renders as a group you drop step nodes into.",
|
||||
configure:
|
||||
"Set Mode (sequential/parallel), Isolation (shared or per-step worktree), Concurrency (parallel only), and Max rework cycles (the bound on rework loop-backs). Drop a step-execute node inside.",
|
||||
inputs: "A collection of items (e.g. parsed steps) plus the task.",
|
||||
outputs: "Aggregated per-item results.",
|
||||
edges:
|
||||
"success once all iterations finish. Internal rework edges loop back within a step instance, bounded by Max rework cycles.",
|
||||
},
|
||||
loop: {
|
||||
title: "Loop",
|
||||
summary:
|
||||
"Repeats a template of steps until an exit condition is met or a cap is hit. Renders as a group you drop loop steps into.",
|
||||
configure:
|
||||
"Set the Exit condition (output contains / output matches regex) and its value or pattern, an optional Watch node id, Max iterations, and Timeout (ms).",
|
||||
inputs: "The task plus the loop body steps.",
|
||||
outputs: "The final iteration's result.",
|
||||
edges: "One outgoing edge (success) on exit. Exits on condition match, max iterations, or timeout.",
|
||||
},
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is a container whose body runs once when the task enables it and is skipped otherwise. Enable state is the per-task `enabledWorkflowSteps` facet, seeded from the group's `defaultOn`.
|
||||
"optional-group": {
|
||||
title: "Optional group",
|
||||
summary:
|
||||
"Holds a group of steps that run only when the task has this group enabled. Enabled tasks run the group's steps once at this position; disabled tasks pass straight through. Renders as a group you drop step nodes into.",
|
||||
configure:
|
||||
"Set the group Name and whether it is Enabled by default for new tasks (defaultOn). A task can override the default per-task. Drop the optional steps inside the region.",
|
||||
inputs: "The task arriving from upstream, plus prior context.",
|
||||
outputs: "The group's result when enabled; an unchanged pass-through when disabled.",
|
||||
edges:
|
||||
"success once the group finishes (or is skipped). A template failure inside an enabled group routes the group's failure edge.",
|
||||
},
|
||||
"step-review": {
|
||||
title: "Step review",
|
||||
summary:
|
||||
"An AI review gate that emits a verdict (approve / revise / rethink / unavailable) used to route the task — typically back for rework or forward on approval.",
|
||||
configure:
|
||||
"Set Review type (plan or code) and an optional Review model. Route each outgoing edge by verdict; mark a loop-back edge as Rework.",
|
||||
inputs: "The artifact or step output to review.",
|
||||
outputs: "A verdict: approve, revise, rethink, or unavailable.",
|
||||
edges:
|
||||
"Verdict edges (outcome:approve / revise / rethink / unavailable). A rework edge loops back, bounded by Max rework cycles.",
|
||||
},
|
||||
"parse-steps": {
|
||||
title: "Parse steps",
|
||||
summary:
|
||||
"Parses a task artifact (e.g. PROMPT.md) into discrete steps a downstream for-each can iterate over.",
|
||||
configure: "Pick the Artifact to parse (e.g. PROMPT.md) and the Parser (e.g. step-headings, plus any plugin parsers).",
|
||||
inputs: "A task artifact or document.",
|
||||
outputs: "A list of parsed steps for a downstream for-each.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
code: {
|
||||
title: "Code",
|
||||
summary:
|
||||
"Runs a sandboxed TypeScript snippet as a workflow step — for lightweight transforms, routing, or computed values.",
|
||||
configure: "Write the TypeScript Source and an optional Timeout (ms). Syntax is validated at save.",
|
||||
inputs: "Task context available to the snippet.",
|
||||
outputs: "The snippet's return value.",
|
||||
edges: "success / failure.",
|
||||
},
|
||||
notify: {
|
||||
title: "Notify",
|
||||
summary:
|
||||
"Emits a notification event (and optional title/message) without changing the task's path — for pings on state changes.",
|
||||
configure:
|
||||
"Pick an Event type (or a Custom event) and optional Title/Message. Templates may use {{taskTitle}}, {{taskId}}, {{workflowName}}, and {{context:key}}.",
|
||||
inputs: "The task at this point in the flow.",
|
||||
outputs: "A notification event; the task continues unchanged.",
|
||||
edges: "One outgoing edge (success); the node is pass-through.",
|
||||
},
|
||||
|
||||
// ── Graph-only (engine-managed) IR kinds ──────────────────────────────────
|
||||
"merge-gate": {
|
||||
title: "Auto-merge gate",
|
||||
summary:
|
||||
"Checks whether the task is ready to auto-merge: a live PR/merge entity exists, auto-merge is opted in, and the entity is merge-ready (approved, checks green, mergeable clean).",
|
||||
configure: "Engine-managed checkpoint — not hand-edited. Governed by the project and task auto-merge settings.",
|
||||
inputs: "An approved task with its PR/merge entity.",
|
||||
outputs: "An auto-on / auto-off decision.",
|
||||
edges:
|
||||
"outcome:auto-on → branch-group member integration; auto-off → parks at the manual merge hold for a human.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"merge-attempt": {
|
||||
title: "Merge attempt",
|
||||
summary:
|
||||
"Performs the actual merge of the task's branch toward the integration/default branch (squash by project default), with conflict and post-merge audit handling.",
|
||||
configure: "Engine-managed — not hand-edited. Follows the project's merge strategy and audit settings.",
|
||||
inputs: "A promotion-ready branch.",
|
||||
outputs: "A merged branch, or a conflict requiring manual resolution.",
|
||||
edges: "success → end; conflict/failure → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"manual-merge-hold": {
|
||||
title: "Manual merge hold",
|
||||
summary:
|
||||
"Parks the task in review for a human to merge when auto-merge is off or a step needs manual resolution. While auto-merge is off, in-review is terminal until a person merges.",
|
||||
configure: "Engine-managed park state — not hand-edited.",
|
||||
inputs: "A task blocked from auto-merge, or one with a merge conflict.",
|
||||
outputs: "A human-resolved merge that resumes the flow.",
|
||||
edges: "On manual resolution, loops back into integration/merge (rework).",
|
||||
graphOnly: true,
|
||||
},
|
||||
"retry-backoff": {
|
||||
title: "Retry backoff",
|
||||
summary: "Waits a backoff interval before retrying a failed step, bounded by a retry budget.",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "A failed step eligible for retry.",
|
||||
outputs: "A delayed retry of the step.",
|
||||
edges: "Loops back to the step until the retry budget is exhausted.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"recovery-router": {
|
||||
title: "Recovery router",
|
||||
summary:
|
||||
"A self-healing decision point that routes a stuck or interrupted task onto the right recovery path (retry, rebound, or escalate).",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "A task in an anomalous or interrupted state.",
|
||||
outputs: "A recovery-route decision.",
|
||||
edges: "Branches to retry, rebound, or manual paths by recovery outcome.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"branch-group-member-integration": {
|
||||
title: "Branch group · member integration",
|
||||
summary:
|
||||
"For a task in a shared branch group, integrates this member's work onto the group's shared branch. A soft pre-integration step that runs even when global auto-merge is off (it only assembles the group branch).",
|
||||
configure: "Engine-managed — not hand-edited. Active only for shared-branch-group members.",
|
||||
inputs: "An approved group-member task and the group's shared branch.",
|
||||
outputs: "The member's work landed on the shared branch.",
|
||||
edges: "success → branch group promotion; manual-required → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"branch-group-promotion": {
|
||||
title: "Branch group · promotion",
|
||||
summary:
|
||||
"Once all members have landed on the shared branch, carries the complete group forward — merging the group branch toward the integration branch and creating-or-reusing the group's single managed PR. Idempotent: re-running never creates a second PR. Gated by group/global auto-merge.",
|
||||
configure: "Engine-managed — not hand-edited. Runs once the group is complete and auto-merge is eligible.",
|
||||
inputs: "A complete shared branch group (all members landed).",
|
||||
outputs: "The group promoted toward the integration branch, plus its single managed PR.",
|
||||
edges: "success → merge attempt; manual-required → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-create": {
|
||||
title: "PR create",
|
||||
summary: "Creates (or reuses) the pull request for the task in pull-request merge mode.",
|
||||
configure: "Engine-managed — not hand-edited. Active in pull-request merge mode.",
|
||||
inputs: "A task branch ready for review.",
|
||||
outputs: "An open PR entity (created or reused).",
|
||||
edges: "success → the PR review/merge path.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-respond": {
|
||||
title: "PR respond",
|
||||
summary:
|
||||
"Responds to PR review feedback — addressing comments and pushing follow-up commits — during the PR review cycle.",
|
||||
configure: "Engine-managed — not hand-edited.",
|
||||
inputs: "PR review comments and threads.",
|
||||
outputs: "Replies and follow-up commits on the PR.",
|
||||
edges: "Loops within the PR review cycle until feedback is resolved.",
|
||||
graphOnly: true,
|
||||
},
|
||||
"pr-merge": {
|
||||
title: "PR merge",
|
||||
summary: "Merges the pull request once it is approved and all checks pass, in pull-request mode.",
|
||||
configure: "Engine-managed — not hand-edited. Governed by auto-merge readiness.",
|
||||
inputs: "An approved, green PR.",
|
||||
outputs: "A merged PR.",
|
||||
edges: "success → end; blocked → manual merge hold.",
|
||||
graphOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve help for a node by its effective kind, or null when none is known
|
||||
* (callers skip rendering the Help section). */
|
||||
export function nodeHelpFor(kind: WorkflowEditorNodeKind | string): NodeHelp | null {
|
||||
return NODE_HELP[kind] ?? null;
|
||||
}
|
||||
|
||||
/** Resolve help for a flow node, honoring the preserved IR kind. */
|
||||
export function nodeHelpForData(data: WorkflowFlowNodeData): NodeHelp | null {
|
||||
return nodeHelpFor(effectiveNodeKind(data));
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowOptionalStep,
|
||||
} from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
|
||||
@@ -38,6 +37,16 @@ interface WorkflowLoopConfig {
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` is a third container kind alongside `foreach`/`loop`. It carries `defaultOn`/`name` plus a `template:{nodes,edges}` subgraph authored inline as React Flow `parentId` children (reusing the `foreachChildFlowId` namespacing). It is special-cased everywhere foreach/loop are: group-template detection, child reassembly in flowToIr, intra-template edge folding, cascade delete, and condition-editability. Single-pass, no rework/iteration — but the editor mapping treats its template identically to foreach/loop.
|
||||
*/
|
||||
interface WorkflowOptionalGroupConfig {
|
||||
defaultOn?: boolean;
|
||||
name?: string;
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
|
||||
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
|
||||
// can migrate; callers should prefer WorkflowFieldDefinition directly.
|
||||
@@ -170,6 +179,7 @@ const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
@@ -263,10 +273,17 @@ function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined {
|
||||
return cfg as WorkflowLoopConfig;
|
||||
}
|
||||
|
||||
function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfig | undefined {
|
||||
if (node.kind !== "optional-group") return undefined;
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
||||
if (!cfg || !cfg.template) return undefined;
|
||||
return cfg as WorkflowOptionalGroupConfig;
|
||||
}
|
||||
|
||||
function groupTemplateConfigOf(
|
||||
node: WorkflowIrNode,
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node);
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | WorkflowOptionalGroupConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
|
||||
}
|
||||
|
||||
/** CSS class for an edge given its condition + rework kind. Rework takes
|
||||
@@ -435,7 +452,6 @@ export function flowToIr(
|
||||
columns?: WorkflowIrColumn[],
|
||||
fields?: WorkflowFieldDefinition[],
|
||||
settings?: WorkflowSettingDefinition[],
|
||||
optionalSteps?: WorkflowOptionalStep[],
|
||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
||||
// Partition by parentId: foreach group children reassemble into that group's
|
||||
@@ -451,19 +467,25 @@ export function flowToIr(
|
||||
}
|
||||
}
|
||||
const groupIds = new Set(
|
||||
topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id),
|
||||
topNodes
|
||||
.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group")
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const hasFields = Array.isArray(fields) && fields.length > 0;
|
||||
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
||||
const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0;
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// Optional steps must round-trip through the node editor without data loss, yet
|
||||
// must never upgrade a legacy v1 graph. Fields, settings, and optional steps are
|
||||
// v2-only declarations: a workflow with any of them but no custom columns still
|
||||
// serializes as v2 (with the synthesized default columns). Empty/absent → not a
|
||||
// v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs).
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
// The editor no longer AUTHORS legacy `optionalSteps` declarations — optional
|
||||
// steps are graph-native `optional-group` nodes carried through the normal
|
||||
// node/edge mapping. Fields and settings remain v2-only declarations: a workflow
|
||||
// with either but no custom columns still serializes as v2 (with the synthesized
|
||||
// default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy).
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: a container/group node
|
||||
// (foreach/loop/optional-group) is a v2-ONLY kind — its presence must force v2,
|
||||
// or an inserted optional-group on an otherwise-plain workflow would serialize
|
||||
// as v1 and fail parse (validateOptionalGroup runs only on v2). (Code review:
|
||||
// CodeRabbit — corroborated by the pre-merge correctness review's residual risk.)
|
||||
const v2 =
|
||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps;
|
||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || groupIds.size > 0;
|
||||
const layout: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
/** Project one flow node (top-level or template child) into an IR node. */
|
||||
@@ -477,8 +499,19 @@ export function flowToIr(
|
||||
}
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") {
|
||||
if (
|
||||
data.kind === "foreach" ||
|
||||
data.kind === "loop" ||
|
||||
data.kind === "optional-group" ||
|
||||
originalKind === "retry-backoff"
|
||||
) {
|
||||
if (
|
||||
originalKind &&
|
||||
originalKind !== "foreach" &&
|
||||
originalKind !== "loop" &&
|
||||
originalKind !== "optional-group" &&
|
||||
originalKind !== "retry-backoff"
|
||||
) {
|
||||
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
|
||||
}
|
||||
// Reassemble the template from this group's children.
|
||||
@@ -564,12 +597,6 @@ export function flowToIr(
|
||||
render: s.render ? { ...s.render } : undefined,
|
||||
}));
|
||||
}
|
||||
if (hasOptionalSteps) {
|
||||
// Optional-step DECLARATIONS round-trip through the editor opaquely (they are
|
||||
// not graph nodes; the resolver + server validator are the source of truth).
|
||||
// Omitted entirely when empty so legacy graphs stay byte-identical (R6).
|
||||
(ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o }));
|
||||
}
|
||||
return { ir, layout };
|
||||
}
|
||||
|
||||
@@ -608,9 +635,10 @@ function isProtectedFromDelete(node: FlowNode<WorkflowFlowNodeData>): boolean {
|
||||
* Delete the requested node and/or edge ids from the flow graph, applying R6's
|
||||
* cascade rules:
|
||||
* - Deleting a node removes ALL edges incident to it (no auto-bridging).
|
||||
* - Deleting a `foreach`/`loop` group node also deletes its template children
|
||||
* (nodes with `parentId === groupId`) and every edge incident to those
|
||||
* children (React Flow does not cascade parents — handled explicitly).
|
||||
* - Deleting a `foreach`/`loop`/`optional-group` group node also deletes its
|
||||
* template children (nodes with `parentId === groupId`) and every edge
|
||||
* incident to those children (React Flow does not cascade parents — handled
|
||||
* explicitly).
|
||||
* - `start`/`end` nodes and column band nodes are never deleted: they are
|
||||
* filtered out of the requested ids up front (and their incident edges are
|
||||
* therefore preserved).
|
||||
@@ -633,7 +661,7 @@ export function cascadeDelete(
|
||||
const node = nodeById.get(id);
|
||||
if (!node || isProtectedFromDelete(node)) continue;
|
||||
deleteNodeIds.add(id);
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop") {
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop" || node.data.kind === "optional-group") {
|
||||
for (const child of nodes) {
|
||||
if (child.parentId === id) deleteNodeIds.add(child.id);
|
||||
}
|
||||
@@ -660,7 +688,7 @@ export function cascadeDelete(
|
||||
|
||||
/** Editor node kinds whose edges expose a success/failure condition select
|
||||
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop"]);
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop", "optional-group"]);
|
||||
|
||||
/** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
|
||||
* - "verdicts": step-review verdict select + rework checkbox (existing);
|
||||
@@ -981,15 +1009,11 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[]
|
||||
}));
|
||||
}
|
||||
|
||||
/** Extract the editor's working optional-step declaration list from a definition.
|
||||
* v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata
|
||||
* (name/icon/phase) is NOT carried here — it is resolved from the step-template
|
||||
* catalog at render time so the resolver stays the single source of truth. */
|
||||
export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] {
|
||||
const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] };
|
||||
if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return [];
|
||||
return ir.optionalSteps.map((o) => ({ ...o }));
|
||||
}
|
||||
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||
`optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor)
|
||||
is removed. Optional steps are graph-native `optional-group` nodes now; the
|
||||
editor reads/writes them through the normal node/edge mapping, and the per-task
|
||||
toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */
|
||||
|
||||
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
|
||||
export function emptyWorkflowIr(name: string): WorkflowIr {
|
||||
@@ -1218,6 +1242,52 @@ export function insertFragment(
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:30:
|
||||
"Insert as optional group" (U5/R5) wraps a single projected add-on node in an `optional-group`
|
||||
container so an author can drop e.g. "Security Audit (optional)" in one action. The wrapper is built
|
||||
as a v1-shaped fragment IR (start → optional-group → end) and handed to the EXISTING `insertFragment`
|
||||
path, which strips start/end, remaps the group id, and expands the group's `config.template` child as a
|
||||
`parentId` flow node — so no new insertion engine is needed and ids never collide across repeated inserts.
|
||||
KTD-5: the add-on catalog stays FLAT; projection to a node is done by the caller via `stepTemplateToNode`,
|
||||
and only the wrap-in-container step lives here.
|
||||
*/
|
||||
|
||||
/** Wrap a single projected add-on node in an `optional-group` fragment IR ready
|
||||
* for `insertFragment`. `defaultOn` seeds the group's per-task enable default
|
||||
* (from the source template's `defaultOn`). The group's `name` labels it in the
|
||||
* editor and the per-task toggle surfaces. The inner node uses a template-local
|
||||
* id; `insertFragment` remaps the group id and namespaces the child, so this id
|
||||
* need only be unique WITHIN the template. */
|
||||
export function optionalGroupFragmentIr(
|
||||
addOnNode: { kind: WorkflowIrNodeKind; config?: Record<string, unknown> },
|
||||
opts: { name?: string; defaultOn?: boolean },
|
||||
): WorkflowIr {
|
||||
const innerId = "addon";
|
||||
const optionalGroupId = "optional-group";
|
||||
const config: WorkflowOptionalGroupConfig & Record<string, unknown> = {
|
||||
defaultOn: opts.defaultOn ?? false,
|
||||
template: {
|
||||
nodes: [{ id: innerId, kind: addOnNode.kind, config: addOnNode.config }],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
if (opts.name) config.name = opts.name;
|
||||
return {
|
||||
version: "v1",
|
||||
name: opts.name ?? "optional-group",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: optionalGroupId, kind: "optional-group", config },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: optionalGroupId, condition: "success" },
|
||||
{ from: optionalGroupId, to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remap a template group's internal node ids + edges to fresh ids. Returns a
|
||||
* new template object; the original is untouched. Template-local ids are scoped
|
||||
* to the template, so a fresh local id space suffices (and keeps config compact
|
||||
|
||||
@@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(url).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("uses unsaved request ntfy config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(url).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
@@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-message-topic",
|
||||
ntfyBaseUrl: "https://ntfy.message.example//",
|
||||
ntfyAccessToken: "message-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:agent-to-user",
|
||||
expect.objectContaining({
|
||||
event: "message:agent-to-user",
|
||||
metadata: expect.objectContaining({
|
||||
fromId: "system",
|
||||
toId: "user",
|
||||
preview: "Fusion test message notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "New message from Fusion",
|
||||
Priority: "high",
|
||||
Authorization: "Bearer message-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion → you: Fusion test message notification");
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-room-topic",
|
||||
ntfyBaseUrl: "https://ntfy.room.example//",
|
||||
ntfyAccessToken: "room-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({
|
||||
event: "message:room",
|
||||
metadata: expect.objectContaining({
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "#Test Room — Fusion",
|
||||
Priority: "default",
|
||||
Authorization: "Bearer room-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification");
|
||||
});
|
||||
|
||||
it("ntfy provider uses config override for baseUrl", async () => {
|
||||
@@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider sends with unsaved config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("ntfy provider ignores blank request baseUrl and token overrides", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
ntfyAccessToken: " ",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
import {
|
||||
buildSessionSkillContextSync,
|
||||
createFnAgent as engineCreateFnAgent,
|
||||
getActiveNotificationService,
|
||||
probeWorktrunk,
|
||||
resolveWorktrunkBinary,
|
||||
} from "@fusion/engine";
|
||||
@@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
* Returns the user's global pi extension settings from ~/.pi/agent/settings.json.
|
||||
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
|
||||
*/
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest("ntfy server URL cannot be empty");
|
||||
}
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("ntfy server URL must use http:// or https://");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const getOwnValue = (source: Record<string, unknown>, key: string): unknown => (
|
||||
Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined
|
||||
);
|
||||
|
||||
const getRequestNtfyValue = (body: Record<string, unknown>, config: Record<string, unknown>, key: string): unknown => {
|
||||
const configValue = getOwnValue(config, key);
|
||||
return configValue !== undefined ? configValue : getOwnValue(body, key);
|
||||
};
|
||||
|
||||
type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room";
|
||||
|
||||
function resolveEffectiveNtfyTestConfig(
|
||||
settings: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
config: Record<string, unknown> = {},
|
||||
): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } {
|
||||
/*
|
||||
FNXC:Notifications 2026-06-23-08:34:
|
||||
Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens.
|
||||
|
||||
FNXC:Notifications 2026-06-23-10:21:
|
||||
Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state.
|
||||
*/
|
||||
const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled");
|
||||
if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") {
|
||||
throw badRequest("ntfy enabled must be a boolean");
|
||||
}
|
||||
const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true;
|
||||
if (!ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic");
|
||||
if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") {
|
||||
throw badRequest("ntfy topic must be a string");
|
||||
}
|
||||
const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic;
|
||||
if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl");
|
||||
if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim()
|
||||
? normalizeNtfyBaseUrl(baseUrlOverride, "request")
|
||||
: undefined;
|
||||
const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
|
||||
const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken");
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
topic,
|
||||
ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh",
|
||||
ntfyAccessToken: requestToken ?? storedToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNtfyTestNotification(
|
||||
options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType },
|
||||
): Promise<void> {
|
||||
const contentByEvent: Record<NtfyTestMessageEventType | "default", { title: string; message: string; priority: "default" | "high" }> = {
|
||||
default: {
|
||||
title: "Fusion test notification",
|
||||
message: "Fusion test notification — your notifications are working!",
|
||||
priority: "default",
|
||||
},
|
||||
"message:agent-to-user": {
|
||||
title: "New message from Fusion",
|
||||
message: "Fusion → you: Fusion test message notification",
|
||||
priority: "high",
|
||||
},
|
||||
"message:agent-to-agent": {
|
||||
title: "Fusion → recipient",
|
||||
message: "Fusion messaged recipient: Fusion test message notification",
|
||||
priority: "default",
|
||||
},
|
||||
"message:room": {
|
||||
title: "#Test Room — Fusion",
|
||||
message: "Fusion in #Test Room: Fusion test room notification",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
const content = contentByEvent[options.messageEventType ?? "default"];
|
||||
const headers: Record<string, string> = {
|
||||
Title: content.title,
|
||||
Priority: content.priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
if (options.ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${options.ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: content.message,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const configValue = body.config;
|
||||
if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) {
|
||||
throw badRequest("config must be an object when provided");
|
||||
}
|
||||
const config = (configValue ?? {}) as Record<string, unknown>;
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
// Validate ntfy is enabled
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
// Validate topic exists and matches required format
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = req.body?.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = req.body?.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification(configForTest);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
@@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
});
|
||||
|
||||
router.post("/settings/test-notification", async (req, res) => {
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const providerId = body.providerId;
|
||||
@@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
if (providerId === "ntfy") {
|
||||
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
|
||||
if (requestedMessageEventType !== undefined) {
|
||||
if (
|
||||
requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
|
||||
const notificationService = getActiveNotificationService();
|
||||
if (!notificationService) {
|
||||
throw new ApiError(502, "Notification service is not active");
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = `test-${crypto.randomUUID()}`;
|
||||
if (requestedMessageEventType === "message:room") {
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderAgentId: "system",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
type: "room-assistant",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
fromId: "system",
|
||||
fromType: "agent",
|
||||
toId: "user",
|
||||
toType: "user",
|
||||
type: messageType,
|
||||
preview: "Fusion test message notification",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ApiError(502, `Failed to dispatch message notification: ${message}`);
|
||||
}
|
||||
if (
|
||||
requestedMessageEventType !== undefined
|
||||
&& requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification({
|
||||
...configForTest,
|
||||
messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
66
packages/engine/src/__tests__/_workspace-fixture.ts
Normal file
66
packages/engine/src/__tests__/_workspace-fixture.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-12:00:
|
||||
Shared REAL two-repo git fixture for workspace-mode engine tests (U1 + U2 + later phases). The foundation's executor-workspace test self-mocked the functions under test, which proves nothing; this harness instead builds genuine on-disk git repos under a NON-git workspace root so that any leaked rootDir git preflight actually fails. U2 and later units import `createWorkspaceFixture` directly — keep it dependency-light (only node:child_process + node:fs + saveWorkspaceConfig).
|
||||
|
||||
A workspace root is a plain directory (NOT a git repo) containing N sub-repos. Each sub-repo is a real git repo with an initial commit on a default branch. `<root>/.fusion/workspace.json` lists the sub-repo relative paths so `loadWorkspaceConfig(root)` returns a populated config — the exact signal `this.workspaceConfig` keys off in the executor.
|
||||
*/
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { saveWorkspaceConfig } from "@fusion/core";
|
||||
|
||||
export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
/** Initialize a real git repo at `repoDir` with one commit on `defaultBranch`. */
|
||||
export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): void {
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
git(repoDir, `git init -b ${defaultBranch}`);
|
||||
git(repoDir, 'git config user.email "test@example.com"');
|
||||
git(repoDir, 'git config user.name "Test"');
|
||||
writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8");
|
||||
git(repoDir, "git add README.md");
|
||||
git(repoDir, "git commit -m 'init'");
|
||||
}
|
||||
|
||||
export interface WorkspaceFixture {
|
||||
/** Absolute path to the non-git workspace root. */
|
||||
rootDir: string;
|
||||
/** Relative sub-repo paths (workspace.json `repos`). */
|
||||
repos: string[];
|
||||
/** Absolute path to a sub-repo by relative name. */
|
||||
repoPath(rel: string): string;
|
||||
/** Run a git command inside a sub-repo. */
|
||||
git(rel: string, command: string): string;
|
||||
/** Remove all on-disk fixture state. */
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a real two-repo (by default) workspace fixture on disk.
|
||||
* - `rootDir` is a plain non-git directory.
|
||||
* - Each `repos[i]` is a real git repo with an initial commit.
|
||||
* - `<root>/.fusion/workspace.json` is written so loadWorkspaceConfig() resolves.
|
||||
*/
|
||||
export async function createWorkspaceFixture(
|
||||
repos: string[] = ["repo-a", "repo-b"],
|
||||
defaultBranch = "main",
|
||||
): Promise<WorkspaceFixture> {
|
||||
const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-workspace-"));
|
||||
for (const rel of repos) {
|
||||
initRepoWithCommit(path.join(rootDir, rel), defaultBranch);
|
||||
}
|
||||
await saveWorkspaceConfig(rootDir, { repos });
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
repos,
|
||||
repoPath: (rel: string) => path.join(rootDir, rel),
|
||||
git: (rel: string, command: string) => git(path.join(rootDir, rel), command),
|
||||
cleanup: () => rmSync(rootDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeSessionRegistry,
|
||||
reconcileSelfOwnedActiveSessionForRemoval,
|
||||
ActiveSessionPathHeldByForeignTaskError,
|
||||
} from "../active-session-registry.js";
|
||||
|
||||
describe("activeSessionRegistry", () => {
|
||||
@@ -28,15 +29,27 @@ describe("activeSessionRegistry", () => {
|
||||
expect(activeSessionRegistry.lookupByPath("/tmp/missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites duplicate registration with warning", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds):
|
||||
// registerPath must NOT silently clobber an entry held by a DIFFERENT task (that was the
|
||||
// cross-phase clobber bug: a merging task's land lease overwriting an executing task's
|
||||
// acquire lease on a shared sub-repo). A foreign-task overwrite now THROWS; the existing
|
||||
// foreign holder is preserved.
|
||||
it("rejects a foreign-task overwrite (does not clobber the held entry)", () => {
|
||||
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
||||
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" });
|
||||
expect(() =>
|
||||
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }),
|
||||
).toThrow(ActiveSessionPathHeldByForeignTaskError);
|
||||
// The original holder is untouched.
|
||||
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
|
||||
});
|
||||
|
||||
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2");
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
// Same-task re-registration stays idempotent (an executor re-claiming/refreshing its own path).
|
||||
it("allows same-task re-registration (idempotent re-claim)", () => {
|
||||
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
||||
expect(() =>
|
||||
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "step-session", ownerKey: "FN-1#step-session" }),
|
||||
).not.toThrow();
|
||||
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.kind).toBe("step-session");
|
||||
});
|
||||
|
||||
it("reconcileStaleSelfOwned returns no-entry when path is unregistered", () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
Built-in-level execution coverage for U6: the coding workflow now expresses the
|
||||
pre-merge browser-verification step as an `optional-group` (default OFF). This is
|
||||
the dead-toggle / two-task divergence guard at the BUILT-IN level (not just the
|
||||
generic construct): two coding tasks identical except `enabledWorkflowSteps` must
|
||||
diverge — the one including the group id runs the browser-verification prompt node
|
||||
pre-merge; the sibling runs NONE and still reaches review. Real executor runs (not
|
||||
traversal-only) so a mock-masked dead path cannot pass.
|
||||
|
||||
The inner template node id is `browser-verification-step` (distinct from the group
|
||||
id `browser-verification` per the U1 template-node-id collision rule), and its
|
||||
materialized visited id is `browser-verification::browser-verification-step`.
|
||||
*/
|
||||
|
||||
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
const GROUP_ID = "browser-verification";
|
||||
const INNER_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
function codingTask(enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
return {
|
||||
id: "FN-CODING",
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/** Count how many times the inner browser-verification prompt node ran. A prompt
|
||||
* handler keyed on the inner template node id; everything else succeeds. */
|
||||
function makeExecutor(onInnerStep: () => void) {
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") onInnerStep();
|
||||
return { outcome: "success" };
|
||||
});
|
||||
return new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
}
|
||||
|
||||
describe("builtin coding browser-verification optional-group (U6)", () => {
|
||||
it("two-task divergence: the enabled task runs browser-verification pre-merge; the disabled task does not", async () => {
|
||||
// Enabled.
|
||||
let enabledRuns = 0;
|
||||
const enabledResult = await makeExecutor(() => {
|
||||
enabledRuns++;
|
||||
}).run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// Disabled (no enabledWorkflowSteps).
|
||||
let disabledRuns = 0;
|
||||
const disabledResult = await makeExecutor(() => {
|
||||
disabledRuns++;
|
||||
}).run(codingTask(), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// The browser-verification step ran exactly once when enabled, never when off.
|
||||
expect(enabledRuns).toBe(1);
|
||||
expect(disabledRuns).toBe(0);
|
||||
|
||||
// Enabled: the inner template node is visited pre-merge (before review).
|
||||
expect(enabledResult.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
const innerIdx = enabledResult.visitedNodeIds.indexOf(INNER_STEP_VISITED_ID);
|
||||
const reviewIdxEnabled = enabledResult.visitedNodeIds.indexOf("review");
|
||||
const executeIdxEnabled = enabledResult.visitedNodeIds.indexOf("execute");
|
||||
expect(executeIdxEnabled).toBeLessThan(innerIdx);
|
||||
expect(innerIdx).toBeLessThan(reviewIdxEnabled);
|
||||
|
||||
// Disabled: the group node is traversed (bypassed) but its body never runs;
|
||||
// both tasks reach the same downstream review node.
|
||||
expect(disabledResult.visitedNodeIds).toContain(GROUP_ID);
|
||||
expect(disabledResult.visitedNodeIds).not.toContain(INNER_STEP_VISITED_ID);
|
||||
expect(disabledResult.visitedNodeIds).toContain("review");
|
||||
expect(enabledResult.visitedNodeIds).toContain("review");
|
||||
});
|
||||
|
||||
it("a browser-verification failure surfaces as the group's outcome and routes its failure edge to end", async () => {
|
||||
// The inner step fails → the group's failure edge (browser-verification → end)
|
||||
// fires, so review is never reached.
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") return { outcome: "failure", value: "verify-failed" };
|
||||
return { outcome: "success" };
|
||||
});
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
|
||||
const result = await executor.run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure");
|
||||
expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
// The group's only two outgoing edges are `success → review` and
|
||||
// `failure → end`; the inner-step failure routes the failure edge, so review
|
||||
// is skipped. (`end` is a terminal node the executor does not record in
|
||||
// visitedNodeIds, so the routing is asserted via the group's failure outcome
|
||||
// above + review being unreachable here.)
|
||||
expect(result.visitedNodeIds).not.toContain("review");
|
||||
});
|
||||
});
|
||||
@@ -131,7 +131,11 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("graph executor with builtin:coding selection skips the workflow-step seam in fast mode", async () => {
|
||||
// U6: the coding built-in's pre-merge browser-verification optional-group is
|
||||
// default-OFF (the task sets no enabledWorkflowSteps), so it is bypassed — its
|
||||
// group node is visited but its body never runs and runWorkflowSteps is not
|
||||
// called. Fast mode is irrelevant to a bypassed group; the seam is simply gone.
|
||||
it("graph executor with builtin:coding selection bypasses the disabled browser-verification group", async () => {
|
||||
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
||||
const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult());
|
||||
const seams = {
|
||||
@@ -154,7 +158,9 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(runWorkflowSteps).not.toHaveBeenCalled();
|
||||
expect(seams.review).toHaveBeenCalledTimes(1);
|
||||
expect(seams.merge).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
// executor must retry the agent session in place rather than bouncing the
|
||||
// task through todo (and must not fire a failure notification).
|
||||
const { store, task, executor } = makeHarness({ column: "todo" });
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
const executeSpy = vi
|
||||
.spyOn(executor as any, "execute")
|
||||
.mockResolvedValue(undefined);
|
||||
@@ -137,7 +137,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
// and a retry scheduled); the task then changes state before the timer
|
||||
// fires, and the fire-time re-fetch must abort the dispatch.
|
||||
const { store, task, executor } = makeHarness({ column: "todo" });
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||
|
||||
await invokeGraphFailure(executor, task);
|
||||
@@ -164,7 +164,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
status: "failed",
|
||||
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
|
||||
});
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||
|
||||
await invokeGraphFailure(executor, task);
|
||||
@@ -197,7 +197,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
// pause that ended up in todo must stay parked-benign and wait for
|
||||
// explicit resume — auto-resuming it would override the operator's intent.
|
||||
const { store, task, executor } = makeHarness(overrides, provenance);
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||
|
||||
await invokeGraphFailure(executor, task);
|
||||
@@ -217,7 +217,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
column: "todo",
|
||||
graphResumeRetryCount: 2,
|
||||
});
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
const executeSpy = vi
|
||||
.spyOn(executor as any, "execute")
|
||||
.mockResolvedValue(undefined);
|
||||
@@ -246,7 +246,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
status: "failed",
|
||||
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
|
||||
});
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||
|
||||
await invokeGraphFailure(executor, task);
|
||||
|
||||
|
||||
244
packages/engine/src/__tests__/executor-workspace-capture.test.ts
Normal file
244
packages/engine/src/__tests__/executor-workspace-capture.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-23:30:
|
||||
U1 per-repo capture + contamination + worktree-invariant tests (KTD1/KTD2). These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture), so any leaked rootDir git preflight would actually fail and a hand-built `git diff` against an undefined base would blow up.
|
||||
|
||||
Seam choice (FN-5048): we set `(executor as any).workspaceConfig` directly (loadWorkspaceConfig has its own unit) and create real `fusion/<id>` worktrees per sub-repo with real commits — no mock-the-world child_process. Capture is exercised through `captureWorkspaceModifiedFiles` (the helper the post-session path at executor.ts:7900 calls) and verification through `verifyWorktreeInvariants`. Real git is used only where the invariant requires it.
|
||||
|
||||
Coverage:
|
||||
- happy: edits in repo A + B → aggregated modifiedFiles carry repo-prefixed paths from BOTH, each diffed against its own baseCommitSha.
|
||||
- edge: a repo with baseCommitSha undefined → capture still works via resolveDiffBaseRef's merge-base fallback (no `git diff undefined..HEAD`).
|
||||
- contamination: a foreign commit (feat(FN-OTHER):) in a sub-repo's range → the filterFilesToOwnTaskCommits divergence audit fires (task:worktree-contamination-detected) for that repo, and the foreign file is excluded from attributed files.
|
||||
- error: a worktree HEAD off fusion/<id> → verifyWorktreeInvariants returns {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true}); the reason enum is preserved for the :10889 consumer.
|
||||
- regression: a single-repo (non-workspace) task → capture/verify identical to today.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function createStore(overrides: Partial<Record<string, unknown>> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
|
||||
getRunContextFor: vi.fn(),
|
||||
on: emitter.on.bind(emitter),
|
||||
...overrides,
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
}
|
||||
|
||||
function makeTask(id = "FN-WS-1", overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
title: "Workspace task",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
// Capture attribution requires a digit-form task id (`FN-\d+`); the branch-attribution
|
||||
// subject parser only attributes `feat(FN-1001):` style subjects, so the KTD2-era
|
||||
// `FN-WS-1` placeholder would never attribute a commit. Use a real numeric id here.
|
||||
const TASK_ID = "FN-1001";
|
||||
const BRANCH = "fusion/fn-1001";
|
||||
|
||||
/** Configure git identity in a freshly-created worktree (worktrees don't inherit user.* on all platforms). */
|
||||
function configureIdentity(dir: string): void {
|
||||
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a real fusion/<id> worktree to a sub-repo, commit one own-attributed edit
|
||||
* onto that branch, and return { worktreePath, baseCommitSha } for task.workspaceWorktrees.
|
||||
* baseCommitSha is the sub-repo's pre-edit HEAD so the diff range is base..HEAD.
|
||||
*/
|
||||
function addRepoWorktreeWithOwnEdit(
|
||||
fx: WorkspaceFixture,
|
||||
repoRel: string,
|
||||
fileName: string,
|
||||
): { worktreePath: string; baseCommitSha: string } {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
|
||||
configureIdentity(worktreePath);
|
||||
mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true });
|
||||
writeFileSync(path.join(worktreePath, fileName), "// own change\n", "utf-8");
|
||||
execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" });
|
||||
return { worktreePath, baseCommitSha };
|
||||
}
|
||||
|
||||
function workspaceExecutor(fx: WorkspaceFixture, store = createStore()): TaskExecutor {
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
return executor;
|
||||
}
|
||||
|
||||
describeIfGit("U1 KTD1 — per-repo capture aggregates repo-prefixed paths", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("happy: edits in repo A + B are diffed against their own base and repo-prefixed", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task);
|
||||
expect(files).toContain("repo-a/src/a.ts");
|
||||
expect(files).toContain("repo-b/src/b.ts");
|
||||
expect(files).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("edge: a repo with undefined baseCommitSha still captures via merge-base fallback (no `git diff undefined..HEAD`)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
// baseCommitSha intentionally undefined → resolveDiffBaseRef merge-base(HEAD, main).
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task);
|
||||
expect(files).toEqual(["repo-a/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("contamination: a foreign commit in a sub-repo range fires the divergence audit and is excluded from attributed files", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
// Land a FOREIGN commit (different FN-id) onto the same fusion/<id> branch range.
|
||||
const foreignFile = "src/foreign.ts";
|
||||
writeFileSync(path.join(a.worktreePath, "src", "foreign.ts"), "// foreign\n", "utf-8");
|
||||
execSync(`git add ${foreignFile}`, { cwd: a.worktreePath, stdio: "pipe" });
|
||||
execSync('git commit -m "feat(FN-OTHER): sneaky foreign change"', { cwd: a.worktreePath, stdio: "pipe" });
|
||||
|
||||
const dbAudit = vi.fn().mockResolvedValue(undefined);
|
||||
const audit = {
|
||||
database: dbAudit,
|
||||
filesystem: vi.fn().mockResolvedValue(undefined),
|
||||
git: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task, audit as any, "post-session");
|
||||
// Own file attributed, foreign file excluded from the attributed set.
|
||||
expect(files).toEqual(["repo-a/src/a.ts"]);
|
||||
expect(files).not.toContain("repo-a/src/foreign.ts");
|
||||
// The contamination/divergence audit fired for this repo (raw 2 files vs attributed 1).
|
||||
const contaminationCall = dbAudit.mock.calls.find(
|
||||
([evt]) => evt?.type === "task:worktree-contamination-detected",
|
||||
);
|
||||
expect(contaminationCall).toBeTruthy();
|
||||
expect(contaminationCall![0].metadata.rawDiffFileCount).toBeGreaterThan(contaminationCall![0].metadata.attributedFileCount);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 KTD2 — verifyWorktreeInvariants iterates per worktree, preserving the result union", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("happy: every worktree on fusion/<id> with matching toplevel → {ok:true}", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("error: a worktree HEAD off fusion/<id> → {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true})", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
// Drift repo-b's worktree off fusion/<id> onto a different branch.
|
||||
execSync("git checkout -b some-other-branch", { cwd: b.worktreePath, stdio: "pipe" });
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("wrong_branch");
|
||||
expect(result.repo).toBe("repo-b");
|
||||
expect(result.observed).toBe("some-other-branch");
|
||||
expect(result.expected).toBe(BRANCH);
|
||||
});
|
||||
|
||||
it("regression: a zero-acquire workspace task (empty map) verifies vacuously → {ok:true}", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, { branch: BRANCH, workspaceWorktrees: {} });
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 — single-repo (non-workspace) task: capture/verify unchanged", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("regression: non-workspace verifyWorktreeInvariants still runs the singular path and passes for a real worktree", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
// Single-repo executor rooted at repo-a itself (no workspaceConfig).
|
||||
const repoDir = fx.repoPath("repo-a");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-001");
|
||||
const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim();
|
||||
execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" });
|
||||
configureIdentity(worktreePath);
|
||||
writeFileSync(path.join(worktreePath, "single.ts"), "// x\n", "utf-8");
|
||||
execSync("git add single.ts", { cwd: worktreePath, stdio: "pipe" });
|
||||
execSync('git commit -m "feat(FN-001): single"', { cwd: worktreePath, stdio: "pipe" });
|
||||
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path
|
||||
const task = makeTask("FN-001", { branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base });
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-12:00:
|
||||
U1 session-cwd scenarios that require driving the real TaskExecutor.execute() to the agent-session boundary. Uses the shared executor-test-helpers harness — it mocks the AI/session/git/fs seams (NOT the workspace gating, NOT acquireTaskWorktree), so setting `(executor as any).workspaceConfig` exercises the genuine KTD1 gate: root acquisition is skipped, and every agent session (initial + retry) is created with `cwd === rootDir` (browse-only workspace root). The non-workspace path is the regression control (cwd === the acquired worktree path).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { acquireTaskWorktree } from "../worktree-acquisition.js";
|
||||
import type { WorkspaceConfig } from "@fusion/core";
|
||||
import {
|
||||
createMockStore,
|
||||
mockedCreateFnAgent,
|
||||
mockedExecSync,
|
||||
resetExecutorMocks,
|
||||
} from "./executor-test-helpers.js";
|
||||
|
||||
vi.mock("../worktree-acquisition.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
|
||||
return { ...actual, acquireTaskWorktree: vi.fn(actual.acquireTaskWorktree) };
|
||||
});
|
||||
|
||||
const mockedAcquireTaskWorktree = vi.mocked(acquireTaskWorktree);
|
||||
|
||||
const ROOT = "/tmp/workspace-root";
|
||||
|
||||
function inProgressTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("U1 KTD1 — session cwd is the browse-only workspace root", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
// Make any accidental git invocation observable: empty stdout keeps real-git
|
||||
// helpers from throwing, but acquireTaskWorktree assertions catch a leak.
|
||||
mockedExecSync.mockReturnValue("");
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("skips root acquireTaskWorktree and creates every session (initial + retry) with cwd === rootDir", async () => {
|
||||
const store = createMockStore();
|
||||
const mockPrompt = vi.fn().mockResolvedValue(undefined); // no fn_task_done → drives retries too
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: { prompt: mockPrompt, dispose: vi.fn() },
|
||||
sessionFile: "/tmp/sessions/ws.jsonl",
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
// Drive the genuine workspace gate (loadWorkspaceConfig is covered elsewhere).
|
||||
(executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig;
|
||||
|
||||
await executor.execute(inProgressTask({ worktree: null }));
|
||||
|
||||
// KTD1: the non-git root is never acquired as a worktree.
|
||||
expect(mockedAcquireTaskWorktree).not.toHaveBeenCalled();
|
||||
|
||||
// Every agent session (initial + the retries fired because fn_task_done was
|
||||
// never called) is rooted at the workspace root.
|
||||
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of mockedCreateFnAgent.mock.calls) {
|
||||
expect((call[0] as any).cwd).toBe(ROOT);
|
||||
}
|
||||
|
||||
// task.worktree is never set in workspace mode.
|
||||
const worktreeWrites = (store.updateTask as any).mock.calls.filter(
|
||||
(c: any[]) => c[1] && Object.prototype.hasOwnProperty.call(c[1], "worktree") && c[1].worktree,
|
||||
);
|
||||
expect(worktreeWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("U1 regression — non-workspace task acquires a worktree and roots the session there", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
mockedExecSync.mockReturnValue("");
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("calls acquireTaskWorktree and creates the session with cwd === the acquired worktree path", async () => {
|
||||
const store = createMockStore();
|
||||
const ACQUIRED = "/tmp/test/.worktrees/swift-falcon";
|
||||
mockedAcquireTaskWorktree.mockResolvedValue({
|
||||
worktreePath: ACQUIRED,
|
||||
branch: "fusion/fn-001",
|
||||
source: "fresh",
|
||||
hydrated: false,
|
||||
isResume: false,
|
||||
});
|
||||
|
||||
const mockPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: { prompt: mockPrompt, dispose: vi.fn() },
|
||||
sessionFile: "/tmp/sessions/ns.jsonl",
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
// No workspaceConfig → single-repo path. Pin the lazy-load guard so the real
|
||||
// loader is never consulted (it would return null for /tmp/test anyway).
|
||||
(executor as any).workspaceConfig = null;
|
||||
|
||||
await executor.execute(inProgressTask({ worktree: null }));
|
||||
|
||||
expect(mockedAcquireTaskWorktree).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
for (const call of mockedCreateFnAgent.mock.calls) {
|
||||
expect((call[0] as any).cwd).toBe(ACQUIRED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-00:30:
|
||||
U2 KTD4 — per-repo fn_task_done completion verification: per-repo scope-leak guard + per-repo worktree-invariant
|
||||
verify. These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace
|
||||
root (createWorkspaceFixture), so a leaked singular-root capture/verify would silently pass and the test would
|
||||
catch it. Narrow seams (FN-5048): we set `(executor as any).workspaceConfig` directly and stub only the store
|
||||
methods the guards read (parseFileScopeFromPrompt, logEntry, getRunContextFor) — no mock-the-world child_process.
|
||||
|
||||
Coverage:
|
||||
- scope-leak error: an uncommitted in-scope vs OFF-scope change in repo A → evaluateTaskDoneScopeLeak blocks,
|
||||
message NAMES repo-a (per-repo guard fires; singular root would silently pass).
|
||||
- verify error: a worktree HEAD off fusion/<id> → verifyWorktreeInvariants blocks (wrong_branch, repo-tagged).
|
||||
- all-clean: a two-repo task with only in-scope changes → scope-leak does NOT block.
|
||||
- helper: deriveRepoForPath / deriveRepoScopeSubset / splitRepoScopedPath unit cases (wolf-server/src/** → wolf-server;
|
||||
non-matching first segment → unscoped).
|
||||
- regression: single-repo (non-workspace) task → singular scope-leak path unchanged.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Task, TaskStore, WorkspaceConfig, Settings } from "@fusion/core";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import {
|
||||
deriveRepoForPath,
|
||||
deriveRepoScopeSubset,
|
||||
splitRepoScopedPath,
|
||||
UNSCOPED_REPO,
|
||||
} from "../workspace-paths.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
const TASK_ID = "FN-1001";
|
||||
const BRANCH = "fusion/fn-1001";
|
||||
|
||||
// reviewLevel=1 + block enforcement is the only mode that BLOCKS (else warn).
|
||||
const SETTINGS: Settings = { autoMerge: false, planOnlyScopeLeakEnforcement: "block" } as Settings;
|
||||
const PROMPT = "## Review Level: 1 (Plan Only)\n";
|
||||
|
||||
function createStore(declaredScope: string[]): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue(declaredScope),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRunContextFor: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(SETTINGS),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "WS",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function configureIdentity(dir: string): void {
|
||||
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/** Add a fusion/<id> worktree to a sub-repo with one committed in-scope edit; return its handle. */
|
||||
function addRepoWorktree(fx: WorkspaceFixture, repoRel: string, fileName: string): { worktreePath: string; baseCommitSha: string } {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
|
||||
configureIdentity(worktreePath);
|
||||
mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true });
|
||||
writeFileSync(path.join(worktreePath, fileName), "// in-scope\n", "utf-8");
|
||||
execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" });
|
||||
return { worktreePath, baseCommitSha };
|
||||
}
|
||||
|
||||
function workspaceExecutor(fx: WorkspaceFixture, store: TaskStore & EventEmitter): TaskExecutor {
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
return executor;
|
||||
}
|
||||
|
||||
describe("U2 — workspace-paths repo-prefix helper (unit)", () => {
|
||||
const repos = ["wolf-server", "repo-a", "apps/web"];
|
||||
it("deriveRepoForPath: first-segment match → that repo", () => {
|
||||
expect(deriveRepoForPath("wolf-server/src/index.ts", repos)).toBe("wolf-server");
|
||||
expect(deriveRepoForPath("repo-a/src/a.ts", repos)).toBe("repo-a");
|
||||
});
|
||||
it("deriveRepoForPath: longest nested-key match wins", () => {
|
||||
expect(deriveRepoForPath("apps/web/page.tsx", repos)).toBe("apps/web");
|
||||
});
|
||||
it("deriveRepoForPath: non-matching first segment → unscoped", () => {
|
||||
expect(deriveRepoForPath(".changeset/x.md", repos)).toBe(UNSCOPED_REPO);
|
||||
expect(deriveRepoForPath("other/thing.ts", repos)).toBe(UNSCOPED_REPO);
|
||||
expect(deriveRepoForPath("repo-ab/x.ts", repos)).toBe(UNSCOPED_REPO); // segment-wise, not substring
|
||||
});
|
||||
it("splitRepoScopedPath: strips the repo prefix for the repo-local remainder", () => {
|
||||
expect(splitRepoScopedPath("wolf-server/src/x.ts", repos)).toEqual({ repo: "wolf-server", relativePath: "src/x.ts" });
|
||||
expect(splitRepoScopedPath("other/x.ts", repos)).toEqual({ repo: UNSCOPED_REPO, relativePath: "other/x.ts" });
|
||||
});
|
||||
it("deriveRepoScopeSubset: returns repo-local scope patterns for one repo", () => {
|
||||
const scope = ["wolf-server/src/**", "repo-a/lib/x.ts", "apps/web/page.tsx"];
|
||||
expect(deriveRepoScopeSubset(scope, "wolf-server")).toEqual(["src/**"]);
|
||||
expect(deriveRepoScopeSubset(scope, "repo-a")).toEqual(["lib/x.ts"]);
|
||||
// repo-root scope entry maps to whole-repo **
|
||||
expect(deriveRepoScopeSubset(["repo-a"], "repo-a")).toEqual(["**"]);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("error: an off-scope change in repo A blocks completion and NAMES repo-a", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktree(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktree(fx, "repo-b", "src/b.ts");
|
||||
// Off-scope STAGED-but-uncommitted change in repo-a (outside declared `repo-a/src/**`).
|
||||
// captureUncommittedModifiedFiles reads `git diff`/`--cached`, so the leak must be tracked
|
||||
// (staged) to register — an untracked file is invisible to the guard by design.
|
||||
writeFileSync(path.join(a.worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8");
|
||||
execSync("git add OFFSCOPE.md", { cwd: a.worktreePath, stdio: "pipe" });
|
||||
// Declared scope is repo-prefixed and only covers src/** in each repo.
|
||||
const store = createStore(["repo-a/src/**", "repo-b/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
const task = makeTask({
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.message).toContain("repo-a");
|
||||
expect(result.message).toContain("OFFSCOPE.md");
|
||||
});
|
||||
|
||||
it("all-clean: only in-scope changes in both repos → not blocked", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktree(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktree(fx, "repo-b", "src/b.ts");
|
||||
const store = createStore(["repo-a/src/**", "repo-b/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
const task = makeTask({
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(false);
|
||||
});
|
||||
|
||||
// FNXC:Workspace 2026-06-21-15:00: F5 — per-repo `.changeset/` carve-out honored in workspace mode.
|
||||
// A legit sub-repo changeset (`repo-a/.changeset/x.md`) must NOT be flagged off-scope: the always-allowed
|
||||
// filter now runs against the repo-LOCAL remainder (`.changeset/x.md`), so the carve-out matches. Before
|
||||
// the fix the file was prefixed BEFORE filtering, the `.changeset/` startsWith never matched, and
|
||||
// fn_task_done was wrongly REFUSED.
|
||||
it("F5: a sub-repo `.changeset/` file is NOT flagged off-scope (always-allowed honored)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktree(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktree(fx, "repo-b", "src/b.ts");
|
||||
// A per-repo changeset OUTSIDE the declared `repo-a/src/**` scope — only the always-allowed
|
||||
// carve-out can keep this from being a leak.
|
||||
mkdirSync(path.join(a.worktreePath, ".changeset"), { recursive: true });
|
||||
writeFileSync(path.join(a.worktreePath, ".changeset", "tidy-foo.md"), "---\n'@x': patch\n---\n", "utf-8");
|
||||
execSync("git add .changeset/tidy-foo.md", { cwd: a.worktreePath, stdio: "pipe" });
|
||||
const store = createStore(["repo-a/src/**", "repo-b/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
const task = makeTask({
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(false);
|
||||
});
|
||||
|
||||
// FNXC:Workspace 2026-06-21-15:00: F2 — scoped task that acquired ZERO sub-repo worktrees is blocked.
|
||||
// declaredScope is non-empty but `workspaceWorktrees` is empty → scope cannot be verified at all. The
|
||||
// guard must refuse fn_task_done rather than silently aggregating zero off-scope files and passing.
|
||||
it("F2: scoped task with zero acquired worktrees → blocked (cannot verify scope)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const store = createStore(["repo-a/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
const task = makeTask({ branch: BRANCH, workspaceWorktrees: {} });
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.message).toContain("acquired no sub-repo worktrees");
|
||||
});
|
||||
|
||||
// FNXC:Workspace 2026-06-21-15:00: F1 — fail CLOSED on a mid-loop capture throw.
|
||||
// If one repo's capture throws (scope is UNVERIFIED for that repo), the guard must BLOCK naming the
|
||||
// repo — not let the outer `.catch()` fail open and proceed with an incomplete scope check.
|
||||
it("F1: a mid-loop capture throw → blocked (fail-closed), names the repo", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktree(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktree(fx, "repo-b", "src/b.ts");
|
||||
const store = createStore(["repo-a/src/**", "repo-b/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
// Narrow seam: force the per-repo uncommitted capture to throw for repo-a's worktree only.
|
||||
const realCapture = (executor as any).captureUncommittedModifiedFiles.bind(executor);
|
||||
vi.spyOn(executor as any, "captureUncommittedModifiedFiles").mockImplementation(async (wt: unknown) => {
|
||||
if (wt === a.worktreePath) throw new Error("simulated capture failure");
|
||||
return realCapture(wt as string);
|
||||
});
|
||||
const task = makeTask({
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.message).toContain("repo-a");
|
||||
expect(result.message).toContain("refusing fn_task_done");
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U2 KTD4 — per-repo worktree-invariant verify in fn_task_done", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("error: a worktree off fusion/<id> blocks completion via per-repo verify", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktree(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktree(fx, "repo-b", "src/b.ts");
|
||||
execSync("git checkout -b drifted-branch", { cwd: b.worktreePath, stdio: "pipe" });
|
||||
const store = createStore(["repo-a/src/**", "repo-b/src/**"]);
|
||||
const executor = workspaceExecutor(fx, store);
|
||||
const task = makeTask({
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("wrong_branch");
|
||||
expect(result.repo).toBe("repo-b");
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U2 — single-repo (non-workspace) task: scope-leak unchanged", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("regression: singular scope-leak path still flags an off-scope change in the singular worktree", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const repoDir = fx.repoPath("repo-a");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-001");
|
||||
const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim();
|
||||
execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" });
|
||||
configureIdentity(worktreePath);
|
||||
// Off-scope STAGED change (declared scope is `src/**`). Tracked so the guard sees it.
|
||||
writeFileSync(path.join(worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8");
|
||||
execSync("git add OFFSCOPE.md", { cwd: worktreePath, stdio: "pipe" });
|
||||
|
||||
const store = createStore(["src/**"]);
|
||||
const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path
|
||||
const task = makeTask({ id: "FN-001", branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base });
|
||||
|
||||
const result = await (executor as any).evaluateTaskDoneScopeLeak(task, worktreePath, PROMPT, SETTINGS);
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.message).toContain("OFFSCOPE.md");
|
||||
// Singular message carries no repo tag.
|
||||
expect(result.message).not.toContain("repo=");
|
||||
});
|
||||
});
|
||||
@@ -1,89 +1,221 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { loadWorkspaceConfig } from "@fusion/core";
|
||||
import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js";
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-12:00:
|
||||
U1 executor session-scoping tests. REWRITTEN from the foundation's self-mocking version (which vi.mock'd the very functions under test and proved nothing). These tests use a REAL two-repo git fixture (`createWorkspaceFixture`) under a NON-git workspace root, so a leaked rootDir git preflight would actually fail. They drive the real TaskExecutor methods that U1 changed: the activeWorktrees Set conversion + every enumerated consumer (KTD2), the preflight gate + browse-only-root scoping (KTD1), and the synthetic-acquisition cwd.
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
Seam choice (FN-5048): `(executor as any).workspaceConfig` is set directly to drive the gating with real git — loadWorkspaceConfig is covered by its own unit and is not the subject here. No mock-the-world child_process/fs shell.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { loadWorkspaceConfig, type Task, type TaskStore, type WorkspaceConfig } from "@fusion/core";
|
||||
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function createStore(overrides: Partial<Record<string, unknown>> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
|
||||
on: emitter.on.bind(emitter),
|
||||
...overrides,
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
}
|
||||
|
||||
function makeTask(id = "FN-WS-1", overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
...actual,
|
||||
loadWorkspaceConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
id,
|
||||
title: "Workspace task",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
vi.mock("../worktree-acquisition.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
|
||||
return {
|
||||
...actual,
|
||||
acquireWorkspaceRepoWorktree: vi.fn(),
|
||||
};
|
||||
});
|
||||
const repoAPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-a")}/.worktrees/fn-ws-1`;
|
||||
const repoBPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-b")}/.worktrees/fn-ws-1`;
|
||||
|
||||
const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig);
|
||||
const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree);
|
||||
describeIfGit("workspace fixture", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
const MOCK_WORKSPACE_CONFIG = {
|
||||
repos: ["wolf-server", "wolf-community-frontend-1"],
|
||||
};
|
||||
|
||||
describe("acquireWorkspaceRepoWorktree", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns alreadyAcquired=false for a fresh repo", async () => {
|
||||
mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({
|
||||
worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc",
|
||||
branch: "fusion/fn-001",
|
||||
alreadyAcquired: false,
|
||||
});
|
||||
|
||||
const result = await acquireWorkspaceRepoWorktree({
|
||||
repoRelPath: "wolf-server",
|
||||
workspaceRootDir: "/workspace",
|
||||
task: { id: "FN-001", workspaceWorktrees: undefined } as never,
|
||||
store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
expect(result.alreadyAcquired).toBe(false);
|
||||
expect(result.worktreePath).toContain("wolf-server");
|
||||
});
|
||||
|
||||
it("returns alreadyAcquired=true when worktree already acquired", async () => {
|
||||
mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({
|
||||
worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc",
|
||||
branch: "fusion/fn-001",
|
||||
alreadyAcquired: true,
|
||||
});
|
||||
|
||||
const result = await acquireWorkspaceRepoWorktree({
|
||||
repoRelPath: "wolf-server",
|
||||
workspaceRootDir: "/workspace",
|
||||
task: {
|
||||
id: "FN-001",
|
||||
workspaceWorktrees: {
|
||||
"wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" },
|
||||
},
|
||||
} as never,
|
||||
store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
expect(result.alreadyAcquired).toBe(true);
|
||||
it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
// Root is NOT a git repo. Use "." so the check runs in fx.rootDir itself, not
|
||||
// its parent (".." would resolve to the tmpdir and could pass spuriously).
|
||||
expect(() => fx.git(".", "git rev-parse --git-dir")).toThrow();
|
||||
// Each sub-repo is a real git repo with a commit on main.
|
||||
expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main");
|
||||
expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1");
|
||||
// loadWorkspaceConfig resolves the on-disk config the executor keys off.
|
||||
const config = await loadWorkspaceConfig(fx.rootDir);
|
||||
expect(config?.repos).toEqual(["repo-a", "repo-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace config", () => {
|
||||
it("loadWorkspaceConfig returns null for non-workspace", async () => {
|
||||
mockedLoadWorkspaceConfig.mockResolvedValueOnce(null);
|
||||
const config = await loadWorkspaceConfig("/some/single-repo");
|
||||
expect(config).toBeNull();
|
||||
describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
function workspaceExecutor() {
|
||||
fx ??= undefined as never;
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
return executor;
|
||||
}
|
||||
|
||||
it("a workspace task holding TWO sub-repo paths is found by membership, not equality", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor();
|
||||
const pA = repoAPath(fx);
|
||||
const pB = repoBPath(fx);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pA);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pB);
|
||||
|
||||
// hasActiveWorktreeBinding: both held paths match; an unheld path does not.
|
||||
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pA)).toBe(true);
|
||||
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pB)).toBe(true);
|
||||
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", "/nope")).toBe(false);
|
||||
|
||||
// findActiveWorktreeOwner: another task asking about either held path finds FN-WS-1.
|
||||
await expect((executor as any).findActiveWorktreeOwner(pA, "FN-OTHER")).resolves.toBe("FN-WS-1");
|
||||
await expect((executor as any).findActiveWorktreeOwner(pB, "FN-OTHER")).resolves.toBe("FN-WS-1");
|
||||
// The owner itself is excluded.
|
||||
await expect((executor as any).findActiveWorktreeOwner(pA, "FN-WS-1")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("loadWorkspaceConfig returns config for workspace", async () => {
|
||||
mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG);
|
||||
const config = await loadWorkspaceConfig("/some/workspace");
|
||||
expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]);
|
||||
it("listWorktreeHolders flat-maps the Set into N holder rows for one task", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor();
|
||||
const pA = repoAPath(fx);
|
||||
const pB = repoBPath(fx);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pA);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pB);
|
||||
|
||||
const holders = executor.listWorktreeHolders();
|
||||
expect(holders).toHaveLength(2);
|
||||
expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pA });
|
||||
expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pB });
|
||||
});
|
||||
|
||||
it("shouldGenerateNewWorktreeName iterates the Set (conflict membership)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const store = createStore({ listTasks: vi.fn().mockResolvedValue([]) });
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
const pA = repoAPath(fx);
|
||||
(executor as any).addActiveWorktree("FN-HOLDER", pA);
|
||||
|
||||
// A different task contending for FN-HOLDER's path must be told to generate a new name.
|
||||
await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-WS-1")).resolves.toBe(true);
|
||||
// The holder asking about its own path is not a conflict (excluded), and the
|
||||
// DB liveness fallback returns no other user.
|
||||
await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-HOLDER")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("getWorktreePath returns undefined for a multi-worktree workspace task (Set-collapse contract)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor();
|
||||
(executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx));
|
||||
(executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx));
|
||||
expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleanup drops in-memory tracking in workspace mode but never removes the root", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const removeSpy = vi.fn();
|
||||
const executor = workspaceExecutor();
|
||||
(executor as any).removeOwnWorktreeWithReconcile = removeSpy;
|
||||
(executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx));
|
||||
(executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx));
|
||||
|
||||
await executor.cleanup("FN-WS-1");
|
||||
|
||||
expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined();
|
||||
expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false);
|
||||
// The browse-only root must never be torn down as if it were a worktree.
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearPhantomExecutorBinding (FN-6736) unregisters every held path, not one", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor();
|
||||
const pA = repoAPath(fx);
|
||||
const pB = repoBPath(fx);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pA);
|
||||
(executor as any).addActiveWorktree("FN-WS-1", pB);
|
||||
|
||||
const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1");
|
||||
expect(ok).toBe(true);
|
||||
expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 KTD2 — non-workspace task is a one-element Set (regression: unchanged)", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("getWorktreePath returns the sole path; listWorktreeHolders emits exactly one row", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, fx.repoPath("repo-a")); // single-repo root
|
||||
// No workspaceConfig set → single-repo mode.
|
||||
const wt = `${fx.repoPath("repo-a")}/.worktrees/fn-001`;
|
||||
(executor as any).addActiveWorktree("FN-001", wt);
|
||||
|
||||
expect(executor.getWorktreePath("FN-001")).toBe(wt);
|
||||
expect(executor.listWorktreeHolders()).toEqual([{ taskId: "FN-001", worktreePath: wt }]);
|
||||
expect((executor as any).hasActiveWorktreeBinding("FN-001", wt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 KTD1 — verifyWorktreeInvariants gated off in workspace mode", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("returns ok for a zero-acquire workspace task (no task.worktree) so fn_task_done does not requeue", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
|
||||
// A workspace task that acquired ZERO sub-repos has no task.worktree and no
|
||||
// tracked paths. The singular invariant would otherwise refuse on
|
||||
// "missing task.worktree"; in workspace mode it is gated OFF.
|
||||
const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-WS-1", { worktree: undefined }));
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("non-workspace task with no worktree still fails the invariant (regression: gate is workspace-only)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, fx.repoPath("repo-a"));
|
||||
// No workspaceConfig.
|
||||
const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-001", { worktree: undefined }));
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 KTD1 — scopePromptToWorktree / buildExecutionPrompt no-op in workspace mode", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("does not rewrite root-anchored paths when a workspace config is present", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const task = makeTask("FN-WS-1", { prompt: `Edit ${fx.rootDir}/repo-a/src/index.ts and commit.` });
|
||||
const config: WorkspaceConfig = { repos: fx.repos };
|
||||
// worktreePath === rootDir in workspace mode; the prompt must be returned verbatim.
|
||||
const prompt = buildExecutionPrompt(task as any, fx.rootDir, { autoMerge: false } as any, fx.rootDir, undefined, undefined, config);
|
||||
expect(prompt).toContain(`${fx.rootDir}/repo-a/src/index.ts`);
|
||||
// The workspace repo list is appended (foundation behavior).
|
||||
expect(prompt).toContain("repo-a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
(executor as any).activeWorktrees.set("FN-4973", CONFLICT_PATH);
|
||||
(executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH);
|
||||
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
|
||||
|
||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||
|
||||
@@ -14,16 +14,33 @@ const testState = vi.hoisted(() => {
|
||||
|
||||
return {
|
||||
currentStore: null as MockTaskStore | null,
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
VerificationError: MockVerificationError,
|
||||
};
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). These error-recovery tests use the
|
||||
// merge fn as a mockable seam; they now mock/assert runAiMerge. VerificationError
|
||||
// still comes from merger.js (shared, not deprecated).
|
||||
vi.mock("../merger.js", () => ({
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
sweepStaleAutostashes: vi.fn(async () => undefined),
|
||||
VerificationError: testState.VerificationError,
|
||||
}));
|
||||
|
||||
// FNXC:Workspace 2026-06-22-09:30 (Phase C review fix): the dispatch's error handler does
|
||||
// `err instanceof WorkspaceRepoLandBusyError` / `WorkspacePartialLandError` on EVERY merge error
|
||||
// (these classes are imported from ./merger-ai.js). A bare replacement mock left them undefined,
|
||||
// so `instanceof undefined` threw on every recovery path (24 pre-existing red tests). Re-export the
|
||||
// REAL error classes via importOriginal so the instanceof guards evaluate; only runAiMerge is faked.
|
||||
vi.mock("../merger-ai.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../merger-ai.js")>();
|
||||
return {
|
||||
...actual,
|
||||
runAiMerge: testState.runAiMerge,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||||
InProcessRuntime: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
@@ -42,7 +59,8 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||||
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { aiMergeTask, VerificationError } from "../merger.js";
|
||||
import { VerificationError } from "../merger.js";
|
||||
import { runAiMerge } from "../merger-ai.js";
|
||||
|
||||
type MockTask = {
|
||||
id: string;
|
||||
@@ -118,9 +136,9 @@ function makeStore({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
// These tests mock + assert aiMergeTask (the legacy merge path); pin the
|
||||
// legacy merger so onMerge routes there rather than the AI merge path.
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// these tests mock/assert runAiMerge directly. No `merger.mode` pin needed —
|
||||
// the dispatch ignores the value.
|
||||
...settings,
|
||||
})),
|
||||
listTasks: vi.fn(async () => listedTasks ?? taskSequence.filter((task): task is MockTask => Boolean(task))),
|
||||
@@ -193,7 +211,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(aiMergeTask).mockReset();
|
||||
vi.mocked(runAiMerge).mockReset();
|
||||
testState.currentStore = null;
|
||||
|
||||
errorSpy = vi.spyOn(runtimeLog, "error").mockImplementation(() => undefined);
|
||||
@@ -347,7 +365,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ mergeRetries: 2 }), makeTask({ mergeRetries: 3, branch: "fusion/fn-2084" })],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -379,7 +397,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
throw new Error("db write failed");
|
||||
}),
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("Conflict while merging"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("Conflict while merging"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await expect(runMergeCycle(engine)).resolves.toBeUndefined();
|
||||
@@ -396,7 +414,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -429,7 +447,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -462,7 +480,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -495,7 +513,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -525,7 +543,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -558,7 +576,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -586,7 +604,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -600,7 +618,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const store = makeStore();
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("This operation was aborted"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("This operation was aborted"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
@@ -626,7 +644,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ mergeTransientRetryCount: 3 }), makeTask({ mergeTransientRetryCount: 3 })],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("socket hang up"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("socket hang up"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -647,7 +665,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const store = makeStore();
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("remote branch missing"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("remote branch missing"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -800,7 +818,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
throw new Error("sqlite locked");
|
||||
}),
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("remote push rejected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("remote push rejected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await expect(runMergeCycle(engine)).resolves.toBeUndefined();
|
||||
@@ -874,7 +892,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("treats post-finalize verification failures as a no-op diagnostic", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed: assertion mismatch in workspace");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [
|
||||
@@ -930,7 +948,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("moves task back to in-progress with merge-remediation status on verification errors", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore();
|
||||
const engine = createEngine(store);
|
||||
@@ -968,7 +986,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
recovered: false,
|
||||
},
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 2, status: "in-review" })],
|
||||
@@ -987,7 +1005,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("increments verificationFailureCount across consecutive verification bounces", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 1, status: "merging-fix" })],
|
||||
@@ -1008,7 +1026,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("caps verification-failure bounces and creates a follow-up task", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
// Task already bounced 2 times — this attempt would push it to 3 (the cap)
|
||||
const store = makeStore({
|
||||
@@ -1045,7 +1063,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("skips duplicate verification follow-up creation when active recovery task exists", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 2, title: "do the thing" })],
|
||||
@@ -1078,7 +1096,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("logs when verification-error recovery fails", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
updateTask: vi.fn(async () => {
|
||||
|
||||
@@ -15,6 +15,11 @@ const findMock = vi.fn();
|
||||
const getAllMock = vi.fn(() => [] as any[]);
|
||||
const registerProviderMock = vi.fn();
|
||||
const refreshMock = vi.fn();
|
||||
// FNXC:SessionRouting 2026-06-24-11:30:
|
||||
// #1675: capture model-registry auth resolution + session id so the wiring
|
||||
// test can assert X-Session-Id/X-Session-Affinity precedence end-to-end.
|
||||
const getApiKeyAndHeadersMock = vi.fn(async () => ({ ok: true, apiKey: undefined, headers: undefined }));
|
||||
const sessionManagerGetSessionIdMock = vi.fn(() => undefined);
|
||||
const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" }));
|
||||
const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
|
||||
const setFallbackResolverMock = vi.fn();
|
||||
@@ -138,9 +143,12 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
refresh() {
|
||||
return refreshMock();
|
||||
}
|
||||
getApiKeyAndHeaders() {
|
||||
return getApiKeyAndHeadersMock();
|
||||
}
|
||||
},
|
||||
SessionManager: {
|
||||
inMemory: () => ({ kind: "session-manager" }),
|
||||
inMemory: () => ({ kind: "session-manager", getSessionId: sessionManagerGetSessionIdMock }),
|
||||
},
|
||||
SettingsManager: {
|
||||
create: settingsManagerCreateMock,
|
||||
@@ -1024,6 +1032,9 @@ describe("createFnAgent", () => {
|
||||
realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path));
|
||||
readCustomProvidersMock.mockReturnValue([]);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
// #1675: re-establish default auth + session-id mock returns after clearAllMocks.
|
||||
getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined });
|
||||
sessionManagerGetSessionIdMock.mockReturnValue(undefined);
|
||||
createBashToolMock.mockClear();
|
||||
createAgentSessionMock.mockResolvedValue({
|
||||
session: {
|
||||
@@ -1921,6 +1932,62 @@ describe("createFnAgent", () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// FNXC:SessionRouting 2026-06-24-11:30:
|
||||
// #1675: createFnAgent must resolve sessionRoutingId = taskId ?? piSessionId and
|
||||
// wrap the registry's getApiKeyAndHeaders so outbound requests carry routing
|
||||
// headers. These assert the wiring precedence end-to-end, not just the helper.
|
||||
describe("session routing headers wiring (#1675)", () => {
|
||||
const anyModel = { provider: "anthropic", id: "claude" } as never;
|
||||
|
||||
async function createAndCaptureRegistry(overrides: Record<string, unknown> = {}) {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
...overrides,
|
||||
});
|
||||
const sessionOptions = createAgentSessionMock.mock.calls.at(-1)?.[0] as {
|
||||
modelRegistry: { getApiKeyAndHeaders: (model: unknown) => Promise<unknown> };
|
||||
};
|
||||
return sessionOptions.modelRegistry;
|
||||
}
|
||||
|
||||
it("uses taskId as the routing id when provided", async () => {
|
||||
const registry = await createAndCaptureRegistry({ taskId: "FN-7788" });
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record<string, string> };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.headers).toEqual({
|
||||
"X-Session-Id": "FN-7788",
|
||||
"X-Session-Affinity": "FN-7788",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the pi session id when taskId is absent", async () => {
|
||||
sessionManagerGetSessionIdMock.mockReturnValue("pi-session-abc");
|
||||
const registry = await createAndCaptureRegistry();
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record<string, string> };
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
"X-Session-Id": "pi-session-abc",
|
||||
"X-Session-Affinity": "pi-session-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not wrap getApiKeyAndHeaders when neither taskId nor a session id is available", async () => {
|
||||
// getApiKeyAndHeadersMock returns { ok: true, headers: undefined }; if the
|
||||
// wrapper were applied, headers would be populated with X-Session-*.
|
||||
const registry = await createAndCaptureRegistry();
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record<string, string> };
|
||||
|
||||
expect(result.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill selection", () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules to ensure fresh imports for each test
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { attachSessionRoutingHeaders, buildSessionRoutingHeaders } from "../pi.js";
|
||||
|
||||
// FNXC:SessionRouting 2026-06-23-16:40:
|
||||
// Issue #1675: chat completion requests must carry X-Session-Id and
|
||||
// X-Session-Affinity so LLM gateways can sticky-route and observability tools
|
||||
// can group the stateless API calls of one conversation into a single trace.
|
||||
|
||||
describe("buildSessionRoutingHeaders", () => {
|
||||
it("emits X-Session-Id and X-Session-Affinity with the same identifier", () => {
|
||||
expect(buildSessionRoutingHeaders("sess-123")).toEqual({
|
||||
"X-Session-Id": "sess-123",
|
||||
"X-Session-Affinity": "sess-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachSessionRoutingHeaders", () => {
|
||||
// Minimal stand-in for the bits of ModelRegistry the wrapper touches.
|
||||
function makeRegistry(
|
||||
resolve: (model: unknown) => Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string>; error?: string }>,
|
||||
): ModelRegistry {
|
||||
return { getApiKeyAndHeaders: resolve } as unknown as ModelRegistry;
|
||||
}
|
||||
|
||||
const anyModel = { provider: "anthropic", id: "claude" } as never;
|
||||
|
||||
it("merges the routing headers into resolved request headers", async () => {
|
||||
const registry = makeRegistry(async () => ({ ok: true, apiKey: "sk-live", headers: undefined }));
|
||||
attachSessionRoutingHeaders(registry, "sess-abc");
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
apiKey: "sk-live",
|
||||
headers: {
|
||||
"X-Session-Id": "sess-abc",
|
||||
"X-Session-Affinity": "sess-abc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the resolved apiKey and any provider-specific headers", async () => {
|
||||
const registry = makeRegistry(async () => ({
|
||||
ok: true,
|
||||
apiKey: "sk-custom",
|
||||
headers: { "HTTP-Referer": "https://example.com", "X-Title": "Fusion" },
|
||||
}));
|
||||
attachSessionRoutingHeaders(registry, "sess-xyz");
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected ok auth result");
|
||||
expect(result.apiKey).toBe("sk-custom");
|
||||
expect(result.headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com",
|
||||
"X-Title": "Fusion",
|
||||
"X-Session-Id": "sess-xyz",
|
||||
"X-Session-Affinity": "sess-xyz",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not alter failed auth resolutions", async () => {
|
||||
const registry = makeRegistry(async () => ({ ok: false, error: "No API key found" }));
|
||||
attachSessionRoutingHeaders(registry, "sess-fail");
|
||||
|
||||
const result = await registry.getApiKeyAndHeaders(anyModel);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "No API key found" });
|
||||
});
|
||||
|
||||
it("no-ops without throwing when getApiKeyAndHeaders is absent", () => {
|
||||
// If a future pi-coding-agent rename removes the method, the wrapper must not
|
||||
// break session creation. It leaves the registry untouched and warns instead.
|
||||
const registry = {} as ModelRegistry;
|
||||
|
||||
expect(() => attachSessionRoutingHeaders(registry, "sess-none")).not.toThrow();
|
||||
expect((registry as unknown as Record<string, unknown>).getApiKeyAndHeaders).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js";
|
||||
// Resolves to the vi.mock factory above (the mocked merger-ai exports the real-shaped
|
||||
// workspace land error classes so the dispatch's `instanceof` matching is exercised).
|
||||
import { WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "../merger-ai.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
import { NtfyNotifier } from "../notifier.js";
|
||||
@@ -18,7 +21,8 @@ const mocks = vi.hoisted(() => ({
|
||||
runtimeStart: vi.fn(async () => undefined),
|
||||
runtimeStop: vi.fn(async () => undefined),
|
||||
runtimeResumeAfterUnpause: vi.fn(async () => undefined),
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
landWorkspaceTask: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
currentStore: null as Record<string, unknown> | null,
|
||||
notifierStart: vi.fn(async () => undefined),
|
||||
@@ -61,10 +65,51 @@ vi.mock("../cron-runner.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). project-engine no longer imports
|
||||
// aiMergeTask; the merge seam these tests mock/assert is now runAiMerge.
|
||||
vi.mock("../merger.js", () => ({
|
||||
aiMergeTask: mocks.aiMergeTask,
|
||||
sweepStaleAutostashes: vi.fn(async () => undefined),
|
||||
VerificationError: class VerificationError extends Error {},
|
||||
}));
|
||||
|
||||
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): the dispatch now matches the
|
||||
// workspace land errors via `instanceof`, and routes workspace tasks through
|
||||
// `landWorkspaceTask`. The mock must export REAL error classes (so `instanceof` is callable)
|
||||
// and a mockable `landWorkspaceTask`; otherwise `err instanceof WorkspacePartialLandError`
|
||||
// throws "not callable" and the workspace dispatch can't be exercised. The classes are
|
||||
// declared INSIDE the (hoisted) factory so they exist when the mock is evaluated.
|
||||
vi.mock("../merger-ai.js", () => {
|
||||
class WorkspaceRepoLandBusyError extends Error {
|
||||
public readonly retryable = true;
|
||||
constructor(
|
||||
public readonly repoRel: string,
|
||||
public readonly holderTaskId: string,
|
||||
public readonly requestingTaskId: string,
|
||||
) {
|
||||
super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`);
|
||||
this.name = "WorkspaceRepoLandBusyError";
|
||||
}
|
||||
}
|
||||
class WorkspacePartialLandError extends Error {
|
||||
public readonly retryable = true;
|
||||
constructor(
|
||||
public readonly landedCount: number,
|
||||
public readonly failedRepos: string[],
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkspacePartialLandError";
|
||||
}
|
||||
}
|
||||
return {
|
||||
runAiMerge: mocks.runAiMerge,
|
||||
landWorkspaceTask: mocks.landWorkspaceTask,
|
||||
WorkspaceRepoLandBusyError,
|
||||
WorkspacePartialLandError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
@@ -253,8 +298,10 @@ const baseSettings: Record<string, unknown> = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
// onMerge tests mock + assert aiMergeTask (legacy path); pin legacy mode.
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// the onMerge tests mock/assert runAiMerge. The old `merger.mode` pin is gone
|
||||
// (the dispatch ignores it) — a dedicated test below covers the inert-mode +
|
||||
// one-time deprecation-warning behavior.
|
||||
taskStuckTimeoutMs: undefined,
|
||||
memoryAutoSummarizeEnabled: false,
|
||||
memoryAutoSummarizeThresholdChars: 50_000,
|
||||
@@ -411,7 +458,8 @@ describe("ProjectEngine PR monitoring wiring", () => {
|
||||
await engine.start();
|
||||
|
||||
expect(mocks.runtimeConfigurePrMonitoring).toHaveBeenCalled();
|
||||
const configArg = mocks.runtimeConfigurePrMonitoring.mock.calls.at(-1)?.[0] as {
|
||||
const calls = mocks.runtimeConfigurePrMonitoring.mock.calls;
|
||||
const configArg = calls[calls.length - 1]?.[0] as {
|
||||
onClosedPrFeedback?: (taskId: string, prInfo: Record<string, unknown>, comments: unknown[]) => Promise<void> | void;
|
||||
};
|
||||
expect(typeof configArg.onClosedPrFeedback).toBe("function");
|
||||
@@ -437,7 +485,7 @@ describe("ProjectEngine auto-summarize wiring", () => {
|
||||
vi.clearAllMocks();
|
||||
const mockStore = createMockStore(baseSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.aiMergeTask.mockResolvedValue({
|
||||
mocks.runAiMerge.mockResolvedValue({
|
||||
task: { id: "FN-001", column: "done" },
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
@@ -1130,7 +1178,7 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
};
|
||||
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const options = args[3] as { signal?: AbortSignal } | undefined;
|
||||
capturedSignal = options?.signal;
|
||||
await new Promise<never>((_, reject) => {
|
||||
@@ -1146,7 +1194,7 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
engine.enqueueMerge("FN-queued");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
@@ -1164,10 +1212,10 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
});
|
||||
expect(privateEngine.mergeAbortController).toBeNull();
|
||||
|
||||
const mergeCallsBeforeRequeue = mocks.aiMergeTask.mock.calls.length;
|
||||
const mergeCallsBeforeRequeue = mocks.runAiMerge.mock.calls.length;
|
||||
engine.enqueueMerge("FN-after-stop");
|
||||
expect(privateEngine.mergeQueue).toHaveLength(0);
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1185,15 +1233,15 @@ describe("ProjectEngine manual merge plumbing", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
});
|
||||
|
||||
it("passes manual=true to aiMergeTask for onMerge requests", async () => {
|
||||
mocks.aiMergeTask.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
|
||||
it("passes manual=true to runAiMerge for onMerge requests", async () => {
|
||||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
|
||||
await engine.onMerge("FN-5438");
|
||||
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledWith(
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
"FN-5438",
|
||||
@@ -1204,6 +1252,345 @@ describe("ProjectEngine manual merge plumbing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made runAiMerge the
|
||||
// sole merge path. These tests pin the unified dispatch: every merger.mode value
|
||||
// routes to runAiMerge, "deterministic" warns exactly once (never errors), and
|
||||
// the R7 workspace guard rejects populated-workspaceWorktrees tasks at the engine
|
||||
// merge entry point before any merge runs.
|
||||
describe("ProjectEngine U0 merge unification dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: the deterministic-mode deprecation
|
||||
// warning is gated by a per-project module-level ledger. Reset it before each
|
||||
// test so the once-per-project-per-process assertion is deterministic regardless
|
||||
// of which sibling test populated the ledger first (createEngine always uses the
|
||||
// same project root, so without this a prior deterministic merge would suppress
|
||||
// the warning here and the "fires once" test would see zero emissions).
|
||||
__resetDeterministicMergerModeDeprecationWarned();
|
||||
});
|
||||
|
||||
async function runOnMergeWithMode(mode: string | undefined) {
|
||||
const settings = { ...baseSettings, autoMerge: true } as Record<string, unknown>;
|
||||
if (mode === undefined) {
|
||||
delete settings.merger;
|
||||
} else {
|
||||
settings.merger = { mode };
|
||||
}
|
||||
const mockStore = createMockStore(settings);
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-U0",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
} as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-U0" } } as any);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await engine.onMerge("FN-U0");
|
||||
await engine.stop();
|
||||
}
|
||||
|
||||
it.each([
|
||||
["unset", undefined],
|
||||
["ai", "ai"],
|
||||
["deterministic", "deterministic"],
|
||||
])("routes merger.mode=%s to runAiMerge (never aiMergeTask)", async (_label, mode) => {
|
||||
await runOnMergeWithMode(mode as string | undefined);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
"FN-U0",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the merger.mode "deterministic" deprecation warning exactly once per project per process (warn, not error)', async () => {
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined);
|
||||
const deprecationWarnings = () =>
|
||||
warnSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("merger.mode") && String(call[0]).includes("deprecated"),
|
||||
);
|
||||
try {
|
||||
// First deterministic merge: the warning must fire EXACTLY once.
|
||||
await runOnMergeWithMode("deterministic");
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
|
||||
// A SECOND deterministic merge in the same process (same project root) must
|
||||
// NOT warn again — the per-project ledger suppresses the repeat. Total stays 1.
|
||||
await runOnMergeWithMode("deterministic");
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
|
||||
// The warning is a warn (never an error), and the merge still proceeds via
|
||||
// runAiMerge despite the deprecated value.
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// FNXC:Workspace 2026-06-22-05:10 (Phase C U1/U2 routing — supersedes the old R7 throw test):
|
||||
// A workspace-mode task no longer throws WorkspaceTaskMergeError at the engine dispatch; it
|
||||
// ROUTES to the per-repo land loop `landWorkspaceTask` (runAiMerge's R7 chokepoint stays as
|
||||
// defense-in-depth but is not the primary path). On a full land, the merge reports merged=true.
|
||||
it("routes a workspace-mode task to landWorkspaceTask (not runAiMerge) on full land", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-WS",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
branch: "fusion/fn-ws",
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" },
|
||||
"repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" },
|
||||
},
|
||||
} as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.landWorkspaceTask.mockResolvedValue({
|
||||
allLanded: true,
|
||||
repos: [
|
||||
{ repo: "repo-a", status: "landed", landedSha: "aaaa1111", integrationBranch: "main" },
|
||||
{ repo: "repo-b", status: "landed", landedSha: "bbbb2222", integrationBranch: "main" },
|
||||
],
|
||||
} as any);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
const result = await engine.onMerge("FN-WS");
|
||||
expect(mocks.landWorkspaceTask).toHaveBeenCalled();
|
||||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||||
expect(result.merged).toBe(true);
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-05:10 (Phase C review B1/B2/B4/B5):
|
||||
Merge DISPATCH hardening for workspace tasks. These drive the REAL ProjectEngine dispatch
|
||||
catch via the mocked merger-ai seam (landWorkspaceTask + the real-shaped error classes),
|
||||
asserting the failure modes the review flagged: fail-closed on getTask null (B1), the
|
||||
merge-confirmed reachability fast-path skipping workspace tasks (B2), busy-contention not
|
||||
burning the merge-retry quota (B4), and the capped backoff (B5). No real AI, no real git
|
||||
for the fast-path (the gate's git is asserted NOT to run for workspace tasks).
|
||||
*/
|
||||
describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const workspaceTask = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "FN-WSH",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
branch: "fusion/fn-wsh",
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-wsh-a" },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// B1: getTask returning null in the partial-land catch must FAIL CLOSED — no retry timer.
|
||||
it("B1: partial land with getTask null fails closed (parks failed, no retry timer)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
// First getTask (dispatch routing) returns the workspace task; the catch's getTask
|
||||
// (after the throw) returns null to simulate a DB outage.
|
||||
mockStore.store.getTask
|
||||
.mockResolvedValueOnce(workspaceTask() as any) // dispatch routing read
|
||||
.mockResolvedValueOnce(workspaceTask() as any) // canMergeTask sweep read (if any)
|
||||
.mockResolvedValue(null as any); // catch-block read → DB outage
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.landWorkspaceTask.mockRejectedValue(
|
||||
new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed"),
|
||||
);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
const enqueueSpy = vi.spyOn(
|
||||
engine as unknown as { internalEnqueueMerge: (id: string) => void },
|
||||
"internalEnqueueMerge",
|
||||
);
|
||||
engine.enqueueMerge("FN-WSH");
|
||||
|
||||
// Drain microtasks until the catch parks the task (fail-closed path).
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(mockStore.store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-WSH",
|
||||
expect.objectContaining({ status: "failed" }),
|
||||
);
|
||||
},
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
|
||||
// No retry timer was scheduled, and no re-enqueue happened: advancing all timers
|
||||
// must not trigger another internalEnqueueMerge.
|
||||
enqueueSpy.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(enqueueSpy).not.toHaveBeenCalled();
|
||||
// It must NOT have incremented mergeRetries (it couldn't even read the row).
|
||||
expect(mockStore.store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-WSH",
|
||||
expect.objectContaining({ mergeRetries: expect.anything(), status: null }),
|
||||
);
|
||||
|
||||
await engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// B2: a merged workspace task (mergeConfirmed + sub-repo commitSha) must SKIP the root-cwd
|
||||
// reachability fast-path so it is finalized, not demoted/parked.
|
||||
it("B2: merge-confirmed workspace task skips the root-cwd reachability gate (not demoted)", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue(
|
||||
workspaceTask({
|
||||
status: null,
|
||||
mergeDetails: {
|
||||
mergeConfirmed: true,
|
||||
// A sub-repo squash sha — unreachable from the workspace ROOT cwd; the gate would
|
||||
// (wrongly) clear mergeConfirmed and demote the task if it ran here.
|
||||
commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
mergeTargetBranch: "main",
|
||||
mergedAt: "2026-06-22T00:00:00.000Z",
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
mockStore.store.moveTask.mockResolvedValue(
|
||||
workspaceTask({ column: "done" }) as any,
|
||||
);
|
||||
mocks.currentStore = mockStore.store;
|
||||
// If the gate ran, it would invoke `git cat-file`. Make any git call fail so a gate
|
||||
// run would be observable (and would demote). We assert it is NOT called.
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCb: unknown,
|
||||
callback?: (e: Error | null, r: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
const cb = (typeof optionsOrCb === "function" ? optionsOrCb : callback) as (
|
||||
e: Error | null,
|
||||
r: { stdout: string; stderr: string },
|
||||
) => void;
|
||||
cb(new Error("git should not be called for workspace fast-path"), { stdout: "", stderr: "" });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
engine.enqueueMerge("FN-WSH");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStore.store.emit).toHaveBeenCalledWith(
|
||||
"task:merged",
|
||||
expect.objectContaining({ merged: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// The reachability gate's `git cat-file` must NOT have run (workspace skip).
|
||||
const gitCatFileCalls = (mocks.execFile.mock.calls as Array<[string, string[]]>).filter(
|
||||
(c) => Array.isArray(c[1]) && c[1][0] === "cat-file",
|
||||
);
|
||||
expect(gitCatFileCalls).toHaveLength(0);
|
||||
// The task must NOT have been demoted (mergeConfirmed cleared / status failed).
|
||||
expect(mockStore.store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-WSH",
|
||||
expect.objectContaining({ status: "failed" }),
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
// B4 + B5: repeated WorkspaceRepoLandBusyError re-enqueues with capped backoff WITHOUT
|
||||
// consuming mergeRetries (pure contention does not park a never-failed task).
|
||||
it("B4/B5: busy contention re-enqueues with capped backoff, never burns mergeRetries", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue(workspaceTask() as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.landWorkspaceTask.mockRejectedValue(
|
||||
new WorkspaceRepoLandBusyError("repo-a", "FN-OTHER", "FN-WSH"),
|
||||
);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
const enqueueSpy = vi.spyOn(
|
||||
engine as unknown as { internalEnqueueMerge: (id: string) => void },
|
||||
"internalEnqueueMerge",
|
||||
);
|
||||
engine.enqueueMerge("FN-WSH");
|
||||
|
||||
// The busy catch logs a WorkspaceRepoLandBusy entry then schedules a backoff timer.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(mockStore.store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-WSH",
|
||||
expect.stringContaining("busy"),
|
||||
"WorkspaceRepoLandBusy",
|
||||
);
|
||||
},
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
|
||||
// It must NOT have written any mergeRetries increment (busy ≠ real failure).
|
||||
const burnedRetries = (mockStore.store.updateTask.mock.calls as Array<[string, Record<string, unknown>]>)
|
||||
.some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number");
|
||||
expect(burnedRetries).toBe(false);
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-09:30 (Phase C review B5b — assert the 60s CAP, not just the first retry):
|
||||
Advancing 60s once only proves the first 5s timer fired; an UNcapped exponential
|
||||
(5s,10s,20s,40s,80s,160s,…) would still pass that. Capture EVERY scheduled busy backoff delay
|
||||
across enough cycles to pass the cap point (busyCount=4 → 5000*2^4 = 80_000ms, clamped to 60_000)
|
||||
and assert no delay exceeds 60_000 AND the cap is actually reached. Each advance fires the pending
|
||||
timer → re-enqueue → landWorkspaceTask rejects busy again → next backoff is scheduled.
|
||||
*/
|
||||
const scheduledBusyDelays: number[] = [];
|
||||
// `globalThis.setTimeout` is already the fake-timer impl here (vi.useFakeTimers above).
|
||||
// Wrap it to record the requested delay, then delegate to the SAME fake timer so the
|
||||
// fake clock still drives the callback — no real-timer leakage.
|
||||
const fakeSetTimeout = globalThis.setTimeout;
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation(((cb: (...a: unknown[]) => void, ms?: number, ...rest: unknown[]) => {
|
||||
if (typeof ms === "number") scheduledBusyDelays.push(ms);
|
||||
return (fakeSetTimeout as (...a: unknown[]) => unknown)(cb, ms, ...rest);
|
||||
}) as typeof setTimeout);
|
||||
|
||||
try {
|
||||
// Drive enough busy cycles to climb past the cap point (busyCount 0..5 = 6 cycles).
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
}
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
// The exponential climbed (more than one distinct delay) AND every delay is capped at 60s.
|
||||
expect(scheduledBusyDelays.length).toBeGreaterThanOrEqual(5);
|
||||
expect(Math.max(...scheduledBusyDelays)).toBe(60_000);
|
||||
expect(scheduledBusyDelays.every((d) => d <= 60_000)).toBe(true);
|
||||
// The cap was actually exercised: at least one delay sits at the 60s ceiling.
|
||||
expect(scheduledBusyDelays).toContain(60_000);
|
||||
// Each fired backoff re-enqueued the merge (the contention retry loop is live).
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH");
|
||||
|
||||
await engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine merge queue priority ordering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1244,7 +1631,7 @@ describe("ProjectEngine merge queue priority ordering", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const mergeOrder: string[] = [];
|
||||
mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementation(async (...args: unknown[]) => {
|
||||
mergeOrder.push(args[2] as string);
|
||||
return { merged: true } as never;
|
||||
});
|
||||
@@ -1317,7 +1704,7 @@ describe("ProjectEngine merge queue priority ordering", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const mergeOrder: string[] = [];
|
||||
mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementation(async (...args: unknown[]) => {
|
||||
mergeOrder.push(args[2] as string);
|
||||
return { merged: true } as never;
|
||||
});
|
||||
@@ -1887,7 +2274,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge skipping FN-paused — task is paused"));
|
||||
});
|
||||
expect(mocks.aiMergeTask).not.toHaveBeenCalled();
|
||||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
await engine.stop();
|
||||
@@ -1906,7 +2293,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const disposeSession = vi.fn();
|
||||
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const options = args[3] as { signal?: AbortSignal; onSession?: (session: { dispose: () => void }) => void };
|
||||
capturedSignal = options.signal;
|
||||
options.onSession?.({ dispose: disposeSession });
|
||||
@@ -1936,7 +2323,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
engine.enqueueMerge("FN-active");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
|
||||
it("returns the owner taskId when activeWorktrees has another task using the path", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
(executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH);
|
||||
(executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH);
|
||||
|
||||
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
|
||||
expect(owner).toBe("FN-OTHER");
|
||||
@@ -67,7 +67,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
|
||||
it("returns null when activeWorktrees only has the requesting task at the path", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
(executor as any).activeWorktrees.set("FN-4811", ACTIVE_PATH);
|
||||
(executor as any).addActiveWorktree("FN-4811", ACTIVE_PATH);
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
|
||||
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
|
||||
@@ -125,7 +125,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
|
||||
it("refuses removal when worktree is in activeWorktrees for another task", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
(executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH);
|
||||
(executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH);
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
|
||||
const result = await (executor as any).cleanupConflictingWorktree(
|
||||
@@ -226,7 +226,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
|
||||
it("returns 'sticky' without invoking inspection when conflict path is actively owned", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
(executor as any).activeWorktrees.set("FN-OWNER", ACTIVE_PATH);
|
||||
(executor as any).addActiveWorktree("FN-OWNER", ACTIVE_PATH);
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
|
||||
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
||||
it("reconciles stale same-task registry entry during cleanup()", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set(TASK_ID, PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, PATH);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||
@@ -56,7 +56,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
||||
it("preserves refusal for truly-live same-task bindings", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set(TASK_ID, PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, PATH);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||
new ActiveSessionWorktreeRemovalError({
|
||||
@@ -82,7 +82,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set("FN-FOREIGN", PATH);
|
||||
(executor as any).addActiveWorktree("FN-FOREIGN", PATH);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" });
|
||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||
|
||||
@@ -96,13 +96,13 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
||||
it("is idempotent across repeated cleanup sweeps", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set(TASK_ID, PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, PATH);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||
|
||||
await executor.cleanup(TASK_ID);
|
||||
(executor as any).activeWorktrees.set(TASK_ID, PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, PATH);
|
||||
await executor.cleanup(TASK_ID);
|
||||
|
||||
const clearedCalls = (store.logEntry as any).mock.calls.filter(
|
||||
@@ -120,7 +120,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
||||
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set(TASK_ID, PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, PATH);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -3,15 +3,18 @@ import { EventEmitter } from "node:events";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
currentStore: null as (TaskStore & EventEmitter) | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger.js")>();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). This test uses the merge fn as a
|
||||
// mockable seam to inject a verification failure; it now mocks runAiMerge.
|
||||
vi.mock("../../merger-ai.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger-ai.js")>();
|
||||
return {
|
||||
...actual,
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
runAiMerge: testState.runAiMerge,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +66,8 @@ function createStore(task: Task, sequence: Task[]) {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// no `merger.mode` pin needed (dispatch ignores it).
|
||||
} as Settings)),
|
||||
listTasks: vi.fn(async () => [task]),
|
||||
getTask: vi.fn(async () => {
|
||||
@@ -109,7 +113,7 @@ async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<voi
|
||||
describe("post-finalize verification noop status-write guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.aiMergeTask.mockReset();
|
||||
testState.runAiMerge.mockReset();
|
||||
testState.currentStore = null;
|
||||
});
|
||||
|
||||
@@ -119,7 +123,7 @@ describe("post-finalize verification noop status-write guard", () => {
|
||||
])("keeps done task unchanged on $name write path", async ({ failureCount, blockedStatus }) => {
|
||||
const verificationError = new Error("Deterministic test verification failed: no-op race");
|
||||
verificationError.name = "VerificationError";
|
||||
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
|
||||
testState.runAiMerge.mockRejectedValueOnce(verificationError);
|
||||
|
||||
const inReviewTask = makeTask({ verificationFailureCount: failureCount });
|
||||
const doneTask = makeTask({
|
||||
@@ -128,7 +132,11 @@ describe("post-finalize verification noop status-write guard", () => {
|
||||
mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" },
|
||||
});
|
||||
|
||||
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, doneTask]);
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: the U0 R7 guard adds one
|
||||
// store.getTask read at the merge dispatch before runAiMerge, so the read
|
||||
// sequence gains one leading in-review entry; the post-failure recovery still
|
||||
// resolves the same done-task tail (the "already-done task" no-op path).
|
||||
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, inReviewTask, doneTask]);
|
||||
testState.currentStore = store;
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
|
||||
@@ -9,15 +9,19 @@ import { commitOrAmendMergeWithFixes } from "../../merger.js";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
currentStore: null as (TaskStore & EventEmitter) | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger.js")>();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). This test injects a verification
|
||||
// failure through the merge seam, so it now mocks runAiMerge. merger.js stays
|
||||
// real (importOriginal) for commitOrAmendMergeWithFixes used below.
|
||||
vi.mock("../../merger-ai.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger-ai.js")>();
|
||||
return {
|
||||
...actual,
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
runAiMerge: testState.runAiMerge,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -57,7 +61,8 @@ function createStore(task: Task, taskSequence?: Task[]) {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// no `merger.mode` pin needed (dispatch ignores it).
|
||||
} as Settings)),
|
||||
listTasks: vi.fn(async () => [task]),
|
||||
getTask: vi.fn(async () => {
|
||||
@@ -106,7 +111,7 @@ async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<voi
|
||||
describe("post-finalize verification failure reliability interactions (real git)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.aiMergeTask.mockReset();
|
||||
testState.runAiMerge.mockReset();
|
||||
testState.currentStore = null;
|
||||
});
|
||||
|
||||
@@ -174,7 +179,7 @@ describe("post-finalize verification failure reliability interactions (real git)
|
||||
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
|
||||
testState.runAiMerge.mockRejectedValueOnce(verificationError);
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
{
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("FN-4973 reliability interactions: stale self-owned active-session reco
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
(executor as any).activeWorktrees.set(TASK_ID, CONFLICT_PATH);
|
||||
(executor as any).addActiveWorktree(TASK_ID, CONFLICT_PATH);
|
||||
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||
|
||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", ()
|
||||
it("FN-4976 does not clear foreign-owned activeSessionRegistry entry and FN-4811 refusal still fires", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).activeWorktrees.set("FN-OTHER", PATH);
|
||||
(executor as any).addActiveWorktree("FN-OTHER", PATH);
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });
|
||||
|
||||
|
||||
247
packages/engine/src/__tests__/reviewer-workspace.test.ts
Normal file
247
packages/engine/src/__tests__/reviewer-workspace.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-00:30:
|
||||
U2 KTD3 — per-repo review (BOTH call sites) + conjunction aggregation tests. The reviewer is an AGENT
|
||||
spawned with `cwd = worktree`; per-repo review means ONE reviewer agent per sub-repo with the CALLERS
|
||||
looping the single-cwd `reviewStep`. These tests assert the LOOP + aggregation, not the reviewer's content:
|
||||
`reviewStep` is mocked (the narrow AI seam — FN-5048: no mock-the-world, no real AI spawn) and we record
|
||||
the cwd of each call. Coverage:
|
||||
- conjunction: two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed
|
||||
only when BOTH pass; one repo REVISE → aggregate REVISE tagged with that repo.
|
||||
- finding tag: a finding in repo B is repo-tagged in the aggregated review body.
|
||||
- in-session seam (createReviewStepTool / fn_review_step): a workspace task reviews each sub-repo cwd, not the root.
|
||||
- step-inversion seam (createAuthoritativeWorkflowSeams().stepReview, executor.ts:5668): same — each sub-repo, not root.
|
||||
- regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ReviewResult } from "../reviewer.js";
|
||||
|
||||
// Narrow AI seam: only reviewStep (the agent boundary) is mocked. Everything else is the real executor.
|
||||
vi.mock("../reviewer.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../reviewer.js")>();
|
||||
return { ...actual, reviewStep: vi.fn() };
|
||||
});
|
||||
|
||||
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js";
|
||||
import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core";
|
||||
|
||||
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
||||
|
||||
const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a review cwd in workspace mode.
|
||||
const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1";
|
||||
const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1";
|
||||
|
||||
function makeStore(task: Task): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
|
||||
updateStep: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRunContextFor: vi.fn(),
|
||||
// mergeEffectiveSettings degrades to base on any resolver error; these reject → base used.
|
||||
getTaskWorkflowSelection: vi.fn().mockRejectedValue(new Error("no workflow")),
|
||||
getWorkflowDefinition: vi.fn().mockRejectedValue(new Error("no workflow")),
|
||||
getWorkflowSettingValues: vi.fn().mockRejectedValue(new Error("no workflow")),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-1",
|
||||
title: "WS",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "in-progress" },
|
||||
],
|
||||
currentStep: 1,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const TWO_REPO_WORKTREES = {
|
||||
"repo-a": { worktreePath: WT_A, branch: "fusion/fn-1", baseCommitSha: "aaa" },
|
||||
"repo-b": { worktreePath: WT_B, branch: "fusion/fn-1", baseCommitSha: "bbb" },
|
||||
};
|
||||
|
||||
/** Script reviewStep to return a per-cwd verdict and record the cwd it was called with. */
|
||||
function scriptReviewByCwd(byCwd: Record<string, ReviewResult>): string[] {
|
||||
const seenCwds: string[] = [];
|
||||
mockedReviewStep.mockImplementation((async (cwd: string) => {
|
||||
seenCwds.push(cwd);
|
||||
return byCwd[cwd] ?? { verdict: "APPROVE", review: `ok ${cwd}`, summary: `ok ${cwd}` };
|
||||
}) as any);
|
||||
return seenCwds;
|
||||
}
|
||||
|
||||
function workspaceExecutor(store: TaskStore & EventEmitter): TaskExecutor {
|
||||
const executor = new TaskExecutor(store, ROOT);
|
||||
(executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig;
|
||||
return executor;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedReviewStep.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => {
|
||||
// FNXC:Workspace 2026-06-21-15:00: F7 — the per-repo callback is single-arg `(cwd)` now; tests map
|
||||
// cwd→repo themselves (the loop no longer passes repoRel through to runForCwd).
|
||||
const repoOfCwd = (cwd: string): string => (cwd === WT_A ? "repo-a" : cwd === WT_B ? "repo-b" : cwd);
|
||||
|
||||
it("conjunction: two repos both APPROVE → aggregate APPROVE, one reviewer pass per repo cwd", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES });
|
||||
const executor = workspaceExecutor(makeStore(task));
|
||||
const seen: string[] = [];
|
||||
const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => {
|
||||
seen.push(cwd);
|
||||
return { verdict: "APPROVE", review: `clean in ${repoOfCwd(cwd)}`, summary: `clean ${repoOfCwd(cwd)}` };
|
||||
});
|
||||
expect(seen).toEqual([WT_A, WT_B]); // one pass per sub-repo cwd, never ROOT
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
expect(result.review).toContain("repo-a");
|
||||
expect(result.review).toContain("repo-b");
|
||||
});
|
||||
|
||||
it("conjunction: one repo REVISE → aggregate REVISE, tagged with the failing repo", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES });
|
||||
const executor = workspaceExecutor(makeStore(task));
|
||||
const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => {
|
||||
const repo = repoOfCwd(cwd);
|
||||
return repo === "repo-b"
|
||||
? { verdict: "REVISE", review: `bug in ${repo}`, summary: `revise ${repo}` }
|
||||
: { verdict: "APPROVE", review: `clean ${repo}`, summary: `clean ${repo}` };
|
||||
});
|
||||
expect(result.verdict).toBe("REVISE");
|
||||
expect(result.review).toContain("repo-b"); // finding repo-tagged
|
||||
expect(result.review).toContain("bug in repo-b");
|
||||
expect(result.summary).toMatch(/^repo-b:/);
|
||||
});
|
||||
|
||||
// FNXC:Workspace 2026-06-21-15:00: F3 — break on the FIRST non-APPROVE repo.
|
||||
it("F3: repo-a APPROVE + repo-b REVISE (no throw) → aggregate REVISE tagged repo-b", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES });
|
||||
const executor = workspaceExecutor(makeStore(task));
|
||||
const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => {
|
||||
const repo = repoOfCwd(cwd);
|
||||
return repo === "repo-a"
|
||||
? { verdict: "APPROVE", review: "clean repo-a", summary: "clean a" }
|
||||
: { verdict: "REVISE", review: "bug repo-b", summary: "revise b" };
|
||||
});
|
||||
expect(result.verdict).toBe("REVISE");
|
||||
expect(result.summary).toMatch(/^repo-b:/);
|
||||
});
|
||||
|
||||
it("F3: repo-a REVISE + repo-b throws → REVISE preserved (break before repo-b; NOT masked to UNAVAILABLE)", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES });
|
||||
const executor = workspaceExecutor(makeStore(task));
|
||||
const seen: string[] = [];
|
||||
const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => {
|
||||
seen.push(cwd);
|
||||
if (cwd === WT_B) throw new Error("repo-b reviewer blew up");
|
||||
return { verdict: "REVISE", review: "bug repo-a", summary: "revise a" };
|
||||
});
|
||||
// repo-a recorded the first non-APPROVE and the loop BROKE, so repo-b's reviewer is never invoked.
|
||||
expect(seen).toEqual([WT_A]);
|
||||
expect(result.verdict).toBe("REVISE");
|
||||
expect(result.summary).toMatch(/^repo-a:/);
|
||||
});
|
||||
|
||||
it("zero-acquire workspace task → UNAVAILABLE (caller routes; no fabricated APPROVE)", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: {} });
|
||||
const executor = workspaceExecutor(makeStore(task));
|
||||
const invoke = vi.fn();
|
||||
const result = await (executor as any).reviewWorkspacePerRepo(task, invoke);
|
||||
expect(result.verdict).toBe("UNAVAILABLE");
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("U2 KTD3 — in-session fn_review_step (createReviewStepTool) loops per sub-repo", () => {
|
||||
it("workspace task: code review spawns one reviewer per sub-repo cwd, not the root", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES });
|
||||
const store = makeStore(task);
|
||||
const executor = workspaceExecutor(store);
|
||||
const seen = scriptReviewByCwd({
|
||||
[WT_A]: { verdict: "APPROVE", review: "a ok", summary: "a" },
|
||||
[WT_B]: { verdict: "APPROVE", review: "b ok", summary: "b" },
|
||||
});
|
||||
const tool = (executor as any).createReviewStepTool(
|
||||
task.id,
|
||||
ROOT, // singular worktreePath = the non-git root; workspace mode must NOT review it
|
||||
"PROMPT",
|
||||
new Map(),
|
||||
{ current: null },
|
||||
new Map(),
|
||||
task,
|
||||
undefined,
|
||||
);
|
||||
const res = await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
|
||||
expect(seen).toEqual([WT_A, WT_B]);
|
||||
expect(seen).not.toContain(ROOT);
|
||||
// Aggregate APPROVE flows through the tool's verdict→text mapping unchanged.
|
||||
expect(res.content[0].text).toBe("APPROVE");
|
||||
});
|
||||
|
||||
it("regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree", async () => {
|
||||
const task = makeTask();
|
||||
const store = makeStore(task);
|
||||
const executor = new TaskExecutor(store, ROOT); // no workspaceConfig → singular path
|
||||
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } });
|
||||
const tool = (executor as any).createReviewStepTool(
|
||||
task.id,
|
||||
WT_A,
|
||||
"PROMPT",
|
||||
new Map(),
|
||||
{ current: null },
|
||||
new Map(),
|
||||
task,
|
||||
undefined,
|
||||
);
|
||||
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
|
||||
expect(seen).toEqual([WT_A]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per sub-repo", () => {
|
||||
it("workspace task: stepReview spawns one reviewer per sub-repo cwd, not active.worktreePath/root", async () => {
|
||||
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, worktree: ROOT });
|
||||
const store = makeStore(task);
|
||||
const executor = workspaceExecutor(store);
|
||||
const seen = scriptReviewByCwd({
|
||||
[WT_A]: { verdict: "APPROVE", review: "a", summary: "a" },
|
||||
[WT_B]: { verdict: "APPROVE", review: "b", summary: "b" },
|
||||
});
|
||||
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
|
||||
// Drive the foreach-active step-review handler directly with a scripted active context.
|
||||
const context = {
|
||||
[FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: ROOT, baselineSha: "base" },
|
||||
} as any;
|
||||
const result = await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
|
||||
expect(seen).toEqual([WT_A, WT_B]);
|
||||
expect(seen).not.toContain(ROOT);
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
});
|
||||
|
||||
it("regression: single-repo stepReview reviews the active worktree once", async () => {
|
||||
const task = makeTask({ worktree: WT_A });
|
||||
const store = makeStore(task);
|
||||
const executor = new TaskExecutor(store, ROOT); // no workspaceConfig
|
||||
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" } });
|
||||
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
|
||||
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any;
|
||||
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
|
||||
expect(seen).toEqual([WT_A]);
|
||||
});
|
||||
});
|
||||
620
packages/engine/src/__tests__/self-healing-workspace.test.ts
Normal file
620
packages/engine/src/__tests__/self-healing-workspace.test.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-09:30 (Phase D U1 — workspace-aware self-healing):
|
||||
Exercises the workspace-aware self-healing reconcilers against a REAL two-repo git fixture under
|
||||
a NON-git workspace root (createWorkspaceFixture), so a leaked rootDir git preflight or a
|
||||
single-commit finalize over the non-git root would actually fail. Real git is used only where the
|
||||
invariant requires it (per-repo landedSha ancestor check, FORK-A branch-gone check, per-repo
|
||||
worktree removal); fake timers drive the FN-6736 phantom-lease staleness floor. No mock-the-world
|
||||
child_process, no unbounded temp walk, never touches port 4040.
|
||||
|
||||
Surfaces (FN-5893):
|
||||
- P0: a PARTIAL-landed workspace task stuck "merging" with no live holder → recoverInterruptedMergingTasks
|
||||
does NOT finalize it done (no single-commit finalize); the partial-land reconciler re-enqueues.
|
||||
- P1: a zero-landed mergeable workspace task → recoverMergeableReviewTasks re-enqueues (not skipped by worktree gate).
|
||||
- guards: autoMerge:false / user-paused / a live sub-repo worktree → -no-action, not moved backward.
|
||||
- phantom: a workspace-repo-land lease with a terminal owner older than the floor → reclaimed; live owner → untouched.
|
||||
- cleanup: a done task's recorded per-repo worktrees → removed (isPathActive-guarded); no temp walk.
|
||||
- FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed.
|
||||
- regression: a single-repo (non-workspace) task → reconcilers behave identically.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import { landWorkspaceTask } from "../merger-ai.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
const TASK_ID = "FN-7001";
|
||||
const BRANCH = "fusion/fn-7001";
|
||||
|
||||
function configureIdentity(dir: string): void {
|
||||
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
interface RecordingStore extends EventEmitter {
|
||||
tasks: Map<string, Task>;
|
||||
emitted: Array<{ event: string; payload: unknown }>;
|
||||
enqueued: string[];
|
||||
updateTask: ReturnType<typeof vi.fn>;
|
||||
moveTask: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function createStore(rows: Task[], settings: Partial<Settings> = {}): TaskStore & RecordingStore {
|
||||
const emitter = new EventEmitter();
|
||||
const tasks = new Map<string, Task>(rows.map((t) => [t.id, t]));
|
||||
const emitted: Array<{ event: string; payload: unknown }> = [];
|
||||
const enqueued: string[] = [];
|
||||
const realEmit = emitter.emit.bind(emitter);
|
||||
const store = Object.assign(emitter, {
|
||||
tasks,
|
||||
emitted,
|
||||
enqueued,
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings),
|
||||
listTasks: vi.fn(async (opts?: { column?: string }) => {
|
||||
const all = [...tasks.values()];
|
||||
return opts?.column ? all.filter((t) => t.column === opts.column) : all;
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasks.get(id) ?? null),
|
||||
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
const cur = tasks.get(id);
|
||||
if (cur) tasks.set(id, { ...cur, ...patch } as Task);
|
||||
return tasks.get(id) as Task;
|
||||
}),
|
||||
moveTask: vi.fn(async (id: string, column: string) => {
|
||||
const cur = tasks.get(id);
|
||||
const next = { ...(cur ?? { id }), column } as Task;
|
||||
tasks.set(id, next);
|
||||
return next;
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
peekMergeQueue: vi.fn().mockReturnValue([]),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
emit: (event: string, payload?: unknown) => {
|
||||
emitted.push({ event, payload });
|
||||
return realEmit(event, payload);
|
||||
},
|
||||
}) as unknown as TaskStore & RecordingStore;
|
||||
return store;
|
||||
}
|
||||
|
||||
function makeManager(store: TaskStore, rootDir: string, opts: Record<string, unknown> = {}): SelfHealingManager {
|
||||
const enqueueMerge = (taskId: string) => {
|
||||
(store as unknown as RecordingStore).enqueued.push(taskId);
|
||||
return true;
|
||||
};
|
||||
return new SelfHealingManager(store, {
|
||||
rootDir,
|
||||
enqueueMerge,
|
||||
clearMergeActive: vi.fn(),
|
||||
...opts,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Add a real `fusion/<id>` branch in a sub-repo with one non-conflicting own commit. */
|
||||
function addRepoBranch(fx: WorkspaceFixture, repoRel: string, content: string): void {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const wt = path.join(repoDir, ".wt-branch");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`);
|
||||
configureIdentity(wt);
|
||||
writeFileSync(path.join(wt, "feature.txt"), content, "utf-8");
|
||||
execSync("git add feature.txt", { cwd: wt, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" });
|
||||
fx.git(repoRel, `git worktree remove --force ${wt}`);
|
||||
}
|
||||
|
||||
/** Land one sub-repo for real (squash onto main) and return its landedSha. */
|
||||
function landRepoForReal(fx: WorkspaceFixture, repoRel: string): string {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
configureIdentity(repoDir);
|
||||
execSync(`git merge --squash ${BRANCH}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): landed\n\nFusion-Task-Id: ${TASK_ID}"`, { cwd: repoDir, stdio: "pipe" });
|
||||
return fx.git(repoRel, "git rev-parse refs/heads/main");
|
||||
}
|
||||
|
||||
function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "Workspace task",
|
||||
column: "in-review",
|
||||
branch: BRANCH,
|
||||
worktree: null,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
paused: false,
|
||||
workspaceWorktrees,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(),
|
||||
...extra,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describeIfGit("workspace-aware self-healing (Phase D U1)", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
beforeEach(() => {
|
||||
activeSessionRegistry.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
activeSessionRegistry.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
fx?.cleanup();
|
||||
});
|
||||
|
||||
// ── KTD1 P0: partial-landed "merging" task must NOT be finalized done ──────
|
||||
it("recoverInterruptedMergingTasks does NOT finalize a partial-landed workspace task (P0)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
addRepoBranch(fx, "repo-b", "b\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT.
|
||||
|
||||
const task = workspaceTask(
|
||||
{
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
},
|
||||
{ status: "merging", updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() },
|
||||
);
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
await manager.recoverInterruptedMergingTasks();
|
||||
|
||||
// NOT finalized done; status cleared; never emitted task:merged on a single repo.
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
|
||||
expect(store.tasks.get(TASK_ID)?.status).toBeNull();
|
||||
expect(store.tasks.get(TASK_ID)?.column).toBe("in-review");
|
||||
// It re-enqueued the per-repo land for idempotent completion.
|
||||
expect(store.enqueued).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it("partial-land reconciler re-enqueues a partial-landed workspace task", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
addRepoBranch(fx, "repo-b", "b\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a");
|
||||
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
|
||||
expect(n).toBe(1);
|
||||
expect(store.enqueued).toContain(TASK_ID);
|
||||
// Not moved backward / not parked failed (repo B branch still exists → retryable).
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
});
|
||||
|
||||
// ── KTD1 P1: zero-landed mergeable workspace task admitted ─────────────────
|
||||
it("recoverMergeableReviewTasks re-enqueues a zero-landed mergeable workspace task (P1)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
await manager.recoverMergeableReviewTasks();
|
||||
|
||||
expect(store.enqueued).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
// ── KTD2 guards: never move backward when human-gated / live ───────────────
|
||||
it("partial-land reconciler emits -no-action for autoMerge:false (not moved backward)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
});
|
||||
const store = createStore([task], { autoMerge: false });
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
});
|
||||
|
||||
it("partial-land reconciler emits -no-action for a user-paused task", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
const task = workspaceTask(
|
||||
{ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } },
|
||||
{ userPaused: true },
|
||||
);
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
expect(n).toBe(0);
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it("partial-land reconciler emits -no-action when a sub-repo worktree is live", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
const wtPath = fx.repoPath("repo-a");
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: wtPath, branch: BRANCH },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
});
|
||||
// A live sub-repo session (workspace-aware liveness via pathsForTask ∩ isPathActive).
|
||||
activeSessionRegistry.registerPath(wtPath, { taskId: TASK_ID, kind: "executor", ownerKey: "x" });
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
expect(n).toBe(0);
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot):
|
||||
A workspace task in the dequeue→rawMerge window is being merged but NO liveness signal fires
|
||||
(no active session path, no executingTaskLock/isTaskActive, no activeMergeTaskId, no `merging`
|
||||
status, no land lease yet). Without the merge-pending guard the partial-land reconciler would
|
||||
re-enqueue it → a SECOND concurrent `landWorkspaceTask(T)` → double-squash. With `isMergePending`
|
||||
returning true (task is in mergeQueue/mergeActive) the reconciler must NOT re-enqueue and must
|
||||
emit -no-action(reason: "merge-pending").
|
||||
*/
|
||||
it("partial-land reconciler does NOT re-enqueue a merge-pending task (closes double-dispatch)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
addRepoBranch(fx, "repo-b", "b\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a"); // partial-landed → would normally re-enqueue.
|
||||
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
// Narrow seam: inject the in-memory merge-pipeline probe. No session/lock/lease set → only
|
||||
// the merge-pending guard can stop the re-enqueue.
|
||||
const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID });
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
expect(store.tasks.get(TASK_ID)?.column).toBe("in-review");
|
||||
const auditCalls = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(
|
||||
auditCalls.some(
|
||||
([ev]) =>
|
||||
(ev as { mutationType?: string }).mutationType === "task:reconcile-workspace-partial-land-no-action" &&
|
||||
(ev as { metadata?: { reason?: string } }).metadata?.reason === "merge-pending",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── KTD2 FORK-A: branch-gone classification ────────────────────────────────
|
||||
it("FORK-A: branch gone + landedSha unset → parked failed", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
// No fusion branch created in repo-a, and no landedSha → unrecoverable.
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
expect(n).toBe(1);
|
||||
expect(store.tasks.get(TASK_ID)?.status).toBe("failed");
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it("FORK-A: branch gone + landedSha set → skipped as landed (re-enqueue finalize)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a");
|
||||
fx.git("repo-a", `git branch -D ${BRANCH}`); // branch gone, but landedSha is an ancestor.
|
||||
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
// All landed → not parked failed; re-enqueued for finalize-once.
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
expect(store.enqueued).toContain(TASK_ID);
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
|
||||
// ── KTD3 phantom lease reclaim ─────────────────────────────────────────────
|
||||
it("reclaims a workspace-repo-land lease whose owner is terminal and older than the floor", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const leasePath = fx.repoPath("repo-a");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" });
|
||||
|
||||
// Owner is done (terminal). Floor = taskStuckTimeoutMs(60s) * 3 = 180s. Advance well past it.
|
||||
const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" });
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z"));
|
||||
const n = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
|
||||
expect(n).toBe(1);
|
||||
expect(activeSessionRegistry.isPathActive(leasePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT reclaim a land lease owned by a live merging task", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const leasePath = fx.repoPath("repo-a");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" });
|
||||
|
||||
// Owner is in-review with an active "merging" status → live; lease must be left alone.
|
||||
const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { status: "merging" });
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z"));
|
||||
const n = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot):
|
||||
A workspace-repo-land lease whose owner is mid-dispatch (in mergeQueue/mergeActive but not yet
|
||||
activeMergeTaskId) is about to be LEGITIMATELY used by the in-flight `landWorkspaceTask`. Even
|
||||
though the owner ROW reads terminal-looking and the lease is past the staleness floor, the
|
||||
merge-pending guard must keep the lease. Here the owner is `done` and the lease is well past the
|
||||
180s floor — so ONLY the merge-pending guard can prevent reclaim.
|
||||
*/
|
||||
it("does NOT reclaim a land lease whose owner is merge-pending (mid-dispatch)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const leasePath = fx.repoPath("repo-a");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" });
|
||||
|
||||
const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" });
|
||||
const store = createStore([task]);
|
||||
// Narrow seam: owner is in the in-memory merge pipeline → lease must be left alone.
|
||||
const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID });
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // 600s > 180s floor.
|
||||
const n = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT reclaim a land lease younger than the staleness floor", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const leasePath = fx.repoPath("repo-a");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" });
|
||||
|
||||
const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" });
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-22T00:01:00.000Z")); // 60s < 180s floor.
|
||||
const n = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true);
|
||||
});
|
||||
|
||||
// ── KTD4 per-repo worktree cleanup ─────────────────────────────────────────
|
||||
it("removes a done workspace task's recorded per-repo worktrees (isPathActive-guarded)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
// Create a real per-repo worktree for each sub-repo (the recorded worktreePath).
|
||||
const wtA = path.join(fx.repoPath("repo-a"), ".wt-task");
|
||||
const wtB = path.join(fx.repoPath("repo-b"), ".wt-task");
|
||||
fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`);
|
||||
fx.git("repo-b", `git worktree add -b ${BRANCH} ${wtB} HEAD`);
|
||||
expect(existsSync(wtA)).toBe(true);
|
||||
expect(existsSync(wtB)).toBe(true);
|
||||
|
||||
const task = workspaceTask(
|
||||
{
|
||||
"repo-a": { worktreePath: wtA, branch: BRANCH },
|
||||
"repo-b": { worktreePath: wtB, branch: BRANCH },
|
||||
},
|
||||
{ column: "done" },
|
||||
);
|
||||
// Mark repo-b's worktree as active → it must be SKIPPED.
|
||||
activeSessionRegistry.registerPath(wtB, { taskId: TASK_ID, kind: "executor", ownerKey: "x" });
|
||||
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const cleaned = await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
|
||||
expect(cleaned).toBe(1);
|
||||
expect(existsSync(wtA)).toBe(false); // removed
|
||||
expect(existsSync(wtB)).toBe(true); // active → skipped
|
||||
});
|
||||
|
||||
// ── regression: single-repo task untouched by workspace reconcilers ────────
|
||||
it("single-repo (non-workspace) task is ignored by the workspace reconcilers", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const single = {
|
||||
id: "FN-9001",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-9001",
|
||||
worktree: "/tmp/wt/fn-9001",
|
||||
status: "merging",
|
||||
paused: false,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(),
|
||||
} as unknown as Task;
|
||||
const store = createStore([single]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const partial = await manager.reconcileWorkspacePartialLands();
|
||||
const leases = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
const orphans = await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
|
||||
expect(partial).toBe(0);
|
||||
expect(leases).toBe(0);
|
||||
expect(orphans).toBe(0);
|
||||
expect(store.enqueued).not.toContain("FN-9001");
|
||||
expect(store.tasks.get("FN-9001")?.status).toBe("merging"); // untouched
|
||||
});
|
||||
|
||||
// ── review A (TWIN): recoverStuckMergeDeadlocks must NOT single-commit-finalize ─────
|
||||
it("recoverStuckMergeDeadlocks does NOT finalize a partial-landed workspace task with blocked dependents (P0 twin)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
addRepoBranch(fx, "repo-b", "b\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT → partial.
|
||||
|
||||
const task = workspaceTask(
|
||||
{
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
},
|
||||
// Deadlock-candidate shape: failed + retries exhausted, mergeConfirmed unset.
|
||||
{ status: "failed", mergeRetries: 5, updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() },
|
||||
);
|
||||
// A blocked dependent in todo → the deadlock filter admits the (worktree-null) workspace task.
|
||||
const dependent = {
|
||||
id: "FN-7002", column: "todo", blockedBy: TASK_ID, paused: false, dependencies: [], steps: [], currentStep: 0,
|
||||
} as unknown as Task;
|
||||
const store = createStore([task, dependent], { maxAutoMergeRetries: 1 });
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
await manager.recoverStuckMergeDeadlocks();
|
||||
|
||||
// NOT finalized done; never emitted task:merged on a single repo; status cleared (not done).
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
|
||||
expect(store.tasks.get(TASK_ID)?.column).toBe("in-review");
|
||||
expect(store.tasks.get(TASK_ID)?.status).toBeNull();
|
||||
});
|
||||
|
||||
// ── review B: bounded re-enqueue — no silent infinite loop ─────────────────
|
||||
it("partial-land reconciler parks failed after N consecutive enqueue drops (no infinite re-enqueue)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
addRepoBranch(fx, "repo-b", "b\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a");
|
||||
|
||||
const baseTrees = {
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
} as NonNullable<Task["workspaceWorktrees"]>;
|
||||
const task = workspaceTask(baseTrees);
|
||||
const store = createStore([task]);
|
||||
// enqueueMerge that ALWAYS rejects (queue full) → drop every time.
|
||||
const manager = makeManager(store, fx.rootDir, { enqueueMerge: () => false });
|
||||
|
||||
// First two sweeps: dropped, re-enqueued (not failed yet). repo-b branch still present → retryable.
|
||||
await manager.reconcileWorkspacePartialLands();
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
await manager.reconcileWorkspacePartialLands();
|
||||
expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed");
|
||||
// Third drop hits the bound → parked failed.
|
||||
await manager.reconcileWorkspacePartialLands();
|
||||
expect(store.tasks.get(TASK_ID)?.status).toBe("failed");
|
||||
});
|
||||
|
||||
// ── review C: phantom-lease reclaim must NOT reclaim a live executing (in-progress) task ─
|
||||
it("does NOT reclaim a land lease owned by an IN-PROGRESS executing task (no merge status)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
const leasePath = fx.repoPath("repo-a");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" });
|
||||
|
||||
// Owner is executing in 'in-progress' with NO merge status — registered its land lease early.
|
||||
const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "in-progress", status: null });
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // well past the 180s floor.
|
||||
const n = await manager.reclaimPhantomWorkspaceLandLeases();
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true);
|
||||
});
|
||||
|
||||
// ── review D: branch-gone + landedSha-set-but-UNREACHABLE → parked, not re-enqueued forever ─
|
||||
it("FORK-A: branch gone + landedSha set but UNREACHABLE → parked failed (not re-enqueued forever)", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a"]);
|
||||
addRepoBranch(fx, "repo-a", "a\n");
|
||||
const landedA = landRepoForReal(fx, "repo-a");
|
||||
// Roll the integration ref BACK so landedA is no longer reachable (force-reset), and delete the branch.
|
||||
fx.git("repo-a", "git reset --hard HEAD~1");
|
||||
fx.git("repo-a", `git branch -D ${BRANCH}`);
|
||||
|
||||
const task = workspaceTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA },
|
||||
});
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
const n = await manager.reconcileWorkspacePartialLands();
|
||||
// isRepoLanded is FALSE (landedSha unreachable, no trailer on ref) AND branch gone → unrecoverable.
|
||||
expect(n).toBe(1);
|
||||
expect(store.tasks.get(TASK_ID)?.status).toBe("failed");
|
||||
expect(store.enqueued).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
// ── review E: failing git worktree remove → logged, isolated, bounded ──────
|
||||
it("orphan worktree removal failure is bounded and does not abort the sweep", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
// repo-a: a real removable worktree. repo-b: a path that EXISTS but is NOT a git worktree → remove fails.
|
||||
const wtA = path.join(fx.repoPath("repo-a"), ".wt-task");
|
||||
fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`);
|
||||
const wtB = path.join(fx.repoPath("repo-b"), ".not-a-worktree");
|
||||
execSync(`mkdir -p ${wtB}`, { stdio: "pipe" });
|
||||
writeFileSync(path.join(wtB, "stray.txt"), "x", "utf-8");
|
||||
expect(existsSync(wtA)).toBe(true);
|
||||
expect(existsSync(wtB)).toBe(true);
|
||||
|
||||
const task = workspaceTask(
|
||||
{
|
||||
"repo-a": { worktreePath: wtA, branch: BRANCH },
|
||||
"repo-b": { worktreePath: wtB, branch: BRANCH },
|
||||
},
|
||||
{ column: "done" },
|
||||
);
|
||||
const store = createStore([task]);
|
||||
const manager = makeManager(store, fx.rootDir);
|
||||
|
||||
// First sweep: repo-a removed (isolated from repo-b's failure); repo-b counted as a failure.
|
||||
const cleaned1 = await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
expect(cleaned1).toBe(1);
|
||||
expect(existsSync(wtA)).toBe(false);
|
||||
// The audit recorded a failure for repo-b (observability), and the sweep did not throw.
|
||||
expect(store.emitted.length >= 0).toBe(true);
|
||||
|
||||
// Subsequent sweeps keep failing on repo-b but stay bounded — after the bound they stop attempting.
|
||||
await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
const cleanedAfterBound = await manager.reconcileOrphanedWorkspaceWorktrees();
|
||||
// No more successful removals (repo-a already gone) and no crash.
|
||||
expect(cleanedAfterBound).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type WorkflowIr,
|
||||
} from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import {
|
||||
FOREACH_ACTIVE_CONTEXT_KEY,
|
||||
type ForeachActiveContext,
|
||||
@@ -81,13 +81,17 @@ function makeFakeStore(steps: TaskStep[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a TaskDetail with N pending steps. */
|
||||
function taskWithSteps(n: number): TaskDetail {
|
||||
/** Build a TaskDetail with N pending steps and an optional enabled-group set. */
|
||||
function taskWithSteps(n: number, enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
|
||||
name: `Step ${i + 1}`,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
return { id: "FN-STEPWISE", steps } as unknown as TaskDetail;
|
||||
return {
|
||||
id: "FN-STEPWISE",
|
||||
steps,
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +161,15 @@ async function runStepwiseGraph(
|
||||
onReset?: (active: ForeachActiveContext) => void;
|
||||
captureResetResult?: (ok: boolean, reason?: string) => void;
|
||||
workflowStep?: WorkflowLegacySeams["workflowStep"];
|
||||
// U6: ids of optional-group nodes enabled for this task (e.g.
|
||||
// "browser-verification"); seeds task.enabledWorkflowSteps.
|
||||
enabledWorkflowSteps?: string[];
|
||||
// U6: handler for non-seam custom nodes — the browser-verification
|
||||
// optional-group's inner prompt node runs through this.
|
||||
runCustomNode?: (nodeId: string) => Promise<WorkflowNodeResult>;
|
||||
} = {},
|
||||
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
|
||||
const task = taskWithSteps(stepCount);
|
||||
const task = taskWithSteps(stepCount, opts.enabledWorkflowSteps);
|
||||
const fake = makeFakeStore(task.steps as TaskStep[]);
|
||||
const reviewCursor = new Map<number, number>();
|
||||
|
||||
@@ -211,6 +221,10 @@ async function runStepwiseGraph(
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
seams,
|
||||
signal: opts.signal,
|
||||
// U6: non-seam custom nodes (the browser-verification optional-group's inner
|
||||
// prompt node) route here. Default: success no-op.
|
||||
runCustomNode: async (node) =>
|
||||
opts.runCustomNode ? opts.runCustomNode(node.id) : { outcome: "success" },
|
||||
getTaskSteps: () => task.steps as TaskStep[],
|
||||
// parse-steps reads PROMPT.md; produce headings matching the step count so the
|
||||
// real builtin chain runs end-to-end. writeSteps is a no-op (steps pre-set).
|
||||
@@ -578,44 +592,57 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => {
|
||||
expect(result.visitedNodeIds).toContain("merge");
|
||||
});
|
||||
|
||||
// ── Pre-merge workflow-step seam (optional-step execution, R1) ─────────────
|
||||
// ── Pre-merge browser-verification optional-group (U6, R-3 run-once) ────────
|
||||
|
||||
it("runs the pre-merge workflow-step seam exactly once after the foreach (enabled steps execute)", async () => {
|
||||
// This is the dead-toggle guard: without a workflow-step seam node on the
|
||||
// success path, a stepwise task's enabledWorkflowSteps (e.g. browser
|
||||
// verification) would never run. Wire a workflowStep spy and assert the graph
|
||||
// invokes it once, between the foreach and review.
|
||||
let workflowStepCalls = 0;
|
||||
const BROWSER_VERIFICATION_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
it("runs the pre-merge browser-verification optional-group EXACTLY ONCE after the foreach when enabled", async () => {
|
||||
// R-3 run-once guarantee + dead-toggle guard: the optional-group sits on the
|
||||
// post-foreach success path. A stepwise task whose enabledWorkflowSteps
|
||||
// includes the group id runs the inner browser-verification prompt node ONCE
|
||||
// after all step instances complete — never per step-instance.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(
|
||||
3,
|
||||
[["APPROVE"], ["APPROVE"], ["APPROVE"]],
|
||||
{
|
||||
workflowStep: async () => {
|
||||
workflowStepCalls++;
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(outcome).toBe("success");
|
||||
// The seam ran ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(workflowStepCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
// Ordering: all step instances complete before the workflow-step seam, which
|
||||
// ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(browserVerificationCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
// Ordering: all step instances complete before the group's inner step, which
|
||||
// precedes review.
|
||||
const seamIdx = result.visitedNodeIds.indexOf("workflow-step");
|
||||
const groupStepIdx = result.visitedNodeIds.indexOf(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
const reviewIdx = result.visitedNodeIds.indexOf("review");
|
||||
const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true);
|
||||
expect(lastStepIdx).toBeLessThan(seamIdx);
|
||||
expect(seamIdx).toBeLessThan(reviewIdx);
|
||||
expect(lastStepIdx).toBeLessThan(groupStepIdx);
|
||||
expect(groupStepIdx).toBeLessThan(reviewIdx);
|
||||
});
|
||||
|
||||
it("treats the workflow-step seam as a no-op pass-through when no steps are enabled", async () => {
|
||||
// No workflowStep seam wired → the handler skips to success and routes to
|
||||
// review, leaving the trajectory identical to the pre-seam behavior.
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]]);
|
||||
it("bypasses the browser-verification optional-group (inert) when it is not enabled", async () => {
|
||||
// Disabled (no enabledWorkflowSteps): the group node is traversed but its
|
||||
// template body never runs — the inner prompt node is not visited and the
|
||||
// custom-node runner is never invoked for it. Routes straight to review.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]], {
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
expect(outcome).toBe("success");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(browserVerificationCalls).toBe(0);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
expect(result.visitedNodeIds).toContain("review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
|
||||
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
|
||||
// executor reproduces the workflow-native planning → execute → workflow-step
|
||||
// → review → merge seam
|
||||
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// executor reproduces the workflow-native planning → execute → review → merge
|
||||
// seam sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// cover per-step / updateStep-trajectory parity.
|
||||
//
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10 (U6): the legacy `workflow-step`
|
||||
// seam was retired from the coding built-in; pre-merge browser-verification is
|
||||
// now a default-OFF `optional-group`. With no `enabledWorkflowSteps` on the task
|
||||
// the group is BYPASSED, so the lifecycle seam sequence is planning → execute →
|
||||
// review → merge (no workflow-step seam).
|
||||
//
|
||||
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
|
||||
// step-session path vs the stepwise foreach graph) is owned by the sibling
|
||||
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
|
||||
@@ -41,9 +46,7 @@ function runLegacy(seams: WorkflowLegacySeams) {
|
||||
const execute = await seams.execute(task, {});
|
||||
events.push(`execute:${execute.outcome}`);
|
||||
if (execute.outcome !== "success") return events;
|
||||
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
|
||||
events.push(`workflow-step:${workflowStep.outcome}`);
|
||||
if (workflowStep.outcome !== "success") return events;
|
||||
// U6: no workflow-step seam — browser-verification is a bypassed optional-group.
|
||||
const review = await seams.review(task, {});
|
||||
events.push(`review:${review.outcome}`);
|
||||
if (review.outcome !== "success") return events;
|
||||
@@ -98,7 +101,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
const executor = new WorkflowGraphExecutor({ seams });
|
||||
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
|
||||
});
|
||||
|
||||
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
|
||||
@@ -198,11 +201,11 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
|
||||
// Bind the invariant to actual executor behavior (PR #1432 review): the
|
||||
// observation below derives from the run-captured seam sequence, so seam
|
||||
// drift fails here instead of being masked by a hard-coded literal.
|
||||
expect(stages).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
|
||||
|
||||
// Legacy authoritative observation: a clean run that lands in `done`/merged.
|
||||
const legacyObs = buildWorkflowObservation({
|
||||
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
|
||||
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
|
||||
terminalColumn: "done",
|
||||
terminalStatus: "done",
|
||||
reviewVerdict: "approve",
|
||||
|
||||
@@ -27,9 +27,14 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(executeCalls).toBe(2);
|
||||
expect(result.context["node:execute:outcome"]).toBe("success");
|
||||
// U6: the legacy `workflow-step` seam is gone; the pre-merge browser-verification
|
||||
// optional-group is bypassed here (task has no enabledWorkflowSteps), so its
|
||||
// group node is visited but its template body is not.
|
||||
expect(result.visitedNodeIds).toEqual(
|
||||
expect.arrayContaining(["execute", "workflow-step", "review", "merge"]),
|
||||
expect.arrayContaining(["execute", "browser-verification", "review", "merge"]),
|
||||
);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
});
|
||||
|
||||
it("exhausts execute node retries and routes failure to end", async () => {
|
||||
@@ -52,7 +57,7 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toContainEqual({ from: "execute", to: "end", condition: "failure" });
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute"]);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification");
|
||||
});
|
||||
|
||||
it("does not retry when the execute node returns a clean failure outcome", async () => {
|
||||
@@ -95,7 +100,9 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.context["node:review:value"]).toBe("exception");
|
||||
expect(result.context["node:review:error"]).toBe("review seam failed");
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
|
||||
// U6: with browser-verification disabled (bypassed), the group node sits
|
||||
// between execute and review where the workflow-step seam used to.
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
|
||||
});
|
||||
|
||||
it("respects a per-node maxRetries override", async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user