diff --git a/.changeset/FN-6963-tool-output-default-off.md b/.changeset/FN-6963-tool-output-default-off.md deleted file mode 100644 index a6cf0d0936..0000000000 --- a/.changeset/FN-6963-tool-output-default-off.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Saved agent tool-output details now default off to reduce persisted log payloads, while timeline rows remain logged and detailed tool arguments/results stay available via the global `persistAgentToolOutput: true` opt-in. diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000000..5122d03c9a --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,37 @@ +# Changeset Format Guide + +Each changeset file in this directory describes one user-facing change for release notes. + +## Required body format + +``` +--- +"@runfusion/fusion": minor +--- + +summary: Add a Command Center productivity control for LOC backfills. +category: feature +dev: Uses the new `fn_backfill_loc` tool; settings key `commandCenter.locBackfill`. +``` + +## Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `summary` | Yes | One line, user-facing, max 120 chars. Describe what changed for the operator. | +| `category` | Yes | One of: `feature`, `fix`, `breaking`, `security`, `performance`, `internal`. | +| `dev` | No | Developer or migration detail. Preserved in per-package CHANGELOGs but excluded from distilled release notes. | + +## Audience + +The `summary` is the only content that appears in end-user release notes by default. Write for Fusion operators — describe behavior, fixes, and what changed. Avoid internal class names, file paths, and implementation detail. + +## Bump types + +- `patch` — bug fixes, internal changes +- `minor` — new features, CLI additions, tools +- `major` — breaking changes + +## Validation + +Run `pnpm check:changesets` to validate. The linter runs in the PR-check gate and `test:gate`. Legacy freeform changesets pass with a warning during the transition period. diff --git a/.changeset/fix-workspace-land-mechanics-phase-c-review.md b/.changeset/fix-workspace-land-mechanics-phase-c-review.md deleted file mode 100644 index f24a7921ba..0000000000 --- a/.changeset/fix-workspace-land-mechanics-phase-c-review.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@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. diff --git a/.changeset/fix-workspace-phase-c-review-round-2.md b/.changeset/fix-workspace-phase-c-review-round-2.md deleted file mode 100644 index 7eba89c430..0000000000 --- a/.changeset/fix-workspace-phase-c-review-round-2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/fn-6939-dev-server-narrow-preview-modal.md b/.changeset/fn-6939-dev-server-narrow-preview-modal.md deleted file mode 100644 index 2133d343fb..0000000000 --- a/.changeset/fn-6939-dev-server-narrow-preview-modal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/fn-6953-ntfy-test-unsaved-config.md b/.changeset/fn-6953-ntfy-test-unsaved-config.md deleted file mode 100644 index 6371518d61..0000000000 --- a/.changeset/fn-6953-ntfy-test-unsaved-config.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/fn-6956-immediate-delete-close.md b/.changeset/fn-6956-immediate-delete-close.md deleted file mode 100644 index eb9af5a114..0000000000 --- a/.changeset/fn-6956-immediate-delete-close.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Close task detail dialogs and embedded task-detail hosts immediately after delete confirmations complete, while delete requests continue reporting success or error toasts asynchronously. diff --git a/.changeset/fn-6965-list-pane-chat-layout.md b/.changeset/fn-6965-list-pane-chat-layout.md deleted file mode 100644 index 6e4e1995fa..0000000000 --- a/.changeset/fn-6965-list-pane-chat-layout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Stack task-detail Chat agent headers above output blocks in the List View split-pane detail pane while preserving full-width desktop chat layout. diff --git a/.changeset/merger-unification-runaimerge-sole-path.md b/.changeset/merger-unification-runaimerge-sole-path.md deleted file mode 100644 index fbd226966a..0000000000 --- a/.changeset/merger-unification-runaimerge-sole-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/multiworkspace-worktree-persistence.md b/.changeset/multiworkspace-worktree-persistence.md deleted file mode 100644 index 2179e7a766..0000000000 --- a/.changeset/multiworkspace-worktree-persistence.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Fix multiworkspace tasks failing to complete. `task.workspaceWorktrees` is now durably persisted (it previously had no SQLite column, so `fn_acquire_repo_worktree`'s write was dropped on every persist and `fn_task_done` always reported "acquired no sub-repo worktrees"). Concurrent workspace tasks no longer collide on the shared browse-root active-session path — each task gets a task-scoped session key, so a second workspace task no longer fails with "active-session path … is held by …". diff --git a/.changeset/retire-optional-steps-declaration.md b/.changeset/retire-optional-steps-declaration.md deleted file mode 100644 index 3ce5035347..0000000000 --- a/.changeset/retire-optional-steps-declaration.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@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`). diff --git a/.changeset/session-routing-headers.md b/.changeset/session-routing-headers.md deleted file mode 100644 index decbd699ad..0000000000 --- a/.changeset/session-routing-headers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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) diff --git a/.changeset/workflow-node-help.md b/.changeset/workflow-node-help.md deleted file mode 100644 index 29a99275c9..0000000000 --- a/.changeset/workflow-node-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/workflow-optional-group-subgraphs.md b/.changeset/workflow-optional-group-subgraphs.md deleted file mode 100644 index c60dad2060..0000000000 --- a/.changeset/workflow-optional-group-subgraphs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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.) diff --git a/.changeset/workspace-dashboard-floor.md b/.changeset/workspace-dashboard-floor.md deleted file mode 100644 index 47db598dea..0000000000 --- a/.changeset/workspace-dashboard-floor.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@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`). diff --git a/.changeset/workspace-multi-repo.md b/.changeset/workspace-multi-repo.md deleted file mode 100644 index b005f1e477..0000000000 --- a/.changeset/workspace-multi-repo.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@runfusion/fusion": minor ---- - -Add workspace mode: open a folder of git repositories as a single Fusion -project. The agent acquires per-repo worktrees on demand via -`fn_acquire_repo_worktree` as it discovers it needs to work in each sub-repo. diff --git a/.changeset/workspace-per-repo-acquisition-hardening.md b/.changeset/workspace-per-repo-acquisition-hardening.md deleted file mode 100644 index 3ce549f618..0000000000 --- a/.changeset/workspace-per-repo-acquisition-hardening.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/workspace-per-repo-land-lease.md b/.changeset/workspace-per-repo-land-lease.md deleted file mode 100644 index 8d9fe58642..0000000000 --- a/.changeset/workspace-per-repo-land-lease.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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). diff --git a/.changeset/workspace-phase-a-u1-executor-session-scoping.md b/.changeset/workspace-phase-a-u1-executor-session-scoping.md deleted file mode 100644 index 6fc9911756..0000000000 --- a/.changeset/workspace-phase-a-u1-executor-session-scoping.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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). diff --git a/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md deleted file mode 100644 index e4a27b33dc..0000000000 --- a/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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/` branch. Single-repo behavior is unchanged. diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md deleted file mode 100644 index bdb9b95252..0000000000 --- a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@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. diff --git a/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md deleted file mode 100644 index ec87d1eb94..0000000000 --- a/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@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/` 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. diff --git a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md deleted file mode 100644 index 1f7f837c80..0000000000 --- a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@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. diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md deleted file mode 100644 index 1bcdb8fa71..0000000000 --- a/.changeset/workspace-phase-d-self-healing.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@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). diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index dd7e3dff3e..5edec3f778 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -44,6 +44,9 @@ jobs: - name: Lint run: pnpm lint + - name: Changeset format + run: pnpm check:changesets + typecheck: name: Typecheck runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7fea77286..1d0007e712 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,10 +392,34 @@ jobs: # Only create the release if at least one artifact exists. A failed build leg # yields a partial release rather than none; a total wipeout fails loudly. + # FNXC:Changelog 2026-06-24-17:45: + # Use the curated root CHANGELOG notes for the GitHub Release body instead + # of GitHub's auto-generated notes. Extracts the version section and passes + # it via --notes-file so the release body matches the distilled CHANGELOG. + - name: Extract release notes from CHANGELOG + if: ${{ steps.collect.outputs.count != '0' }} + id: notes + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + NOTES=$(node -e " + const fs = require('fs'); + const content = fs.readFileSync('CHANGELOG.md', 'utf8'); + const lines = content.split(/\r?\n/); + const header = '## ' + '${VERSION}'; + const start = lines.findIndex(l => l.trim() === header); + if (start === -1) { console.log('Release v${VERSION}'); process.exit(0); } + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith('## ')) { end = i; break; } + } + console.log(lines.slice(start + 1, end).join('\n').trim() || 'Release v${VERSION}'); + ") + echo "$NOTES" > /tmp/release-notes.md + - name: Create GitHub Release if: ${{ steps.collect.outputs.count != '0' }} uses: softprops/action-gh-release@v2 with: - generate_release_notes: true + body_path: /tmp/release-notes.md fail_on_unmatched_files: true files: release-files/* diff --git a/AGENTS.md b/AGENTS.md index 3dfc2a2d53..ddf29832a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,27 @@ Bump types: Do **NOT** create changesets for AGENTS.md/README/internal docs, CI config, or behavior-preserving refactors. `@fusion/core`, `@fusion/dashboard`, and `@fusion/engine` are private. +#### Changeset body format (required) + +Each changeset body must use labeled fields — not freeform paragraphs. The `summary` is the only content that appears in end-user release notes. The audience is Fusion operators, not developers reading internals. + +```markdown +--- +"@runfusion/fusion": minor +--- + +summary: Add a Command Center productivity control for LOC backfills. +category: feature +dev: Uses the new `fn_backfill_loc` tool; settings key `commandCenter.locBackfill`. +``` + +Fields: +- `summary` (required) — one line, user-facing, max 120 chars. Describe what changed for the operator, not implementation detail. +- `category` (required) — one of: `feature`, `fix`, `breaking`, `security`, `performance`, `internal`. +- `dev` (optional) — developer/migration detail. Preserved in per-package CHANGELOGs but excluded from distilled release notes. + +A linter (`pnpm check:changesets`) validates this format and runs in the PR-check gate. Legacy freeform changesets pass with a warning during the transition period; use `--strict` to fail on legacy format. + ### Releasing Use only: diff --git a/CHANGELOG.md b/CHANGELOG.md index 056ed732e6..d8b4a2b3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ User-facing release notes aggregated across all packages. This file is auto-synced from each `packages/*/CHANGELOG.md` by `scripts/release.mjs` — do not edit by hand. +## 0.47.0 + +### New + +- Structured changeset format with AI-distilled release notes for cleaner, user-facing changelogs. + +### Internal + +- Saved agent tool-output details now default off to reduce persisted log payloads, while timeline rows remain logged and detailed tool arguments/results stay available via the global `persistAgentToolOutput: true` opt-in. +- 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). +- 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. +- 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. +- 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. +- Close task detail dialogs and embedded task-detail hosts immediately after delete confirmations complete, while delete requests continue reporting success or error toasts asynchronously. +- Stack task-detail Chat agent headers above output blocks in the List View split-pane detail pane while preserving full-width desktop chat layout. +- 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. +- Fix multiworkspace tasks failing to complete. `task.workspaceWorktrees` is now durably persisted (it previously had no SQLite column, so `fn_acquire_repo_worktree`'s write was dropped on every persist and `fn_task_done` always reported "acquired no sub-repo worktrees"). Concurrent workspace tasks no longer collide on the shared browse-root active-session path — each task gets a task-scoped session key, so a second workspace task no longer fails with "active-session path … is held by …". +- **Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`. +- 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) +- 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. +- 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.) +- Workspace tasks no longer render blank in the dashboard. Task cards and the task +- Add workspace mode: open a folder of git repositories as a single Fusion +- 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. +- 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). +- 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). +- 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/` branch. Single-repo behavior is unchanged. +- 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. +- Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the +- Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent +- 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`. + ## 0.46.0 ### @fusion/dashboard @@ -9736,6 +9768,14 @@ for reference. - Updated dependencies [a2ed6d0] - @runfusion/fusion@0.1.0 +## 0.39.10 + +### @fusion/i18n + +#### Patch Changes + +- @fusion/core@0.47.0 + ## 0.39.9 ### @fusion/i18n @@ -9810,6 +9850,14 @@ for reference. - @fusion/core@0.40.0 +## 0.11.36 + +### @fusion/droid-cli + +#### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.36 + ## 0.11.35 ### @fusion/droid-cli diff --git a/RELEASING.md b/RELEASING.md index b882de275a..88efb2c3f0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,7 +20,24 @@ This will prompt you to: - Choose the semver bump type (patch, minor, major) - Write a summary of the change -A markdown file will be created in the `.changeset/` directory. Commit this file along with your code changes. +Then edit the created changeset file to use the structured body format: + +```markdown +--- +"@runfusion/fusion": minor +--- + +summary: Add a Command Center productivity control for LOC backfills. +category: feature +dev: Uses the new `fn_backfill_loc` tool; settings key `commandCenter.locBackfill`. +``` + +Fields: +- `summary` (required) — one line, user-facing, max 120 chars. +- `category` (required) — one of: `feature`, `fix`, `breaking`, `security`, `performance`, `internal`. +- `dev` (optional) — developer/migration detail. + +A markdown file will be created in the `.changeset/` directory. Commit this file along with your code changes. Validate with `pnpm check:changesets`. ### 2. Version PR is created automatically @@ -29,6 +46,7 @@ When changesets are merged to `main`, the `version.yml` workflow automatically o - Consumes all pending changeset files - Bumps package versions according to the changeset declarations - Generates/updates `CHANGELOG.md` files for affected packages +- Distills the version's changeset summaries into grouped, end-user-facing release notes in the root `CHANGELOG.md` ### 3. Merge the Version PR to release diff --git a/docs/contributing.md b/docs/contributing.md index a049d7f8c7..396a649485 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -231,7 +231,7 @@ Use the default lane for normal local iteration before PRs. Run `test:deep` when Fusion uses Changesets + version PR workflow. - See [RELEASING.md](../RELEASING.md) for release flow details. -- For published package behavior changes, include a changeset. +- For published package behavior changes, include a changeset using the structured body format (`summary`, `category`, optional `dev` fields). See the changeset format guide in [`.changeset/README.md`](../.changeset/README.md). ## Code Signing diff --git a/docs/plans/2026-06-23-003-feat-better-changelog-plan.md b/docs/plans/2026-06-23-003-feat-better-changelog-plan.md new file mode 100644 index 0000000000..353090dfc9 --- /dev/null +++ b/docs/plans/2026-06-23-003-feat-better-changelog-plan.md @@ -0,0 +1,377 @@ +--- +title: "Better Changelog — Structured Changesets + AI-Distilled Release Notes" +date: 2026-06-23 +type: feat +status: draft +origin: user request +deepened: false +--- + +# Better Changelog — Structured Changesets + AI-Distilled Release Notes + +## Summary + +Replace the current dense, freeform changeset paragraphs with a **structured changeset body format** (category + end-user summary + optional dev detail) enforced by a linter, and add an **AI-powered release-notes distiller** that turns a release's collected changesets into clean, grouped, end-user-facing release notes. The distiller reuses the existing `createFnAgent` model-call seam, falls back to deterministic category-grouped rendering when no model is available, and feeds both the root `CHANGELOG.md` and GitHub Release notes through a single unified pipeline. + +--- + +## Problem Frame + +Fusion's changelog pipeline has two problems: + +1. **Changeset content is unwieldy.** AI agents author dense, multi-paragraph technical changesets (some 800+ words of internal implementation detail) that aggregate into a 10,000+ line root `CHANGELOG.md`. The content is written for developers, not the Fusion operators who consume release notes. + +2. **Release notes are inconsistent across paths.** The local release (`scripts/release.mjs`) extracts notes from the changeset-derived CHANGELOG via `extractVersionNotes`. The CI binary release (`.github/workflows/release.yml`) uses GitHub's `generate_release_notes: true`, which auto-generates from PR titles/commits — a completely different output. The dashboard's Update banner links to GitHub release notes, so users see whichever path produced the release. + +The changeset *versioning engine* (cross-package fixed-group semver via `@changesets/cli`) is sound — the problem is content quality and output rendering, not versioning mechanics. + +### Actors + +- **AI agents** — primary changeset authors during task execution +- **Fusion operators** — primary changelog/release-notes audience +- **Release operator** — runs `pnpm release` locally or merges the CI version PR + +--- + +## Requirements + +- **R1.** Every changeset body follows a structured format: a category from a fixed set (`added`, `changed`, `fixed`, `deprecated`, `removed`, `security`) and a concise end-user-facing summary. +- **R2.** A linter validates changeset bodies and runs in CI, rejecting non-conforming changesets before merge. +- **R3.** A release-notes distiller consumes structured changesets and produces grouped, end-user-facing release notes organized by category (Keep a Changelog style). +- **R4.** The distiller operates in two modes: deterministic (always available, category-grouped markdown) and AI-polished (when model access is available, reusing the existing `createFnAgent` seam). +- **R5.** Both release paths (local `release.mjs` and CI `release.yml`) produce the same distilled release notes — one source of truth. +- **R6.** The root `CHANGELOG.md` per-version entry is the distilled end-user release notes, not the raw per-package technical aggregate. +- **R7.** Per-package `CHANGELOG.md` files render structured changesets as clean bullet points via a custom changelog function. + +--- + +## Key Technical Decisions + +### KTD1: Keep the changeset versioning engine, impose structured body schema + +The `@changesets/cli` versioning engine correctly handles the fixed-group cross-package semver bumping defined in `.changeset/config.json`. Re-implementing that is real risk with no user-facing benefit. The problem is changeset *content quality*. We keep the engine and impose a structured body format enforced by a linter. + +**Directional format** (implementation may refine the exact parsing rules): + +```markdown +--- +"@runfusion/fusion": minor +--- + +added: Command Center productivity control for previewing and applying historical LOC backfills from the dashboard. +``` + +- First non-empty line after frontmatter: `: ` +- Category must be one of: `added`, `changed`, `fixed`, `deprecated`, `removed`, `security` +- Optional subsequent paragraph: developer-facing detail (consumed by the distiller for AI context, omitted from end-user output by default) + +### KTD2: Custom changeset changelog function + +The changeset body renders into per-package `CHANGELOG.md` entries via the changelog function configured in `.changeset/config.json` (currently `"@changesets/cli/changelog"`). Without custom rendering, structured fields would appear raw (e.g., `added: some summary`). A custom changelog function parses the structured body and renders it as a clean bullet point. Developer detail is omitted from per-package entries (it lives in the changeset source file until consumed). + +### KTD3: Two-mode distiller (deterministic + AI) + +Release notes must work in both local and CI environments. The distiller's **deterministic mode** groups changesets by category and renders markdown — always available, no model call, no credentials. The **AI mode** passes structured entries to `createFnAgent` (with `tools: "readonly"`, mirroring `packages/dashboard/src/pr-metadata-generator.ts`) for a polished, grouped, end-user summary. AI mode activates when model access is available; deterministic mode is the fallback. Both modes consume the same structured changeset input, so the output shape is consistent regardless of mode. + +### KTD4: Root CHANGELOG becomes distilled end-user notes + +The root `CHANGELOG.md` is the user-facing artifact (linked from the dashboard Update banner via `packages/dashboard/app/components/UpdateAvailableBanner.tsx`). Each version's entry becomes the distilled, category-grouped end-user release notes. Per-package `CHANGELOG.md` files retain developer-facing technical detail (rendered cleanly via KTD2's custom function). Historical entries are not backfilled — only future releases get the new treatment. + +### KTD5: CI model access is optional (deterministic fallback) + +The distiller's AI mode needs model credentials. CI may not have these configured. Rather than making AI distillation a hard CI requirement, the distiller falls back to deterministic mode in CI (still a major improvement — clean category-grouped notes from structured summaries). When a CI model secret is configured, AI mode activates automatically. The local release path always uses AI mode (the operator's machine has model access). + +--- + +## High-Level Technical Design + +```mermaid +flowchart TD + A["AI agent authors
structured changeset"] --> B["Linter validates
CI + local"] + B -->|valid| C["Changeset committed
to .changeset/"] + B -->|invalid| A + C --> D["Release triggered
local release.mjs or CI version.yml"] + D --> E["Distiller reads structured
changesets BEFORE versioning"] + D --> F["changesets/action
version bump + per-package CHANGELOG
(version.yml: npm; release.yml: GitHub Release)"] + E --> G{Mode} + G -->|AI available| H["createFnAgent
polished end-user notes"] + G -->|No model| I["Deterministic
category-grouped notes"] + H --> J["Distilled release notes"] + I --> J + F --> K["Per-package CHANGELOGs
via custom changelog function"] + J --> L["Root CHANGELOG
end-user notes per version"] + J --> M["GitHub Release body"] +``` + +**Key sequencing constraint:** the distiller must read `.changeset/*.md` files *before* `changeset version` runs, because versioning consumes and deletes the changeset files. In `release.mjs`, the distillation call happens after authorization but before `pnpm release:version`. The distilled output is held in memory (or a temp file) and used after the CHANGELOG sync to (a) replace that version's root CHANGELOG entry and (b) feed the GitHub release `--notes-file`. + +--- + +## Scope Boundaries + +### In scope + +- Structured changeset body format + parser + linter +- Migration of existing pending `.changeset/*.md` files to the new format +- Custom changeset changelog function for clean per-package rendering +- Release-notes distiller (deterministic + AI modes) +- Integration into both release paths (local `release.mjs` + CI `release.yml`) +- Root `CHANGELOG.md` per-version entries become distilled end-user notes +- Documentation updates (`AGENTS.md`, `RELEASING.md`, `docs/contributing.md`) + +### Out of scope (non-goals) + +- Re-implementing cross-package semver versioning (the changeset versioning engine stays) +- Backfilling historical `CHANGELOG.md` entries to the new format +- Changing the npm publishing mechanism (OIDC via `version.yml`) +- Changing the binary build/signing pipeline (`release.yml` build legs) +- Dashboard UI changes (the Update banner continues to link to GitHub releases) + +### Deferred to follow-up work + +- Changeset authoring automation (agent prompt updates to emit the structured format automatically — partially covered by AGENTS.md convention update) +- Version-PR-preview release notes (showing distilled notes in the version PR body before merge) +- Release-notes deduplication across packages in the fixed group +- Migration of `extractVersionNotes` consumers if any remain after distiller integration + +--- + +## Implementation Units + +### U1. Structured Changeset Schema, Parser, and Linter + +**Goal:** Define the structured changeset body format, implement a parser that extracts `{ category, userSummary, devDetail? }` from `.changeset/*.md` bodies, and a linter that validates all pending changesets against the schema. + +**Requirements:** R1, R2 + +**Dependencies:** none + +**Files:** +- `scripts/lib/changeset-schema.mjs` (new) — parser + types +- `scripts/check-changeset-format.mjs` (new) — linter entrypoint +- `scripts/__tests__/changeset-schema.test.mjs` (new) — parser tests +- `scripts/__tests__/check-changeset-format.test.mjs` (new) — linter tests + +**Approach:** + +The parser reads a changeset file in two phases: (1) parse the YAML frontmatter for package/bump-type (using the same lightweight parsing the existing release scripts already do), and (2) parse the body for the structured fields. The body format is: first non-empty line after frontmatter is `: `, optional subsequent lines are developer detail. Categories are the Keep a Changelog set: `added`, `changed`, `fixed`, `deprecated`, `removed`, `security`. + +The linter scans all `.changeset/*.md` files (excluding `config.json`), parses each, and reports violations: missing category prefix, invalid category, missing or empty summary, summary exceeding a character budget (directional: ~150 chars), or unparseable body. Exit code 1 on any violation. The linter is a standalone node script with no build dependency, so it can run in the lint CI job without needing compiled artifacts. + +**Patterns to follow:** `scripts/check-no-nohup.mjs`, `scripts/check-no-kill-4040.mjs` — standalone validation scripts that read repo files and exit non-zero on violation. These already run as part of `test:gate`. + +**Test scenarios:** +- *Happy path:* parse a well-formed body (`added: some feature`) — returns `{ category: "added", userSummary: "some feature", devDetail: undefined }` +- *Edge case:* body with category + summary + optional dev detail paragraph — devDetail populated +- *Edge case:* body with extra blank lines between frontmatter and content — trimmed correctly +- *Edge case:* summary at exactly the character budget boundary — accepted +- *Error path:* body missing category prefix (e.g., `This adds a feature`) — linter rejects with clear message +- *Error path:* invalid category (e.g., `enhanced: ...`) — linter lists valid categories +- *Error path:* summary exceeding character budget — linter rejects +- *Error path:* empty body after frontmatter — linter rejects +- *Integration:* linter scans a directory of mixed valid/invalid `.changeset/*.md` files — reports all violations, exits 1 + +**Verification:** `node scripts/check-changeset-format.mjs` exits 0 when all changesets conform, exits 1 with per-file violation messages when any do not. + +--- + +### U2. Migrate Existing Pending Changesets + +**Goal:** Convert all current `.changeset/*.md` files to the new structured format so the linter passes on day one. + +**Requirements:** R1 + +**Dependencies:** U1 + +**Files:** +- All `.changeset/*.md` files (13 pending files as of this plan) + +**Approach:** + +Each existing changeset body is rewritten into the `category: summary` format. The category is inferred from the content (e.g., "Fix ..." → `fixed`, "Add ..." → `added`, "Breaking:" → `changed` or `removed`). The dense technical paragraph is compressed into a one-sentence end-user summary, with key technical context moved to the optional dev-detail paragraph. The frontmatter (package + bump type) is unchanged. + +**Test expectation:** none — data migration. Verify all migrated files pass the linter from U1. + +**Verification:** `node scripts/check-changeset-format.mjs` exits 0 against the migrated files. + +--- + +### U3. Custom Changeset Changelog Function + +**Goal:** Replace the default `@changesets/cli/changelog` with a custom function that parses structured changeset bodies and renders them as clean bullet points in per-package `CHANGELOG.md` files. + +**Requirements:** R7 + +**Dependencies:** U1 (uses the parser) + +**Files:** +- `scripts/lib/changeset-changelog-function.mjs` (new) — custom changelog function +- `.changeset/config.json` (modify) — point `changelog` field to the custom function +- `scripts/__tests__/changeset-changelog-function.test.mjs` (new) — rendering tests + +**Approach:** + +The changesets library calls `getReleaseLine(changeset, type)` for each changeset when generating per-package CHANGELOG entries. The custom function parses the structured body (reusing U1's parser) and renders: `- **Added:** summary` (category title-cased and bolded). If the changeset body is not in structured format (e.g., a legacy entry that slipped through), it falls back to the raw body text so rendering never breaks. The `getDependencyReleaseLine` function passes through dependency bumps unchanged (these are mechanical and contain no user-facing content). + +The `.changeset/config.json` `changelog` field changes from `"@changesets/cli/changelog"` to a path pointing at the custom function module. + +**Patterns to follow:** The changesets changelog function interface (`getReleaseLine`, `getDependencyReleaseLine`). The fallback-to-raw pattern from `pr-metadata-generator.ts`'s `buildFallback`. + +**Test scenarios:** +- *Happy path:* structured body `added: feature text` → renders `- **Added:** feature text` +- *Happy path:* each category title-cases correctly (`fixed` → `Fixed`, `deprecated` → `Deprecated`, etc.) +- *Edge case:* body with dev detail → detail omitted from rendered line (per-package CHANGELOG is concise) +- *Edge case:* legacy unstructured body → falls back to raw text rendering (no crash) +- *Integration:* changesets/action calls `getReleaseLine` during `changeset version` → per-package CHANGELOG shows clean entries + +**Verification:** Run `pnpm release:version --dry-run` (or equivalent) and inspect generated per-package CHANGELOG entries for clean rendering. + +--- + +### U4. Release-Notes Distiller + +**Goal:** Implement the distiller module with deterministic and AI modes that consume structured changesets and produce grouped, end-user-facing release notes. + +**Requirements:** R3, R4 + +**Dependencies:** U1 (uses the parser) + +**Files:** +- `scripts/lib/release-notes-distiller.mjs` (new) — deterministic mode (pure JS, no engine imports) +- `scripts/lib/release-notes-distiller-ai.ts` (new) — AI mode using `createFnAgent` from `@fusion/engine` +- `scripts/__tests__/release-notes-distiller.test.mjs` (new) — deterministic mode tests + +**Approach:** + +**Deterministic mode** (`release-notes-distiller.mjs`): pure JS, no `@fusion/*` imports. Takes an array of parsed `StructuredChangeset` objects, groups by category in Keep a Changelog order (`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`), and renders markdown: + +```markdown +## Added + +- Command Center productivity control for LOC backfills +- Editable global model pricing overrides + +## Fixed + +- Stop Planning Mode from auto-focusing on mobile +- Fix stale durable agent task assignments +``` + +Categories with no entries are omitted. Dev detail is excluded from the output. + +**AI mode** (`release-notes-distiller-ai.ts`): a tsx-runnable module that imports `createFnAgent` from `@fusion/engine` (the same seam `packages/dashboard/src/pr-metadata-generator.ts` uses). It passes the structured changeset entries (category + summary, optionally enriched with dev detail) to the model with a system prompt that instructs it to produce a polished, grouped, end-user-facing summary. The model returns markdown in the same category-grouped shape, but with summaries rewritten for clarity and flow. The AI mode will default to the title-summarizer model setting (`resolveTitleSummarizerSettingsModel`) for consistency with other lightweight AI text tasks, pending the open question on whether a dedicated model setting is warranted. + +**Fallback contract:** if the AI call fails, times out, or no model is configured, the distiller returns the deterministic output. The caller never sees an error from distillation — only degraded polish. + +**Patterns to follow:** `packages/dashboard/src/pr-metadata-generator.ts` — the `createFnAgent({ tools: "readonly", onText, systemPrompt })` + accumulate + parse + fallback pattern. + +**Test scenarios:** +- *Happy path (deterministic):* 5 changesets across 3 categories → markdown with 3 category headings, entries grouped correctly, empty categories omitted +- *Edge case (deterministic):* all changesets in one category → single heading section +- *Edge case (deterministic):* empty changeset array → minimal/empty output +- *Happy path (AI):* structured changesets → model produces grouped markdown (mocked `createFnAgent` in test) +- *Error path (AI):* model timeout/error → falls back to deterministic output (no thrown error) +- *Error path (AI):* no model configured → deterministic output returned directly +- *Integration:* distiller output is valid markdown with category headings usable as GitHub release notes + +**Verification:** Deterministic mode produces correct grouped markdown for representative changeset sets. AI mode falls back cleanly when the model is unavailable. + +--- + +### U5. Integrate Distiller into Both Release Paths + +**Goal:** Wire the distiller into local `release.mjs` and CI `release.yml` so both produce the same distilled release notes, and the root `CHANGELOG.md` entry per version is the distilled output. + +**Requirements:** R5, R6 + +**Dependencies:** U3, U4 + +**Files:** +- `scripts/release.mjs` (modify) — distill changesets before versioning, use distilled notes for root CHANGELOG + GitHub release +- `scripts/lib/extract-version-notes.mjs` (modify or deprecate) — retained as fallback but superseded by distiller +- `.github/workflows/release.yml` (modify) — use distilled notes instead of `generate_release_notes: true` +- `.github/workflows/version.yml` (modify) — ensure root CHANGELOG sync uses distilled notes when versioning + +**Approach:** + +**Local path (`release.mjs`):** After authorization but before `pnpm release:version` (which consumes and deletes changeset files), read all `.changeset/*.md` files, parse them via U1's parser, and pass to U4's distiller (AI mode — the operator's machine has model access). Store the distilled notes. After `syncRootChangelog()` runs, replace that version's root CHANGELOG entry with the distilled notes. Use the distilled notes for the GitHub release `--notes-file` instead of `extractVersionNotes`. + +**CI path (`release.yml`):** Replace `generate_release_notes: true` with `notes-file` pointing to a pre-distilled notes file. The notes file is produced during the version step (when changesets are consumed). Since CI may lack model credentials, the CI distiller runs in deterministic mode by default. When a CI model secret (e.g., `FUSION_CHANGELOG_MODEL_KEY`) is configured, AI mode activates. The `softprops/action-gh-release` action's `body` field receives the distilled notes. + +**CI path (`version.yml`):** The `changesets/action` version step calls `pnpm release:version` which runs `changeset version`. The root CHANGELOG sync (currently only in local `release.mjs`'s `syncRootChangelog()`) needs to also run in CI so the version PR includes the distilled root CHANGELOG. Add a post-version step that runs the distiller in deterministic mode and patches the root CHANGELOG before the version PR is committed. + +**Migration note:** `extractVersionNotes` is retained as a fallback for historical versions but is no longer the primary notes source. Existing tests in `scripts/__tests__/extract-version-notes.test.mjs` continue to pass. + +**Test scenarios:** +- *Happy path (local):* `release.mjs` distills changesets before versioning → GitHub release body matches distilled notes +- *Happy path (local):* root CHANGELOG entry for the new version is the distilled end-user notes (not the raw per-package aggregate) +- *Happy path (CI):* `release.yml` uses distilled notes file → GitHub release body matches distilled output +- *Edge case (CI):* no model secret configured → deterministic distilled notes used (still grouped, clean) +- *Edge case:* no changesets pending → release flow handles gracefully (no crash, minimal notes) +- *Integration:* both paths produce the same notes shape for the same changeset set + +**Verification:** A dry-run local release (`pnpm release --dry-run`) shows distilled notes. CI release workflow references a notes file rather than auto-generation. + +--- + +### U6. Update Documentation and Conventions + +**Goal:** Update all documentation that describes changeset authoring or the release flow to reflect the new structured format, linter, and distillation pipeline. + +**Requirements:** R1, R2 + +**Dependencies:** U1, U5 (documents the final behavior) + +**Files:** +- `AGENTS.md` (modify) — update the "Finalizing Changes" changeset rules with the structured format, category list, and summary budget +- `RELEASING.md` (modify) — update release flow to mention distillation +- `docs/contributing.md` (modify) — update changeset convention section + +**Approach:** + +Update the AGENTS.md "Finalizing Changes" section to specify the new changeset body format: `category: user-facing summary` with the allowed category list, the character budget for summaries, and the optional dev-detail paragraph. Note that the linter enforces this in CI. Update the bump-type guidance (unchanged: patch/minor/major) but clarify the body format. + +Update RELEASING.md to describe the distillation step in both release paths. Update contributing.md's changeset section to match. + +Add FNXC comments to the new scripts documenting the date and requirement rationale. + +**Test expectation:** none — documentation update. + +**Verification:** Documentation accurately describes the new format and flow. No stale references to the old freeform changeset body convention. + +--- + +## System-Wide Impact + +**Affected parties:** +- **AI agents** — every changeset authored during task execution must use the new structured format. The AGENTS.md update (U6) is the primary vector; agent prompt compliance is convention-driven. +- **Release operator** — no change to the release command (`pnpm release`); distillation is automatic. +- **Fusion operators** — release notes and root CHANGELOG become significantly more readable. +- **CI** — `release.yml` and `version.yml` gain a distillation step; `pr-checks.yml` gains a changeset-format check. + +**Affected surfaces:** +- All future `.changeset/*.md` files (format change) +- `.changeset/config.json` (changelog function change) +- `scripts/release.mjs` (distiller integration) +- Root `CHANGELOG.md` (per-version entries become distilled notes) +- Per-package `CHANGELOG.md` files (rendered via custom function) +- `.github/workflows/release.yml`, `.github/workflows/version.yml` (distillation integration) +- `.github/workflows/pr-checks.yml` (linter in lint job) + +--- + +## Risks & Dependencies + +- **Risk: changeset format adoption by agents.** AI agents author changesets based on AGENTS.md conventions. If agents don't adopt the format, the linter blocks PRs. Mitigation: the AGENTS.md update (U6) is explicit, and the linter error messages list valid categories and the expected format. +- **Risk: custom changelog function breaks changesets/action in CI.** The custom function replaces a well-tested default. Mitigation: fallback-to-raw rendering for non-conforming bodies (U3) ensures the function never crashes; test with `changeset version` locally before merging. +- **Risk: distiller reads changesets at the wrong time.** The distiller must read `.changeset/*.md` before `changeset version` consumes them. Mitigation: U5 explicitly sequences the distillation call before `pnpm release:version` in `release.mjs`. +- **Dependency: `createFnAgent` from `@fusion/engine`.** The AI distiller mode imports this. It requires model credentials and settings resolution. Mitigation: deterministic mode is the fallback; AI mode is opt-in via available credentials. +- **Dependency: `softprops/action-gh-release` body vs notes-file.** The CI release needs to pass distilled notes. The action supports `body` (inline) or `body_path` (file). Verify which is cleaner for the CI integration. + +--- + +## Open Questions + +- **Linter enforcement level:** Should the changeset-format linter be part of the merge gate (`test:gate` / lint job in `pr-checks.yml`) from day one, or start as non-blocking in `full-suite.yml` and promote after a grace period? *Recommendation: start in the lint job (blocking) since U2 migrates all existing changesets in the same PR.* +- **Summary character budget:** What is the right max length for the end-user summary? *Directional: 150 chars. Confirm during implementation.* +- **Model setting for distiller AI mode:** Should the distiller use the existing `resolveTitleSummarizerSettingsModel` setting (shared with PR title summarization), or get a dedicated model setting? *Recommendation: reuse title-summarizer setting for now; add a dedicated setting only if quality or cost demands it.* diff --git a/docs/plans/2026-06-24-001-feat-better-changelog-plan.md b/docs/plans/2026-06-24-001-feat-better-changelog-plan.md new file mode 100644 index 0000000000..ff410a3e7c --- /dev/null +++ b/docs/plans/2026-06-24-001-feat-better-changelog-plan.md @@ -0,0 +1,285 @@ +--- +title: "feat: Better changelog — structured changesets + AI-distilled release notes" +type: feat +date: 2026-06-24 +--- + +# feat: Better changelog — structured changesets + AI-distilled release notes + +## Summary + +Replace today's dense, agent-authored technical changeset paragraphs with a **structured, concise changeset schema** (end-user summary + category + optional dev detail), enforced by a linter. Add an **AI distillation step** at version time that transforms a release's collected changesets into clean, grouped, end-user-facing release notes, reusing the existing `createFnAgent` model-call seam. Unify both release paths (local `release.mjs` and CI `version.yml` / `release.yml`) behind a single distilled artifact so the root `CHANGELOG.md` and GitHub Release both carry the same user-facing notes. + +## Problem Frame + +AI agents currently author changesets as dense technical paragraphs — multi-sentence implementation detail, internal class names, and edge-case mechanics that serve developers, not the Fusion operators who read release notes. These aggregate into a 10,000+ line root `CHANGELOG.md` and flow unchanged into GitHub release notes via `extractVersionNotes`. The result is unwieldy: an end user reading "what changed in v0.46.0" wades through internal jargon about `reconcileOrphanedTaskDirs` recency windows and `dotGitPointerIsDangling` sentinels. + +Two structural issues compound this: + +1. No format constraint exists — changeset bodies are freeform markdown with no required fields, length cap, or audience guidance. +2. The two release paths produce **different** notes: the local release (`release.mjs`) extracts from the changeset-derived `CHANGELOG.md`, while CI (`release.yml`) uses GitHub's `generate_release_notes: true` (auto-generated from PR titles/commits). Neither produces a curated, user-facing summary. + +--- + +## Requirements + +### Changeset format + +- R1. Each changeset body uses a structured schema with a required `summary` field (one line, user-facing, max 120 chars), a required `category` field (one of: `feature`, `fix`, `breaking`, `security`, `performance`, `internal`), and an optional `dev` field for developer/migration detail. +- R2. The `summary` is the only content that flows into end-user release notes by default. The `dev` field is preserved in per-package CHANGELOGs but excluded from distilled release notes unless the distillation model judges it user-relevant. +- R3. Existing freeform changesets are grandfathered during a transition period: the linter warns (not fails) when the structured fields are absent, giving the agent fleet time to adopt the new format. + +### Linter + +- R4. A changeset linter validates the structured schema and runs as part of the PR-check gate (`pr-checks.yml`) and `test:gate`, so malformed changesets block merge. +- R5. The linter enforces `summary` length (max 120 chars), valid `category` enum, and that only `@runfusion/fusion` appears in the frontmatter bump declarations (matching the single-package-publish reality). + +### Distillation + +- R6. At version time, a distillation step reads the version's changesets, calls `createFnAgent` with a release-notes system prompt, and produces grouped, user-facing release notes organized by category (New, Fixed, Breaking, etc.). +- R7. Distillation degrades gracefully: if the model call fails, times out, or returns unparseable output, the release proceeds using the structured `summary` lines as a fallback (bullet list by category), so a model outage never blocks a release. +- R8. The distillation step respects the same settings-driven model resolution as other AI features (title-summarizer model settings), so operators can point it at any configured provider/model. + +### Release integration + +- R9. The local release (`release.mjs`) uses distilled notes for both the root `CHANGELOG.md` version section and the GitHub Release notes, replacing the current `extractVersionNotes` raw-aggregation path. +- R10. The CI version workflow (`version.yml`) runs distillation after the changeset versioning step and writes the distilled notes into the root `CHANGELOG.md` before the version PR is created, so the merged version PR carries curated notes. +- R11. The CI binary release workflow (`release.yml`) uses the root `CHANGELOG.md` version section (already distilled) for the GitHub Release body instead of `generate_release_notes: true`, so both paths produce identical curated notes. + +### Root CHANGELOG + +- R12. The root `CHANGELOG.md` shows distilled end-user notes per version. Per-package `CHANGELOG.md` files retain the structured changeset entries (summary + category + dev detail) as the developer-facing record. + +--- + +## Key Technical Decisions + +- **Keep the changeset versioning engine, impose structure on bodies.** The `@changesets/cli` correctly handles the fixed-group cross-package semver (`config.json` `fixed` array). Re-implementing that is real risk for zero versioning benefit. The plan imposes a structured *content* schema on changeset bodies and adds a linter; the versioning engine stays untouched. + +- **Structured body format, not new frontmatter.** Changeset frontmatter (`---"@runfusion/fusion": minor---`) is consumed by the changesets tool and must stay machine-parseable. The structured content (`summary`, `category`, `dev`) lives in the body as labeled fields, parsed by a lightweight reader. This avoids fighting the changesets tool's frontmatter contract. + +- **Distillation via `createFnAgent` with `tools: "readonly"`.** The PR-metadata generator (`packages/dashboard/src/pr-metadata-generator.ts`) already proves this exact pattern: single-shot model call, `onText` accumulation, settings-driven model resolution, graceful fallback on parse failure. The distillation module mirrors that shape. No new model infrastructure is needed. + +- **Distillation runs in `release.mjs` (local) and as a post-version step in `version.yml` (CI).** Both paths share the same `scripts/lib/distill-release-notes.ts` module. In CI, the step runs after `changeset version` produces per-package CHANGELOGs but before the version PR commit, so curated notes ship with the version bump. Model credentials in CI come from a GitHub secret mapped to the existing settings model resolution. + +- **Root CHANGELOG becomes the distilled view; per-package CHANGELOGs stay developer-facing.** The root `CHANGELOG.md` is the user-facing artifact (linked from the dashboard Update banner, GitHub Release). Per-package CHANGELOGs remain the developer/integrator record with structured changeset entries. `syncRootChangelog` is replaced by a distillation-aware sync that writes the distilled notes as the version's root section. + +--- + +## High-Level Technical Design + +### Changeset body schema + +``` +--- +"@runfusion/fusion": minor +--- + +summary: Add a Command Center productivity control for LOC backfills. +category: feature +dev: Uses the new `fn_backfill_loc` tool; settings key `commandCenter.locBackfill`. +``` + +The body is parsed as labeled fields. `summary` and `category` are required; `dev` is optional. Any freeform text not matching a labeled field is treated as legacy content and triggers a linter warning. + +### Release-time distillation flow + +```mermaid +flowchart TB + A[Pending changesets
.changeset/*.md] --> B[changeset version
bumps + per-pkg CHANGELOGs] + B --> C[Parse structured summaries
from versioned packages] + C --> D{createFnAgent
distill release notes} + D -->|success| E[Distilled notes
grouped by category] + D -->|fail/timeout/parse| F[Fallback: bullet list
from summary fields] + E --> G[Write root CHANGELOG
version section] + F --> G + G --> H[GitHub Release
--notes-file] +``` + +### Two release paths unified + +```mermaid +flowchart LR + subgraph Local["Local release (release.mjs)"] + L1[version] --> L2[distill] --> L3[root CHANGELOG] --> L4[gh release create] + end + subgraph CI["CI release (version.yml + release.yml)"] + C1[changesets/action version] --> C2[distill step] --> C3[version PR with
curated root CHANGELOG] --> C4[merge + tag] --> C5[release.yml uses
CHANGELOG notes] + end +``` + +--- + +## Implementation Units + +### U1. Structured changeset parser + schema + +- **Goal:** Define the structured changeset body schema and ship a parser that extracts `summary`, `category`, and `dev` fields from a changeset markdown file, with legacy freeform fallback. +- **Requirements:** R1, R2 +- **Dependencies:** none +- **Files:** + - `scripts/lib/changeset-schema.mjs` (new) — schema constants (categories, max summary length), parse and validate functions + - `scripts/__tests__/changeset-schema.test.mjs` (new) +- **Approach:** The parser reads a changeset `.md` file, splits frontmatter from body (reusing the `---` delimited convention), then extracts labeled fields (`summary:`, `category:`, `dev:`) from the body. Fields are parsed as `key: value` on the first line matching each label, with `dev` allowing multi-line content until the next labeled field or EOF. If no labeled fields are found, the entire body is treated as legacy `summary` (first line) with `category: internal` default and a `legacy: true` flag. +- **Patterns to follow:** `readChangesetSummaries` in `scripts/release.mjs` (frontmatter parsing pattern); `parseAiResult` in `packages/dashboard/src/pr-metadata-generator.ts` (lenient parse with null fallback). +- **Test scenarios:** + - Happy path: parse a well-formed structured changeset with all three fields; assert each field is extracted correctly. + - Multi-line `dev`: parse a changeset where `dev` spans multiple lines; assert full content captured. + - Legacy freeform: parse an old-style changeset with a dense paragraph body and no labeled fields; assert `legacy: true`, `summary` is the first line, `category` defaults to `internal`. + - Missing `category`: parse a changeset with `summary` but no `category`; assert validation flags it as missing. + - Empty body: parse a changeset with frontmatter but empty body; assert graceful null return. + - Summary over 120 chars: parse a changeset with an over-length `summary`; assert validation flags the violation. +- **Verification:** Parser unit tests pass; parser correctly handles all three existing changeset shapes (structured, legacy paragraph, minimal one-liner) found in `.changeset/`. + +### U2. Changeset linter + +- **Goal:** Ship a linter script that validates every changeset in `.changeset/` against the structured schema, enforcing required fields, summary length, category enum, and frontmatter package scope. Wire it into the PR-check gate. +- **Requirements:** R3, R4, R5 +- **Dependencies:** U1 +- **Files:** + - `scripts/check-changeset-format.mjs` (new) — linter entrypoint + - `scripts/__tests__/check-changeset-format.test.mjs` (new) + - `.github/workflows/pr-checks.yml` (modify) — add changeset-format check step + - `package.json` (modify) — add `check:changesets` script + - `package.json` (modify) — add `check:changesets` to `test:gate` chain +- **Approach:** The linter scans `.changeset/*.md` (excluding `README.md` and `config.json`), parses each via U1's parser, and validates: `summary` present and <= 120 chars, `category` is a valid enum value, frontmatter declares only `@runfusion/fusion`. Legacy changesets (no structured fields) produce a **warning** (exit 0) during the transition period; structurally invalid changesets (partial fields, bad category, over-length summary) produce **errors** (exit 1). The warning-vs-error threshold is a `--strict` flag so the transition can be tightened later. +- **Patterns to follow:** `scripts/check-no-nohup.mjs`, `scripts/check-no-kill-4040.mjs` (existing lint-gate scripts that exit 1 on violation, integrated into `test:gate`). +- **Test scenarios:** + - Valid structured changeset passes with exit 0 and no warnings. + - Legacy freeform changeset passes with exit 0 and a warning (transition mode). + - Missing `category` on a structured changeset fails with exit 1 and names the file. + - Over-length `summary` (121+ chars) fails with exit 1. + - Invalid `category` value (e.g., `enhancement`) fails with exit 1 and lists valid values. + - `--strict` flag causes legacy changesets to fail (exit 1) instead of warn. + - Empty `.changeset/` directory (excluding config/README) passes with exit 0. + - Frontmatter declaring a non-`@runfusion/fusion` package fails with exit 1. +- **Verification:** `pnpm check:changesets` exits 0 against the current `.changeset/` directory (all existing entries are legacy and pass in transition mode). `pnpm test:gate` includes the check and passes. + +### U3. AI distillation module + +- **Goal:** Ship the distillation module that takes a version's parsed changesets and produces grouped, user-facing release notes via `createFnAgent`, with graceful fallback to a structured bullet list on any model failure. +- **Requirements:** R6, R7, R8 +- **Dependencies:** U1 +- **Files:** + - `scripts/lib/distill-release-notes.ts` (new) — distillation orchestrator + - `scripts/__tests__/distill-release-notes.test.ts` (new) +- **Approach:** The module accepts an array of parsed changeset entries (from U1), the target version, and an optional settings/model override. It builds a system prompt that instructs the model to produce grouped markdown release notes (sections: New, Fixed, Breaking, Performance, Security; omit empty sections), using only the `summary` fields as input and writing for a Fusion operator audience. It calls `createFnAgent` with `tools: "readonly"` and accumulates text via `onText`, mirroring `generatePrMetadata`. On success, the accumulated text is the release notes body. On any failure (model error, timeout, unparseable/empty output), the fallback builds a category-grouped bullet list directly from the structured `summary` fields — no model call. The module accepts an `AbortSignal` and timeout for release-script integration. +- **Execution note:** Start with a failing test for the fallback path (no model available) to lock the graceful-degradation contract before implementing the model-call path. +- **Patterns to follow:** `packages/dashboard/src/pr-metadata-generator.ts` (the canonical single-shot `createFnAgent` pattern: settings model resolution, `onText` accumulation, `AbortController` + timeout, try/finally `session.dispose()`, fallback on failure). `packages/engine/src/merger-ai.ts` (prompt-builder / verdict-parser separation for testability). +- **Technical design (directional):** + + ``` + distillReleaseNotes({ + entries: ParsedChangeset[], + version: string, + settings?: Settings, + signal?: AbortSignal, + timeoutMs?: number, + }): Promise<{ notes: string; source: "ai" | "fallback" }> + ``` + + The system prompt instructs: produce markdown grouped under `### New`, `### Fixed`, `### Breaking`, `### Performance`, `### Security`; use the `summary` text verbatim or lightly edited for grouping; omit empty sections; no internal class names or implementation detail; audience is a Fusion operator. + +- **Test scenarios:** + - Fallback path: call with `createFnAgent` stubbed to throw; assert returns bullet list grouped by category, `source: "fallback"`, exit 0. + - Fallback path: call with model returning empty string; assert fallback bullet list. + - Fallback path: call with model returning non-markdown garbage; assert fallback bullet list. + - AI path: call with model stubbed to return grouped markdown; assert notes match, `source: "ai"`. + - Timeout: call with `timeoutMs: 1` and a slow stub; assert fallback fires. + - Abort: call with a pre-aborted signal; assert fallback fires immediately. + - Category grouping in fallback: pass entries with categories `feature`, `fix`, `breaking`; assert fallback groups them under correct headings and omits empty sections. + - Legacy entries: pass entries with `category: internal` (legacy default); assert they appear in an "Internal" section or are omitted per audience rule. + - Single entry: pass one entry; assert notes are well-formed with one bullet. +- **Verification:** Module unit tests pass with both stubbed model (AI path) and forced-failure (fallback path). Module produces valid markdown for the current pending changesets when run manually with a real model. + +### U4. Release script integration (local path) + +- **Goal:** Wire the distillation module into `release.mjs` so the local release produces distilled notes for both the root `CHANGELOG.md` and the GitHub Release. +- **Requirements:** R9, R12 +- **Dependencies:** U1, U3 +- **Files:** + - `scripts/release.mjs` (modify) — replace `syncRootChangelog` + `extractVersionNotes` with distillation-aware versions + - `scripts/lib/sync-root-changelog.mjs` (new, extracted from `release.mjs`) — refactored root CHANGELOG sync that accepts a distilled-notes override for the version section + - `scripts/__tests__/sync-root-changelog.test.mjs` (new) +- **Approach:** After `pnpm release:version` runs (which bumps versions and writes per-package CHANGELOGs), the script reads the versioned changesets (now deleted from `.changeset/` by `changeset version`), so the changeset content must be captured **before** `changeset version` runs. Add a pre-version capture step that reads and parses all pending changesets via U1, then after versioning, passes the parsed entries to U3's `distillReleaseNotes`. The distilled notes replace the version's section in the root `CHANGELOG.md` (per-package CHANGELOGs are untouched — they carry the structured entries). The GitHub Release uses the distilled notes directly via `--notes-file`. Extract `syncRootChangelog` into its own module so it can accept the distilled-notes override. +- **Patterns to follow:** The existing `release.mjs` flow ordering (version → sync → build → commit → publish → tag → release). The `readChangesetSummaries` function already captures pre-version changeset content; extend it to use U1's parser. +- **Test scenarios:** + - `syncRootChangelog` with distilled override: pass a version, existing root CHANGELOG content, and distilled notes; assert the version section is replaced with the distilled notes while other versions are preserved. + - `syncRootChangelog` without override (fallback): pass no distilled notes; assert behavior matches current aggregation (backward compat for dry-runs without model). + - Pre-version capture: mock `.changeset/` with structured entries; assert entries are captured before `changeset version` deletes them. + - Pre-version capture with legacy entries: assert legacy entries are captured with `legacy: true` and still feed distillation. +- **Verification:** `pnpm release --dry-run` shows the captured changesets and proposed distilled notes without making changes. A real release produces a root `CHANGELOG.md` with the distilled version section and a GitHub Release with matching notes. + +### U5. CI workflow integration + +- **Goal:** Wire distillation into the CI version workflow (`version.yml`) and update the binary release workflow (`release.yml`) to use the curated notes. +- **Requirements:** R10, R11 +- **Dependencies:** U3, U4 +- **Files:** + - `.github/workflows/version.yml` (modify) — add a post-version distillation step between `changeset version` and the version PR commit + - `.github/workflows/release.yml` (modify) — replace `generate_release_notes: true` with `--notes-file` reading the root CHANGELOG version section + - `scripts/ci-distill-release-notes.mjs` (new) — CI entrypoint that resolves model credentials from GitHub secrets, runs distillation, and writes the root CHANGELOG +- **Approach:** In `version.yml`, after `changesets/action` runs the version step (which calls `pnpm release:version`), add a new step that runs `node scripts/ci-distill-release-notes.mjs --version `. This script reads the just-versioned per-package CHANGELOGs, extracts the structured entries for the new version, calls U3's distillation module with model settings resolved from a GitHub secret (`FUSION_RELEASE_MODEL_API_KEY` or similar, mapped through the existing settings model resolution), and writes the distilled notes into the root `CHANGELOG.md`. The version PR then carries curated notes. In `release.yml`, replace `generate_release_notes: true` with a step that extracts the version section from the root `CHANGELOG.md` (via `extractVersionNotes`) and passes it as `--notes-file`, so the GitHub Release matches the curated notes. +- **Patterns to follow:** The existing `version.yml` `changesets/action` integration; the `release.yml` `softprops/action-gh-release` usage. +- **Test scenarios:** + - CI distillation entrypoint: mock per-package CHANGELOGs with structured entries; assert the script produces distilled root CHANGELOG section. + - CI distillation with no model secret: assert the script falls back gracefully (bullet list) and does not fail the workflow. + - `release.yml` notes extraction: given a root CHANGELOG with a distilled version section, assert `extractVersionNotes` returns the correct content for `--notes-file`. + - `release.yml` notes extraction: version not found in CHANGELOG; assert fallback string is used. +- **Verification:** `version.yml` workflow run (manual dispatch) produces a version PR with a distilled root CHANGELOG section. `release.yml` GitHub Release body matches the root CHANGELOG version section. + +### U6. Agent guidance + documentation update + +- **Goal:** Update all documentation and agent-facing guidance so the agent fleet and human contributors author changesets in the new structured format. +- **Requirements:** R1, R3 +- **Dependencies:** U1, U2 +- **Files:** + - `AGENTS.md` (modify) — update the "Finalizing Changesets" section with the structured format, field definitions, category enum, and examples + - `RELEASING.md` (modify) — update the changeset authoring section with the new format + - `docs/contributing.md` (modify) — update the changeset reference + - `.changeset/README.md` (new) — template + format reference that `pnpm changeset` consumers see +- **Approach:** The AGENTS.md "Finalizing Changesets" section currently says "add a changeset" with a bump-type table. Expand it with the structured body schema: required `summary` (one line, user-facing, max 120 chars), required `category` (enum), optional `dev` (developer detail). Provide before/after examples showing the transformation from dense paragraph to structured fields. Note the linter enforcement and the transition period (legacy changesets warn, don't fail). Add a `.changeset/README.md` that `pnpm changeset` surfaces as the format guide. +- **Patterns to follow:** The existing AGENTS.md "Finalizing Changesets" section structure (bump types, rules table). +- **Test expectation:** none — documentation-only unit. +- **Verification:** AGENTS.md guidance matches the U1 schema and U2 linter rules exactly. A new contributor reading the docs can author a valid structured changeset without further guidance. + +--- + +## Scope Boundaries + +### Deferred for later + +- Backfilling the existing 10,000-line root `CHANGELOG.md` history into the distilled format. Only future releases get the new treatment; historical versions retain their current content. +- A dashboard surface for browsing release notes in-app (beyond the existing Update banner link to GitHub). The plan covers the notes pipeline, not a new UI surface. +- Per-package CHANGELOG distillation (the per-package files keep structured entries; only the root CHANGELOG is distilled). +- Migration of the `@changesets/cli` tool itself to a custom versioning system. + +### Outside this product's identity + +- Replacing the changeset versioning engine with a commit-conventional or PR-derived changelog generator. The confirmed direction is structured changesets + distillation, not deriving from PRs. + +--- + +## Risks & Dependencies + +- **CI model credentials.** Distillation in `version.yml` requires a model API key available as a GitHub secret. If this is not configured, CI distillation must fall back gracefully (bullet list from summaries). The local release path is unaffected (uses the operator's local settings). This is the main operational dependency. +- **`createFnAgent` from a root script.** No existing root-level script imports `@fusion/*` packages (they are TS source resolved via workspace aliases). The distillation module is written in TypeScript and run via `tsx` (already a devDependency). The CI entrypoint is `.mjs` and shells to the TS module via `tsx`. If workspace resolution is problematic in the release context, the fallback is to shell out to a Fusion CLI one-shot (if one exists) or inline the model call. This is an execution-time unknown to resolve during U3. +- **Changeset capture timing.** `changeset version` deletes changeset files after consuming them. The pre-version capture step (U4) must read and parse changesets before `changeset version` runs. If the capture step is missed, the per-package CHANGELOGs (which contain the aggregated entries) can serve as a secondary source. The implementation should handle both paths. +- **Transition period ambiguity.** During the transition, a release may contain a mix of structured and legacy changesets. The distillation module and fallback must handle mixed input gracefully (legacy entries use the first-line-as-summary default). + +--- + +## System-Wide Impact + +- **Agent fleet behavior.** Every AI agent that completes a task with a changeset must author the new structured format. This is a behavioral change enforced by the linter and documented in AGENTS.md. The transition period prevents immediate breakage. +- **Release pipeline.** Both release paths (local and CI) gain a model-call step. The local path adds ~10-30 seconds for distillation. The CI path adds a step to the version workflow. Both degrade gracefully on model failure. +- **External consumers.** The root `CHANGELOG.md` and GitHub Release notes change shape (from raw changeset aggregation to curated, grouped notes). Users who parse the CHANGELOG programmatically may need to adjust. The per-package CHANGELOGs retain the changesets structure for npm consumers. + +--- + +## Documentation / Operational Notes + +- Operators running local releases (`pnpm release`) get distillation automatically using their configured model settings — no additional setup. +- CI distillation requires a GitHub secret for the model API key. Document the required secret name and settings shape in `RELEASING.md`. +- The `--strict` flag on the changeset linter allows tightening the transition: once all agents produce structured changesets, flip the gate from warning to error on legacy format. +- The distillation system prompt and category mappings are defined in `scripts/lib/distill-release-notes.ts` and can be tuned without code changes beyond the prompt string. diff --git a/package.json b/package.json index 9fcec03216..707ed4613d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fusion-workspace", - "version": "0.46.0", + "version": "0.47.0", "private": true, "license": "MIT", "homepage": "https://github.com/Runfusion/Fusion#readme", @@ -14,10 +14,11 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", "check:line-count": "node scripts/check-file-line-count.mjs", - "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", + "check:changesets": "node scripts/check-changeset-format.mjs", + "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", @@ -62,7 +63,7 @@ "changeset": "changeset", "version": "changeset version", "release": "node scripts/release.mjs", - "release:version": "changeset version && node scripts/sync-workspace-version.mjs", + "release:version": "changeset version && node scripts/sync-workspace-version.mjs && node scripts/run-ci-distill.mjs", "mobile:build": "pnpm --filter @fusion/dashboard build && pnpm --filter @fusion/mobile cap sync", "mobile:ios": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open ios", "mobile:android": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open android", diff --git a/packages/cli-alias/CHANGELOG.md b/packages/cli-alias/CHANGELOG.md index 4a6fffc0f7..6254d065c6 100644 --- a/packages/cli-alias/CHANGELOG.md +++ b/packages/cli-alias/CHANGELOG.md @@ -1,5 +1,35 @@ # runfusion.ai +## 0.47.0 + +### Patch Changes + +- Updated dependencies [038ac30] +- Updated dependencies [627bdcf] +- Updated dependencies [3a71237] +- Updated dependencies [e9a6955] +- Updated dependencies [7b60539] +- Updated dependencies [cf2f3ba] +- Updated dependencies [b9821ee] +- Updated dependencies [a6252e5] +- Updated dependencies [f062819] +- Updated dependencies [e5382f0] +- Updated dependencies [e17e9bc] +- Updated dependencies [2019e5a] +- Updated dependencies [9c6b4dd] +- Updated dependencies [0c031b8] +- Updated dependencies [023e4b0] +- Updated dependencies [8f4098e] +- Updated dependencies [12d33c5] +- Updated dependencies [64e87f9] +- Updated dependencies [09bd01b] +- Updated dependencies [fc9423e] +- Updated dependencies [81edbee] +- Updated dependencies [744ed09] +- Updated dependencies [7544346] +- Updated dependencies [7cd204e] + - @runfusion/fusion@0.47.0 + ## 0.46.0 ### Patch Changes diff --git a/packages/cli-alias/package.json b/packages/cli-alias/package.json index cfad121630..4a8c62bc43 100644 --- a/packages/cli-alias/package.json +++ b/packages/cli-alias/package.json @@ -1,6 +1,6 @@ { "name": "runfusion.ai", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Launch Fusion with `npx runfusion.ai` — tiny alias for @runfusion/fusion.", "homepage": "https://runfusion.ai", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a678faef3f..c21c75f4cb 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,74 @@ # @runfusion/fusion +## 0.47.0 + +### Minor Changes + +- a6252e5: 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. +- e5382f0: **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`). + +- e17e9bc: 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) +- 2019e5a: summary: Structured changeset format with AI-distilled release notes for cleaner, user-facing changelogs. + category: feature + dev: Changeset bodies now use labeled fields (summary, category, dev). A linter enforces the format in the PR gate. Release notes are distilled into grouped, end-user-facing sections. See .changeset/README.md for the format guide. +- 9c6b4dd: 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. +- 0c031b8: 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.) +- 023e4b0: 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`). +- 8f4098e: Add workspace mode: open a folder of git repositories as a single Fusion + project. The agent acquires per-repo worktrees on demand via + `fn_acquire_repo_worktree` as it discovers it needs to work in each sub-repo. +- 12d33c5: 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. +- 64e87f9: 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). +- 09bd01b: 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). +- fc9423e: 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/` branch. Single-repo behavior is unchanged. +- 81edbee: 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. + +- 744ed09: 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/` 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. +- 7544346: 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. +- 7cd204e: 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). + +### Patch Changes + +- 038ac30: Saved agent tool-output details now default off to reduce persisted log payloads, while timeline rows remain logged and detailed tool arguments/results stay available via the global `persistAgentToolOutput: true` opt-in. +- 627bdcf: 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. + +- 3a71237: 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. +- e9a6955: 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. +- 7b60539: 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. +- cf2f3ba: Close task detail dialogs and embedded task-detail hosts immediately after delete confirmations complete, while delete requests continue reporting success or error toasts asynchronously. +- b9821ee: Stack task-detail Chat agent headers above output blocks in the List View split-pane detail pane while preserving full-width desktop chat layout. +- f062819: Fix multiworkspace tasks failing to complete. `task.workspaceWorktrees` is now durably persisted (it previously had no SQLite column, so `fn_acquire_repo_worktree`'s write was dropped on every persist and `fn_task_done` always reported "acquired no sub-repo worktrees"). Concurrent workspace tasks no longer collide on the shared browse-root active-session path — each task gets a task-scoped session key, so a second workspace task no longer fails with "active-session path … is held by …". + ## 0.46.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d61f21cc9..65cde80c9f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@runfusion/fusion", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2b2cc0ff80..2c34023fa7 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/core +## 0.47.0 + ## 0.46.0 ## 0.45.0 diff --git a/packages/core/package.json b/packages/core/package.json index 55b01e5bbd..a1cb775d90 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/core", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index c352b4598b..50dc3ce054 100644 --- a/packages/dashboard/CHANGELOG.md +++ b/packages/dashboard/CHANGELOG.md @@ -1,5 +1,22 @@ # @fusion/dashboard +## 0.47.0 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/engine@0.47.0 +- @fusion/i18n@0.39.10 +- @fusion-plugin-examples/cli-printing-press@0.1.27 +- @fusion-plugin-examples/compound-engineering@0.1.10 +- @fusion-plugin-examples/dependency-graph@0.1.41 +- @fusion-plugin-examples/roadmap@0.1.29 +- @fusion-plugin-examples/cursor-runtime@0.1.29 +- @fusion-plugin-examples/droid-runtime@0.1.36 +- @fusion-plugin-examples/hermes-runtime@0.2.60 +- @fusion-plugin-examples/openclaw-runtime@0.2.60 +- @fusion-plugin-examples/paperclip-runtime@0.2.60 + ## 0.46.0 ### Patch Changes diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index e50f21bb78..1771c5a851 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -267,6 +267,34 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const [workspaceRepos, setWorkspaceRepos] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); const gitRepoPath = selectedRepo ?? undefined; + /* + FNXC:Workspace 2026-06-25-00:10: + In a workspace the project root is a non-git browse-only directory. On modal open the section fetch + fires immediately with no repoPath (selectedRepo not yet resolved), so a git status against the root + returns "Not a git repository" and toasts a spurious error on every open — even though the repo + dropdown renders correctly. fetchWorkspaceRepos resolves a tick later and re-fetches against a real + sub-repo. We track detection status in a REF (read inside the async fetch catch without a stale + closure or extra render dep) so we can SUPPRESS that one benign root-race error: a "Not a git + repository" with no repoPath while detection is unresolved OR has detected a workspace. A genuine + broken non-workspace project (resolved, repos empty) still surfaces the error normally. + */ + const workspaceDetectionRef = useRef<{ resolved: boolean; isWorkspace: boolean }>({ resolved: false, isWorkspace: false }); + // Tracks whether the most recent fetch suppressed a root-race error, and a state tick that flips + // when detection resolves — together they let a genuinely-broken NON-workspace project re-surface + // the error (a single re-fetch) after detection settles, without adding a redundant fetch to the + // common non-workspace-OK path (where the first fetch already succeeded). + const suppressedRootRaceRef = useRef(false); + const [detectionResolved, setDetectionResolved] = useState(false); + /* + FNXC:Workspace 2026-06-25-09:40 (detection generation guard): + A rapid projectId switch (or close→reopen) can leave a previous project's fetchWorkspaceRepos + promise in flight. When it resolves it must NOT overwrite the CURRENT project's detection verdict — + doing so could suppress a real error for the new project or mis-fire the re-surface effect. Each + detection run is stamped with a monotonically increasing generation; only the latest run is allowed + to mutate detection state, and the effect cleanup bumps the generation so a superseded/unmounted run + is abandoned. + */ + const detectionGenerationRef = useRef(0); // ── Changes state const [fileChanges, setFileChanges] = useState([]); @@ -322,6 +350,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!isOpen) return; setLoading(true); setSectionError(null); + suppressedRootRaceRef.current = false; try { switch (activeSection) { case "status": { @@ -375,8 +404,30 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj } } } catch (err) { - setSectionError(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data")); - addToast(getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"), "error"); + const message = getErrorMessage(err) || t("git.failedToFetchData", "Failed to fetch git data"); + /* + FNXC:Workspace 2026-06-25-00:10: + Suppress the benign workspace-root race: on open, the first fetch fires before selectedRepo + resolves (no repoPath → the non-git browse root), which fails "Not a git repository". A workspace + re-fetches against a real sub-repo a tick later. Only swallow this when there is NO repoPath AND + detection is still pending OR has confirmed a workspace; a resolved non-workspace project surfaces + a genuine "Not a git repository" normally. + */ + const detection = workspaceDetectionRef.current; + const isWorkspaceRootRace = + gitRepoPath === undefined && + /not a git repository/i.test(message) && + (!detection.resolved || detection.isWorkspace); + if (isWorkspaceRootRace) { + // Benign: defer reporting. A workspace re-fetches against its sub-repo (selectedRepo change); + // a non-workspace re-fetches once via the detection-resolved effect below, surfacing any real + // error then. + suppressedRootRaceRef.current = true; + setSectionError(null); + } else { + setSectionError(message); + addToast(message, "error"); + } } finally { setLoading(false); } @@ -909,20 +960,46 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj selectedRepo in the effect deps, preserving the projectId-keyed intent. */ useEffect(() => { + // Reset detection on project switch so a stale verdict can't suppress a real error. The + // generation guard (see ref note above) makes a superseded in-flight resolution a no-op. + const gen = ++detectionGenerationRef.current; + workspaceDetectionRef.current = { resolved: false, isWorkspace: false }; + suppressedRootRaceRef.current = false; + setDetectionResolved(false); fetchWorkspaceRepos(projectId) .then((result) => { + if (gen !== detectionGenerationRef.current) return; const repos = result.repos; + workspaceDetectionRef.current = { resolved: true, isWorkspace: repos.length > 0 }; setWorkspaceRepos(repos); setSelectedRepo((current) => current && repos.includes(current) ? current : (repos[0] ?? null), ); }) .catch(() => { + if (gen !== detectionGenerationRef.current) return; + workspaceDetectionRef.current = { resolved: true, isWorkspace: false }; setWorkspaceRepos([]); setSelectedRepo(null); + }) + .finally(() => { + if (gen !== detectionGenerationRef.current) return; + setDetectionResolved(true); }); + // Bump the generation on cleanup so an unmounted/superseded run's late resolution is abandoned. + return () => { detectionGenerationRef.current++; }; }, [projectId]); // keyed on projectId; selectedRepo is revalidated via the functional updater + // FNXC:Workspace 2026-06-25-00:10: once detection settles, re-surface a suppressed root-race error + // for a NON-workspace project (a genuinely broken/non-git repo). A workspace already re-fetches via + // the selectedRepo change, so we skip it here to avoid a redundant second fetch. + useEffect(() => { + if (isOpen && detectionResolved && suppressedRootRaceRef.current && !workspaceDetectionRef.current.isWorkspace) { + suppressedRootRaceRef.current = false; + void fetchSectionData(); + } + }, [isOpen, detectionResolved, fetchSectionData]); + const handleSyncIntegrationTip = useCallback(async () => { if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return; const worktreePath = rootDir; diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 5b220116eb..582d3d67fb 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -19,6 +19,15 @@ interface TaskChangesTabProps { projectId?: string; column?: ColumnId; mergeDetails?: MergeDetails; + /** + * FNXC:Workspace 2026-06-25-09:40: + * True for a workspace (multi-repo) task. Such a task has no singular + * `worktree`/`branch` — its changes live in per-sub-repo worktrees, which the + * backend `/tasks/:id/diff` now aggregates (repo-prefixed paths). Used to skip + * the single-repo "No worktree available" empty state, which would otherwise + * fire on every workspace task because `worktree` is undefined. + */ + isWorkspace?: boolean; /** * Files modified by the task during execution, captured from the worktree. * Used as a last-resort fallback when the live worktree diff is empty or the @@ -127,7 +136,7 @@ interface NormalizedFile { * modifiedFiles view instead of showing a hard error. This preserves the prior * graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity. */ -export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles }: TaskChangesTabProps) { +export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles, isWorkspace }: TaskChangesTabProps) { const { t } = useTranslation("app"); const [files, setFiles] = useState([]); const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 }); @@ -309,7 +318,10 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai } // Non-done task without a worktree → only show fallback state when branch-fallback diff is empty. - if (!isDone && !worktree && files.length === 0) { + // A workspace task legitimately has no singular `worktree` (its changes come from the per-sub-repo + // aggregation), so it must NOT hit this "No worktree available" branch — fall through to the + // standard empty/populated rendering below. + if (!isDone && !worktree && !isWorkspace && files.length === 0) { if (modifiedFiles && modifiedFiles.length > 0) { return renderModifiedFilesFallback(modifiedFiles, false, undefined, "execution", t); } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 9ce9f76d86..66efd7109b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -24,8 +24,8 @@ import { } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; -import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; +import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; import type { ToastType } from "../hooks/useToast"; @@ -936,9 +936,7 @@ export function TaskDetailContent({ const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState(null); const [githubRepoOverrideError, setGithubRepoOverrideError] = useState(null); const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false); - const [isRecoveringBranchBinding, setIsRecoveringBranchBinding] = useState(false); const [isCheckingPrStatus, setIsCheckingPrStatus] = useState(false); - const [recoverBranchBindingOutcome, setRecoverBranchBindingOutcome] = useState(null); const moveMenuRef = useRef(null); const activityListRef = useRef(null); const moveButtonRef = useRef(null); @@ -1045,8 +1043,6 @@ export function TaskDetailContent({ setGithubTrackingEnabledDraft(null); setGithubRepoOverrideError(null); setIsEditing(false); - setRecoverBranchBindingOutcome(null); - setIsRecoveringBranchBinding(false); }, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]); useEffect(() => { @@ -2178,25 +2174,6 @@ export function TaskDetailContent({ }, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]); const isTaskPaused = task.paused || task.userPaused; - const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch; - - const handleRecoverBranchBinding = useCallback(async () => { - setIsRecoveringBranchBinding(true); - try { - const outcome = await recoverBranchBinding(task.id, projectId); - setRecoverBranchBindingOutcome(outcome); - if (outcome.result === "applied") { - addToast(t("taskDetail.branchBinding.reattached", "Reattached branch for {{id}} ({{branch}})", { id: task.id, branch: outcome.branch }), "success"); - onTaskUpdated?.({ ...task, branch: outcome.branch, worktree: undefined }); - } else { - addToast(t("taskDetail.branchBinding.skipped", "Branch reattachment skipped for {{id}}: {{reason}}", { id: task.id, reason: outcome.reason }), "info"); - } - } catch (err) { - addToast(getErrorMessage(err), "error"); - } finally { - setIsRecoveringBranchBinding(false); - } - }, [addToast, onTaskUpdated, projectId, task]); const handleTogglePause = useCallback(async () => { try { @@ -3422,7 +3399,7 @@ export function TaskDetailContent({ )} ) : activeTab === "changes" ? ( - + ) : activeTab === "review" ? ( )} - {showRecoverBranchBindingBanner && ( -
-
-
-

- {t("taskDetail.branchBinding.copy", "This in-review task isn't currently attached to a fusion branch. If a live fusion branch still exists for it, you can reattach it here.")} -

- {recoverBranchBindingOutcome && ( -
- {recoverBranchBindingOutcome.result === "applied" - ? t("taskDetail.branchBinding.reattachedResult", "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", { branch: recoverBranchBindingOutcome.branch, count: recoverBranchBindingOutcome.aheadCount, base: recoverBranchBindingOutcome.integrationBase }) - : t("taskDetail.branchBinding.skippedResult", "Reattachment skipped: {{reason}}", { reason: recoverBranchBindingOutcome.reason })} - {recoverBranchBindingOutcome.result === "skipped" && recoverBranchBindingOutcome.candidates?.length ? ( - - {` ${t("taskDetail.branchBinding.candidates", "Candidates:")} ${recoverBranchBindingOutcome.candidates.map((entry) => `${entry.branch} (${entry.aheadCount})`).join(", ")}`} - - ) : null} -
- )} -
- -
-
- )} + {/* + FNXC:Workspace 2026-06-24-23:10: + The "Branch needs reattachment" banner was removed. It fired for any in-review task with a + null singular `task.branch`, which is the NORMAL, healthy state for a workspace task (its + attachment is the per-sub-repo worktrees in `task.workspaceWorktrees`, not a root branch), so + the banner was a permanent false positive for workspace tasks. Reattachment of a genuinely + lost binding is handled automatically by self-healing's reconcileInReviewBranchRebind, which + runs event-driven on the move-to-in-review and on its sweep — no manual user action needed. + */}
{isEditing ? ( <> diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index 9028f8049b..e00773951a 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -70,6 +70,10 @@ vi.mock("../../api", async () => { fetchAheadCommits: vi.fn(), fetchRemoteCommits: vi.fn(), fetchBranchCommits: vi.fn(), + // FNXC:Test 2026-06-25-00:10: GitManagerModal detects workspace sub-repos on mount via + // fetchWorkspaceRepos; the mock was never added when that call landed, breaking the whole suite + // at import. Default to a non-workspace project ({ repos: [] }) so the root git path is exercised. + fetchWorkspaceRepos: vi.fn().mockResolvedValue({ repos: [] }), }; }); @@ -112,6 +116,7 @@ import { fetchAheadCommits, fetchRemoteCommits, fetchBranchCommits, + fetchWorkspaceRepos, } from "../../api"; import { subscribeSse } from "../../sse-bus"; @@ -284,6 +289,64 @@ describe("GitManagerModal", () => { (fetchRemoteCommits as any).mockResolvedValue([]); }); + // ── Workspace root-race toast suppression ─────────────────── + // FNXC:Workspace 2026-06-25-00:10: a workspace project's root is non-git, so the first git status + // (no repoPath yet) fails "Not a git repository". That benign race must NOT toast; a real + // non-workspace project with the same error must. + + it("does NOT toast 'Not a git repository' for a workspace project's initial root-race fetch", async () => { + (fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["openvide", "swarmclaw"] }); + // Root (no repoPath) → not a git repo; a real sub-repo → resolves. + (fetchGitStatus as any).mockImplementation((_pid: unknown, _opts: unknown, repoPath?: string) => + repoPath + ? Promise.resolve({ branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 }) + : Promise.reject(new Error("Not a git repository")), + ); + + render(); + + // Wait until the re-fetch against the selected sub-repo has happened. + await waitFor(() => { + expect((fetchGitStatus as any).mock.calls.some((c: unknown[]) => c[2] === "openvide")).toBe(true); + }); + expect(mockAddToast).not.toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + + it("DOES toast 'Not a git repository' for a real non-workspace project", async () => { + (fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] }); + (fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository")); + + render(); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + }); + + it("does not let a stale workspace project's late detection suppress a real error after a rapid project switch", async () => { + // FNXC:Workspace 2026-06-25-09:40 (generation guard): switch from workspace project A (whose + // fetchWorkspaceRepos resolves LATE) to broken non-workspace project B before A resolves. A's late + // "workspace" verdict must be abandoned (generation guard) so it can't suppress B's real error. + let resolveA: (v: { repos: string[] }) => void = () => {}; + const aPromise = new Promise<{ repos: string[] }>((r) => { resolveA = r; }); + (fetchWorkspaceRepos as any).mockImplementation((pid: string) => + pid === "projA" ? aPromise : Promise.resolve({ repos: [] })); + (fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository")); + + const { rerender } = render( + , + ); + // Switch to B before A's detection resolves. + rerender(); + // A resolves late as a workspace — must be ignored for the now-current project B. + resolveA({ repos: ["openvide"] }); + + // B is a genuinely broken non-workspace repo → its error must still surface. + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error"); + }); + }); + // ── Basic Rendering ───────────────────────────────────────── it("renders nothing when not open", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx index 38baf06f45..11e944261c 100644 --- a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx @@ -201,6 +201,44 @@ describe("TaskChangesTab — worktree-backed (non-done tasks)", () => { }); }); +// FNXC:Workspace 2026-06-25-00:40: a workspace task has no singular `worktree` — its changes come +// from the backend's per-sub-repo aggregation (repo-prefixed paths). It must render those instead of +// the single-repo "No worktree available" empty state. +describe("TaskChangesTab — workspace tasks", () => { + it("renders aggregated repo-prefixed files for a workspace task (no singular worktree)", async () => { + mockFetchTaskDiff.mockResolvedValue({ + files: [ + { path: "openvide/src/a.ts", status: "added", additions: 2, deletions: 0, patch: "@@ -0,0 +1,2 @@\n+a\n+aa" }, + { path: "swarmclaw/lib/b.ts", status: "modified", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n+b\n-old" }, + ], + stats: { filesChanged: 2, additions: 3, deletions: 1 }, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("openvide/src/a.ts")).toBeTruthy(); + }); + expect(screen.getByText("swarmclaw/lib/b.ts")).toBeTruthy(); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); + + it("does NOT show 'No worktree available' for an empty workspace task", async () => { + mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("No files modified.")).toBeTruthy(); + }); + expect(screen.queryByText("No worktree available for this task.")).toBeNull(); + }); +}); + describe("TaskChangesTab — commit-backed (done tasks)", () => { it("loads diff from fetchTaskDiff for done task with commitSha", async () => { mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx deleted file mode 100644 index 2a922920b5..0000000000 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { TaskDetailModal } from "../TaskDetailModal"; -import * as api from "../../api"; -import { makeTask, noop, noopDelete, noopMerge, noopMove, noopOpenDetail, setupTaskDetailModalHooks } from "./TaskDetailModal.test-helpers"; - -setupTaskDetailModalHooks(); - -describe("TaskDetailModal rebind banner", () => { - it("shows banner only for in-review tasks with missing branch", () => { - const { rerender } = render( - , - ); - - expect(screen.getByText("Branch needs reattachment")).toBeTruthy(); - - rerender( - , - ); - // FN-5113: branch present + worktree cleared is the healthy post-handoff/post-rebind state (see AGENTS.md FN-5083). Banner must NOT show. - expect(screen.queryByText("Branch needs reattachment")).toBeNull(); - - rerender( - , - ); - expect(screen.queryByText("Branch needs reattachment")).toBeNull(); - }); - - it("calls recover endpoint and renders applied result", async () => { - const recoverSpy = vi.spyOn(api, "recoverBranchBinding").mockResolvedValueOnce({ - taskId: "FN-099", - result: "applied", - branch: "fusion/fn-099", - aheadCount: 2, - integrationBase: "main", - previousBranch: null, - }); - - render( - , - ); - - await userEvent.click(screen.getByRole("button", { name: "Reattach branch" })); - - expect(recoverSpy).toHaveBeenCalledWith("FN-099", undefined); - expect(await screen.findByText(/Reattached fusion\/fn-099/)).toBeTruthy(); - }); - - it("renders skipped reason and candidates", async () => { - vi.spyOn(api, "recoverBranchBinding").mockResolvedValueOnce({ - taskId: "FN-099", - result: "skipped", - reason: "ambiguous-candidates", - candidates: [ - { branch: "fusion/FN-099", aheadCount: 1 }, - { branch: "fusion/fn-099", aheadCount: 2 }, - ], - }); - - render( - , - ); - - await userEvent.click(screen.getByRole("button", { name: "Reattach branch" })); - - expect(await screen.findByText(/Reattachment skipped: ambiguous-candidates/)).toBeTruthy(); - expect(screen.getByText(/fusion\/FN-099/)).toBeTruthy(); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index d7cc3ce404..080d8ae781 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -81,6 +81,10 @@ vi.mock("lucide-react", () => ({ Split: () => null, Merge: () => null, Repeat: () => null, + // FNXC:Test 2026-06-24-23:30: WorkflowNodeEditor (lazy-loaded by TaskDetailModal) uses ToggleRight + // for the optional-group node (FN-6880); the explicit mock list omitted it, breaking every + // TaskDetailModal suite at import. Keep this list in sync with the node-editor icon set. + ToggleRight: () => null, ClipboardCheck: () => null, ListChecks: () => null, Code2: () => null, diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index f2350c5388..226e9ef736 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/dashboard", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion dashboard: React UI and HTTP API server for monitoring and controlling the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts new file mode 100644 index 0000000000..15ac0b266b --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-diff-workspace.test.ts @@ -0,0 +1,154 @@ +/* +FNXC:Workspace 2026-06-25-00:40: +A workspace (multi-repo) task has no singular `worktree`/`branch` — its changes live in per-sub-repo +worktrees recorded in `task.workspaceWorktrees`. `/tasks/:id/diff` and `/tasks/:id/file-diffs` must +aggregate each sub-repo's diff (computed in that sub-repo's worktree) and prefix every file path with +the sub-repo key, instead of diffing the non-git workspace root (which returns empty). + +We mock runGitCommand (keyed by cwd so each sub-repo returns its own files) and node:fs/promises +access (so the sub-repo worktrees "exist") — no real/slow git. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task } from "@fusion/core"; + +const runGitCommandMock = vi.fn<(...args: any[]) => Promise>(); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + // Per-repo base: the route passes the sub-repo's captured baseCommitSha through. + resolveDiffBase: vi.fn(async (task: any) => task.baseCommitSha), + runGitCommand: (...args: any[]) => runGitCommandMock(...args), +})); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { ...actual, access: vi.fn(async () => undefined) }; +}); + +import { createServer } from "../server.js"; + +class MockStore extends EventEmitter { + private tasks = new Map(); + getRootDir(): string { return "/ws-root"; } + getFusionDir(): string { return "/ws-root/.fusion"; } + getDatabase() { + return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }) }; + } + getMissionStore() { + return { + listMissions: vi.fn().mockResolvedValue([]), createMission: vi.fn(), getMission: vi.fn(), updateMission: vi.fn(), deleteMission: vi.fn(), + listTemplates: vi.fn().mockResolvedValue([]), createTemplate: vi.fn(), getTemplate: vi.fn(), updateTemplate: vi.fn(), deleteTemplate: vi.fn(), instantiateMission: vi.fn(), + }; + } + async listTasks(): Promise { return Array.from(this.tasks.values()); } + getTask(id: string): Task | undefined { return this.tasks.get(id); } + addTask(task: Task): void { this.tasks.set(task.id, task); } + async getTaskCommitAssociationsByLineageId(): Promise<[]> { return []; } +} + +function workspaceTask(): Task { + return { + id: "MULT-002", title: "ws task", description: "", column: "in-review", + dependencies: [], steps: [], currentStep: 0, log: [], + createdAt: "2026-06-24T00:00:00.000Z", updatedAt: "2026-06-24T00:00:00.000Z", + worktree: undefined, branch: undefined, + workspaceWorktrees: { + // Intentionally non-alphabetical insertion to prove sorted, deterministic output. + swarmclaw: { worktreePath: "/wt/swarmclaw", branch: "fusion/mult-002", baseCommitSha: "baseS" }, + openvide: { worktreePath: "/wt/openvide", branch: "fusion/mult-002", baseCommitSha: "baseO" }, + }, + } as Task; +} + +// Per-cwd git responses. Anything not listed throws — restrictActiveCommittedFilesToOwnTask's +// attribution probes hit that and are swallowed (display-only), preserving the broad diff. +const RESPONSES: Record> = { + "/wt/openvide": { + "diff --name-status -M baseO..HEAD": "A\tsrc/a.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseO -- src/a.ts": "+a\n+aa\n", + }, + "/wt/swarmclaw": { + "diff --name-status -M baseS..HEAD": "M\tlib/b.ts", + "diff --cached --name-status -M": "", + "diff --name-status -M": "", + "diff baseS -- lib/b.ts": "+b\n-old\n", + }, +}; + +describe("workspace task diff aggregation", () => { + beforeEach(() => { + vi.clearAllMocks(); + runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => { + const repo = (cwd && RESPONSES[cwd]) || {}; + const key = gitArgs.join(" "); + if (key in repo) return repo[key] ?? ""; + throw new Error(`Unexpected git command [${cwd}]: ${key}`); + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("/diff aggregates per-sub-repo files with repo-prefixed paths and summed stats", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/diff"); + + expect(res.status).toBe(200); + expect(res.body.files.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.files.find((f: any) => f.path === "openvide/src/a.ts").status).toBe("added"); + expect(res.body.stats).toEqual({ filesChanged: 2, additions: 3, deletions: 1 }); + }); + + it("preserves deterministic repo-sorted order across the concurrent (parallelized) aggregation", async () => { + // FNXC:WorkspaceDiff 2026-06-25-09:40: sub-repos are now diffed concurrently; the output must + // still be sorted by repo key regardless of which sub-repo's git calls finish first. Three repos + // inserted out of order, with the first-sorted repo deliberately given the slowest git response. + const task = workspaceTask(); + (task as any).workspaceWorktrees = { + zulu: { worktreePath: "/wt/zulu", branch: "fusion/mult-002", baseCommitSha: "baseZ" }, + alpha: { worktreePath: "/wt/alpha", branch: "fusion/mult-002", baseCommitSha: "baseA" }, + mike: { worktreePath: "/wt/mike", branch: "fusion/mult-002", baseCommitSha: "baseM" }, + }; + const resp: Record> = { + "/wt/alpha": { "diff --name-status -M baseA..HEAD": "A\ta.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseA -- a.ts": "+x\n" }, + "/wt/mike": { "diff --name-status -M baseM..HEAD": "A\tm.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseM -- m.ts": "+y\n" }, + "/wt/zulu": { "diff --name-status -M baseZ..HEAD": "A\tz.ts", "diff --cached --name-status -M": "", "diff --name-status -M": "", "diff baseZ -- z.ts": "+w\n" }, + }; + runGitCommandMock.mockImplementation(async (gitArgs: string[], cwd?: string) => { + const key = gitArgs.join(" "); + const repo = (cwd && resp[cwd]) || {}; + if (key in repo) { + // Make the first-sorted repo (alpha) resolve LAST to prove order is by key, not completion. + if (cwd === "/wt/alpha") await new Promise((r) => setTimeout(r, 5)); + return repo[key] ?? ""; + } + throw new Error(`Unexpected git command [${cwd}]: ${key}`); + }); + + const store = new MockStore(); + store.addTask(task); + const app = createServer(store as any); + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/diff"); + + expect(res.status).toBe(200); + expect(res.body.files.map((f: any) => f.path)).toEqual(["alpha/a.ts", "mike/m.ts", "zulu/z.ts"]); + }); + + it("/file-diffs returns repo-prefixed per-file patches", async () => { + const store = new MockStore(); + store.addTask(workspaceTask()); + const app = createServer(store as any); + + const { get } = await import("../test-request.js"); + const res = await get(app, "/api/tasks/MULT-002/file-diffs"); + + expect(res.status).toBe(200); + expect(res.body.map((f: any) => f.path)).toEqual(["openvide/src/a.ts", "swarmclaw/lib/b.ts"]); + expect(res.body.find((f: any) => f.path === "swarmclaw/lib/b.ts").diff).toContain("-old"); + }); +}); diff --git a/packages/dashboard/src/routes/register-session-diff-routes.ts b/packages/dashboard/src/routes/register-session-diff-routes.ts index b41ec8d5ec..5ddc7785cc 100644 --- a/packages/dashboard/src/routes/register-session-diff-routes.ts +++ b/packages/dashboard/src/routes/register-session-diff-routes.ts @@ -1,6 +1,8 @@ import { access } from "node:fs/promises"; +import { join } from "node:path"; import type { Request, Router } from "express"; import type { RunAuditEvent, RunAuditEventFilter } from "@fusion/core"; +import { isWorkspaceTask } from "@fusion/core"; import { ApiError, notFound, rethrowAsApiError } from "../api-error.js"; import { resolveDiffBase, runGitCommand } from "./resolve-diff-base.js"; import { countPatchLines } from "./diff-counts.js"; @@ -372,6 +374,235 @@ async function collectDoneRangeFiles(range: string, rootDir: string): Promise(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const results = new Array(items.length); + let cursor = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = Array.from({ length: workerCount }, async () => { + for (;;) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await fn(items[index]!, index); + } + }); + await Promise.all(workers); + return results; +} + +/** + * Build the per-file detailed diff for a SINGLE worktree: committed + * (diffBase..HEAD) + staged + unstaged, with the committed set scoped to the + * task's own commits. Untracked files are intentionally excluded — at review + * time they are almost always build artifacts/cache/logs. + * + * Extracted so the single-repo diff endpoints AND the per-sub-repo workspace + * aggregation (computeWorkspaceTaskFiles) share ONE implementation. The + * single-repo `/tasks/:id/diff` and `/tasks/:id/file-diffs` paths must remain + * behaviour-identical to their previous inline form. + */ +async function computeWorktreeDetailedFiles( + taskLike: { id: string; baseBranch?: string; baseCommitSha?: string }, + cwd: string, + timeoutMs: number, +): Promise { + const diffBase = await resolveDiffBase(taskLike, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); + + const fileMap = new Map(); + + if (diffBase) { + try { + const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, timeoutMs)).trim(); + for (const line of committedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // committed diff failed + } + } + + await restrictActiveCommittedFilesToOwnTask(fileMap, { + taskId: taskLike.id, + diffBase, + worktreePath: cwd, + runGit: (args) => runGitCommand(args, cwd, timeoutMs), + }); + + try { + const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of stagedOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // staged diff failed + } + + try { + const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, timeoutMs)).trim(); + for (const line of workingTreeOutput.split("\n").filter(Boolean)) { + const parsed = parseNameStatusLine(line); + if (!parsed || fileMap.has(parsed.path)) continue; + fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); + } + } catch { + // working tree diff failed + } + + /* + FNXC:WorkspaceDiff 2026-06-25-09:40: + The per-file `git diff` patch fetch is the dominant cost (one subprocess per changed file). Run it + with bounded concurrency instead of a serial await loop — independent files do not depend on each + other, so this collapses M serial git spawns to ~M/limit wall-clock. We deliberately do NOT skip the + patch for deleted files: /file-diffs filters out empty-patch entries and the patch supplies the + additions/deletions counts, so a delete needs its real patch to stay visible and counted. Status + uses the shared parseStatusCode helper (single source of truth for the A/D/R/M mapping). + */ + const entries = Array.from(fileMap.entries()).filter(([filePath]) => Boolean(filePath)); + const results = await mapWithConcurrency(entries, 8, async ([filePath, { statusCode, oldPath }]) => { + const status = parseStatusCode(statusCode); + + let patch = ""; + try { + patch = diffBase + ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, timeoutMs) + : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, timeoutMs); + } catch { + // ignore individual file errors + } + + const { additions, deletions } = countPatchLines(patch); + return oldPath + ? { path: filePath, status, additions, deletions, patch, oldPath } + : { path: filePath, status, additions, deletions, patch }; + }); + + return results; +} + +/** + * Aggregate a workspace task's changed files across ALL acquired sub-repo + * worktrees. A workspace task has no singular `task.worktree`/`task.branch` + * (those are null by design); its per-repo state lives in + * `task.workspaceWorktrees`. Each sub-repo's diff is computed in its own live + * worktree (in-progress/in-review) or, when that worktree is gone (done tasks), + * from its landed range in the sub-repo root. Every file path is prefixed with + * the sub-repo key (e.g. `openvide/src/foo.ts`) so the Changes tab shows which + * sub-repo each file belongs to. A missing/unreadable sub-repo is skipped + * best-effort rather than failing the whole response. + */ +async function computeWorkspaceTaskFiles( + task: { + id: string; + baseBranch?: string; + workspaceWorktrees?: Record; + }, + rootDir: string, + timeoutMs: number, +): Promise { + const worktrees = task.workspaceWorktrees ?? {}; + + /* + FNXC:WorkspaceDiff 2026-06-25-09:40: + Resolve each sub-repo's diff CONCURRENTLY (bounded) rather than awaiting them one at a time: every + sub-repo's git work is independent, so a serial loop made the aggregate cost N×(per-repo) and could + block the response for a long time on a many-repo task. Keys are sorted first and mapped by position, + so the aggregated output stays in deterministic repo-sorted order regardless of completion order. + */ + const repoRels = Object.keys(worktrees).sort(); + const perRepo = await mapWithConcurrency(repoRels, 4, async (repoRel) => { + const entry = worktrees[repoRel]; + if (!entry) return [] as WorktreeDetailedFile[]; + + let repoFiles: WorktreeDetailedFile[] = []; + + // Prefer the live sub-repo worktree (in-progress / in-review). The access() + // probe is an optimistic fast-path skip; the try/catch below is the real guard. + let worktreeUsable = false; + if (entry.worktreePath) { + try { + await access(entry.worktreePath); + worktreeUsable = true; + } catch { + // worktree gone → fall through to the landed-range fallback + } + } + if (worktreeUsable) { + try { + repoFiles = await computeWorktreeDetailedFiles( + // Per-repo base: use the sub-repo's own captured fork point, with the + // workspace task's baseBranch stripped so resolveDiffBase uses the + // per-repo baseCommitSha rather than a shared workspace branch. + { id: task.id, baseBranch: undefined, baseCommitSha: entry.baseCommitSha }, + entry.worktreePath, + timeoutMs, + ); + } catch { + repoFiles = []; + } + } + + // Fallback: landed range in the sub-repo root (a done task whose per-repo + // worktree was already cleaned up). Each sub-repo lands independently with + // its own baseCommitSha → landedSha. + // FNXC:WorkspaceDiff 2026-06-25-09:40: collectDoneRangeFiles returns AggregatedDoneTaskFile, which + // carries no oldPath, so a renamed file's rename-SOURCE is unavailable on this done fallback (the + // file still shows under its new path). The live-worktree path above does preserve oldPath. + if (repoFiles.length === 0 && entry.baseCommitSha && entry.landedSha) { + const repoRootDir = join(rootDir, repoRel); + try { + const rangeFiles = await collectDoneRangeFiles(`${entry.baseCommitSha}..${entry.landedSha}`, repoRootDir); + repoFiles = rangeFiles.map((file) => ({ + path: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + } catch { + repoFiles = []; + } + } + + // Prefix every path with the sub-repo key so the Changes tab shows which repo each file is in. + return repoFiles.map((file) => ({ + ...file, + path: `${repoRel}/${file.path}`, + oldPath: file.oldPath ? `${repoRel}/${file.oldPath}` : undefined, + })); + }); + + return perRepo.flat(); +} + function extractCommitShaCandidate(event: { target?: unknown; metadata?: unknown; payload?: unknown; newValue?: unknown }): string | undefined { if (typeof event.target === "string" && event.target.trim()) { return event.target.trim(); @@ -730,6 +961,31 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // FNXC:WorkspaceDiff 2026-06-25-09:40: + // Workspace tasks have no singular worktree/branch; their changes live in per-sub-repo + // worktrees. Aggregate across them (repo-prefixed paths) and short-circuit BEFORE the single-repo + // logic, which would diff the non-git workspace root and return empty. renamed→modified is folded + // to match the /diff contract (which has no 'renamed' status; /file-diffs keeps it). + if (isWorkspaceTask(task)) { + const workspaceFiles = await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), DIFF_TIMEOUT_MS); + const files = workspaceFiles.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? "modified" : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); + res.json({ + files, + stats: { + filesChanged: files.length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }, + }); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -906,85 +1162,18 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = resolvedWorktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - - // Only count files actually changed by the task: committed (base..HEAD) - // + staged + unstaged. Untracked files are intentionally excluded — at - // review time they're almost always build artifacts/cache/logs that - // weren't in .gitignore, not real task changes. - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 10000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // committed diff failed - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 10000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // staged diff failed - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 10000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, parsed.statusCode); - } - } catch { - // working tree diff failed - } - - const files: Array<{ - path: string; - status: "added" | "modified" | "deleted"; - additions: number; - deletions: number; - patch: string; - }> = []; - - for (const [filePath, statusCode] of fileMap) { - if (!filePath) continue; - - let status: "added" | "modified" | "deleted"; - if (statusCode.startsWith("A")) status = "added"; - else if (statusCode.startsWith("D")) status = "deleted"; - else status = "modified"; - - let patch = ""; - try { - patch = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 10000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 10000); - } catch { - // ignore individual file errors - } - - const { additions, deletions } = countPatchLines(patch); - - files.push({ path: filePath, status, additions, deletions, patch }); - } + // Single-repo detailed diff (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Renames fold to + // "modified" here (the /diff shape has no "renamed" status), matching the + // previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, DIFF_TIMEOUT_MS); + const files = detailed.map((file) => ({ + path: file.path, + status: file.status === "renamed" ? ("modified" as const) : file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })); const stats = { filesChanged: files.length, @@ -1010,6 +1199,20 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute return; } + // FNXC:WorkspaceDiff 2026-06-25-09:40: + // Workspace tasks aggregate per-sub-repo patches (repo-prefixed paths); short-circuit before the + // single-repo logic that diffs the non-git root. Unlike /diff, /file-diffs preserves the + // 'renamed' status + oldPath. Empty-patch entries are dropped (parity with the single-repo path). + if (isWorkspaceTask(task)) { + const workspaceFiles = (await computeWorkspaceTaskFiles(task, scopedStore.getRootDir(), FILE_DIFFS_TIMEOUT_MS)) + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); + res.json(workspaceFiles); + return; + } + if (task.column === "done") { const mergeShaForBaseBoundary = await resolveDoneTaskMergeSha(task, scopedStore, { includeBaseCommitSha: true }); const resolvedMergeSha = await resolveDoneTaskMergeSha(task, scopedStore); @@ -1153,83 +1356,17 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } const cwd = worktree; - const diffBase = await resolveDiffBase(task, cwd, "HEAD", undefined, { enableDisplayRecovery: true }); - // Only files actually changed by the task: committed + staged + unstaged. - // Untracked files (build artifacts, cache, logs) are intentionally - // excluded so the count matches "ACTUAL files changed by the task". - const fileMap = new Map(); - - if (diffBase) { - try { - const committedOutput = (await runGitCommand(["diff", "--name-status", "-M", `${diffBase}..HEAD`], cwd, 5000)).trim(); - for (const line of committedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // continue with working-tree-only changes - } - } - - await restrictActiveCommittedFilesToOwnTask(fileMap, { - taskId: task.id, - diffBase, - worktreePath: cwd, - runGit: (args) => runGitCommand(args, cwd, 5000), - }); - - try { - const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of stagedOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore staged diff failures - } - - try { - const workingTreeOutput = (await runGitCommand(["diff", "--name-status", "-M"], cwd, 5000)).trim(); - for (const line of workingTreeOutput.split("\n").filter(Boolean)) { - const parsed = parseNameStatusLine(line); - if (!parsed || fileMap.has(parsed.path)) continue; - fileMap.set(parsed.path, { statusCode: parsed.statusCode, oldPath: parsed.oldPath }); - } - } catch { - // ignore unstaged diff failures - } - - const files = []; - - for (const [filePath, { statusCode, oldPath }] of fileMap.entries()) { - let status: "added" | "modified" | "deleted" | "renamed" = "modified"; - - if (statusCode.startsWith("A")) { - status = "added"; - } else if (statusCode.startsWith("D")) { - status = "deleted"; - } else if (statusCode.startsWith("R")) { - status = "renamed"; - } - - let diff = ""; - try { - diff = diffBase - ? await runGitCommand(["diff", diffBase, "--", filePath], cwd, 5000) - : await runGitCommand(["diff", "HEAD", "--", filePath], cwd, 5000); - } catch { - diff = ""; - } - - if (!diff) { - continue; - } - - files.push(oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff }); - } + // Single-repo per-file patches (committed base..HEAD + staged + unstaged), + // shared with the per-sub-repo workspace aggregation. Files with an empty + // patch (e.g. pure renames with no content change) are dropped, matching + // the previous inline behaviour. + const detailed = await computeWorktreeDetailedFiles(task, cwd, FILE_DIFFS_TIMEOUT_MS); + const files = detailed + .filter((file) => file.patch) + .map((file) => (file.oldPath + ? { path: file.path, status: file.status, diff: file.patch, oldPath: file.oldPath } + : { path: file.path, status: file.status, diff: file.patch })); fileDiffsCache.set(task.id, { files, diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 93a4e66607..b1c1b1f086 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -195,7 +195,6 @@ const qualityAppComponentTests = [ "TaskDetailModal.create-pr-integration", "TaskDetailModal.github-tracking-header", "TaskDetailModal.github-tracking-stale", - "TaskDetailModal.rebind-banner", "TaskDocumentsTab", "TaskFieldsSection", "TaskForm", diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index e2816f1d47..b31eb62bac 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,5 +1,13 @@ # @fusion/desktop +## 0.47.0 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/dashboard@0.47.0 +- @fusion/engine@0.47.0 + ## 0.46.0 ### Patch Changes diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6225d943b8..75da40f6f4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@fusion/desktop", "productName": "Fusion", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "author": { "name": "Runfusion", diff --git a/packages/droid-cli/CHANGELOG.md b/packages/droid-cli/CHANGELOG.md index 25ed802bcf..cc92649e41 100644 --- a/packages/droid-cli/CHANGELOG.md +++ b/packages/droid-cli/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/droid-cli +## 0.11.36 + +### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.36 + ## 0.11.35 ### Patch Changes diff --git a/packages/droid-cli/package.json b/packages/droid-cli/package.json index a32cc27737..909ee2f570 100644 --- a/packages/droid-cli/package.json +++ b/packages/droid-cli/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/droid-cli", - "version": "0.11.35", + "version": "0.11.36", "description": "First-party Fusion pi extension that routes LLM calls through the Droid CLI subprocess.", "license": "MIT", "private": true, diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 90283be033..b859aee5bf 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion/engine +## 0.47.0 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/pi-claude-cli@0.47.0 + ## 0.46.0 ### Patch Changes diff --git a/packages/engine/package.json b/packages/engine/package.json index 36a29c7ea9..e36988d96f 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/engine", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/engine/src/__tests__/workspace-merger-deps-resilient.test.ts b/packages/engine/src/__tests__/workspace-merger-deps-resilient.test.ts new file mode 100644 index 0000000000..ed8184e5cd --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-deps-resilient.test.ts @@ -0,0 +1,135 @@ +/* +FNXC:Workspace 2026-06-24-23:50 (resilient workspace land — dependency-sync failure): +A workspace per-repo land must NOT be blocked by one sub-repo whose clean-room `npm install` +fails (e.g. a corrupt `-@0.0.1` lockfile entry npm 11 rejects). The git squash does not need +installed deps; only dep-dependent merge verification degrades. landWorkspaceTask sets +`nonFatalDependencySync` on landOneRepo so the install throw is caught, logged, and the land +proceeds. The single-repo land path keeps the documented HARD-fail (flag defaults off). + +We drive the REAL landWorkspaceTask / landOneRepo against a REAL git fixture with injected +agents (the squash is a plain `git merge --squash`, no AI), and MOCK installWorktreeDependencies +to throw — so no real/slow/networked npm runs (FN-5048). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; + +vi.mock("../merge-dependency-sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, installWorktreeDependencies: vi.fn() }; +}); + +import { installWorktreeDependencies } from "../merge-dependency-sync.js"; +import { landWorkspaceTask, landOneRepo } from "../merger-ai.js"; +import { createRunAuditor, generateSyntheticRunId } from "../run-audit.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; +const TASK_ID = "FN-3001"; +const BRANCH = "fusion/fn-3001"; +const NPM_FAILURE = new Error("Dependency sync failed for FN-3001: npm error EINVALIDPACKAGENAME Invalid package name \"-\" of package \"-@0.0.1\""); + +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" }); +} + +function createStore(): TaskStore & { logs: string[] } { + const emitter = new EventEmitter(); + const logs: string[] = []; + return Object.assign(emitter, { + logs, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn((_id: string, message: string) => { logs.push(message); return Promise.resolve(undefined); }), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + // mergeAndReview reads store.getTask().comments for prompt context — return a real task shape. + getTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "in-review", branch: BRANCH, comments: [], steeringComments: [], steps: [], log: [] }), + moveTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "done" } as Task), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + }) as unknown as TaskStore & { logs: string[] }; +} + +function addRepoBranchWithEdit(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 feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +const squashMergeAgent = async (cwd: string): Promise => { + configureIdentity(cwd); + try { execSync(`git merge --squash ${BRANCH}`, { cwd, stdio: "pipe" }); } catch { /* conflicts handled below */ } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${BRANCH}: squashed"`, { cwd, stdio: "pipe" }); +}; +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, title: "Workspace merge task", description: "", column: "in-review", + branch: BRANCH, dependencies: [], steps: [], currentStep: 0, log: [], workspaceWorktrees, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("workspace land — dependency-sync failure resilience", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("lands ALL sub-repos even when clean-room dependency sync fails (non-fatal)", async () => { + vi.mocked(installWorktreeDependencies).mockRejectedValue(NPM_FAILURE); + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + }); + + // Despite every per-repo install throwing, both repos land and the integration ref advances. + expect(result.allLanded).toBe(true); + for (const r of result.repos) expect(r.status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + // The degradation is surfaced, not swallowed silently. + expect(store.logs.some((m) => /dependency sync FAILED/i.test(m) && /deps unavailable/i.test(m))).toBe(true); + }); + + it("single-repo land (flag off) still HARD-fails on a dependency-sync failure", async () => { + vi.mocked(installWorktreeDependencies).mockRejectedValue(NPM_FAILURE); + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const store = createStore(); + const audit = createRunAuditor(store, { runId: generateSyntheticRunId("ai-merge", TASK_ID), agentId: "merger", taskId: TASK_ID, phase: "merge" }); + + // landOneRepo WITHOUT nonFatalDependencySync → the documented hard-fail must propagate. + await expect( + landOneRepo(fx.repoPath("repo-a"), BRANCH, "main", { + taskId: TASK_ID, settings: { autoMerge: false } as never, audit, + log: async () => undefined, setStatus: async () => undefined, maxPasses: 1, + mergeAgent: squashMergeAgent, reviewAgent: approveReviewAgent, stashResolveAgent: async () => undefined, + includeTaskId: true, trailers: [], store, + // nonFatalDependencySync intentionally omitted (defaults off) + }), + ).rejects.toThrow(/Invalid package name/); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 8973c66c70..9a4d91aa16 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -60,7 +60,10 @@ function createStore(settings: Record = {}): TaskStore & Record updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), appendAgentLog: vi.fn().mockResolvedValue(undefined), - getTask: vi.fn().mockResolvedValue(undefined), + // FNXC:Test 2026-06-24-23:50: mergeAndReview reads store.getTask().comments for merge/review + // prompt context (selectUserCommentsForAgentContext); an undefined return throws mid-land. Return + // a real task shape so the per-repo land reaches landSquash. + getTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "in-review", branch: BRANCH, comments: [], steeringComments: [], steps: [], log: [] }), moveTask: vi.fn((id: string, column: string) => { moveTaskCalls.push({ id, column }); return Promise.resolve({ id, column } as Task); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 1194dc2420..2c9bd2ade1 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -195,6 +195,16 @@ export async function pruneExistingAiMergeWorktrees( try { entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); } catch (err: unknown) { + /* + FNXC:AiMerge 2026-06-24-23:10: + An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory + (e.g. `/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a + workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means + "nothing to prune" — skip it silently rather than emitting an alarming warning on every merge. + Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir + (which always exists) remains fatal. + */ + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue; await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); if (tempRoot === tmpdir()) throw err; continue; @@ -1003,6 +1013,15 @@ export interface LandRepoContext { taskTitle?: string; signal?: AbortSignal; allowDirtyLocalCheckoutSync?: boolean; + /* + FNXC:Workspace 2026-06-24-23:50 (resilient workspace land): + When true, a clean-room dependency-sync FAILURE is non-fatal: the land proceeds (the git squash + does not need installed deps) and only dep-dependent merge verification degrades for this repo. + Set on the workspace per-repo land so one sub-repo's broken/corrupt package manifest (e.g. an + invalid `-@0.0.1` lockfile entry npm rejects) cannot block landing the other sub-repos. Defaults + off, preserving the documented hard-fail for the single-repo land path. + */ + nonFatalDependencySync?: boolean; store: TaskStore; } @@ -1099,30 +1118,55 @@ export async function landOneRepo( * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. */ const depsSyncStartedAt = Date.now(); - const depsSyncResult = await installWorktreeDependencies({ - cwd: canonicalMergeRoot, - settings, - taskId, - signal, - context: "for AI merge clean room", - logger: aiMergeLog, - log, - }); - await audit.git({ - type: "merge:ai-deps-sync", - target: integrationBranch, - metadata: { + let depsSyncResult: Awaited> | null = null; + try { + depsSyncResult = await installWorktreeDependencies({ + cwd: canonicalMergeRoot, + settings, taskId, - tipSha, - mergeRoot: canonicalMergeRoot, - installCommand: depsSyncResult.installCommand, - configured: depsSyncResult.configured, - skipped: depsSyncResult.skipped, - skipReason: depsSyncResult.skipReason, - durationMs: depsSyncResult.durationMs, - }, - }); - await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + signal, + context: "for AI merge clean room", + logger: aiMergeLog, + log, + }); + } catch (depsErr: unknown) { + /* + FNXC:Workspace 2026-06-24-23:50 (resilient workspace land): + The default contract hard-fails install errors so verification cannot silently run against an + uninstalled checkout. For a WORKSPACE per-repo land (ctx.nonFatalDependencySync) we instead + degrade: the git squash does not need installed deps, so one sub-repo whose manifest npm + refuses to install (e.g. a corrupt `-@0.0.1` lockfile entry) must not block landing the + others. Log + audit the degradation and proceed; the merge/review agents still run (they just + cannot run dep-dependent build/test verification for this repo). A genuine abort signal still + propagates. Non-workspace land keeps the original throw. + */ + throwIfAborted(signal, taskId); + if (!ctx.nonFatalDependencySync) throw depsErr; + const depsErrMessage = getErrorMessage(depsErr); + await log(`AI merge (workspace): dependency sync FAILED for this sub-repo's clean room — landing without dep-dependent verification (deps unavailable): ${depsErrMessage}`); + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { taskId, tipSha, mergeRoot: canonicalMergeRoot, failed: true, nonFatal: true, error: depsErrMessage, durationMs: Date.now() - depsSyncStartedAt }, + }); + } + if (depsSyncResult) { + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { + taskId, + tipSha, + mergeRoot: canonicalMergeRoot, + installCommand: depsSyncResult.installCommand, + configured: depsSyncResult.configured, + skipped: depsSyncResult.skipped, + skipReason: depsSyncResult.skipReason, + durationMs: depsSyncResult.durationMs, + }, + }); + } + await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult ? (depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)") : " (failed — non-fatal, deps unavailable)"}`); // 2 + 3. Merge + review loop (corrective passes). const squashSha = await mergeAndReview({ @@ -1616,6 +1660,9 @@ export async function landWorkspaceTask( mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + // FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block + // landing the others — degrade verification for that repo, still land the git squash. + nonFatalDependencySync: true, store, }); if (landResult.outcome === "landed") { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 3bddad24f4..2a0807bf7e 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -575,7 +575,8 @@ type RebindOutcome = | "ambiguous-candidates" | "no-unique-work" | "unsafe-to-auto-mutate:user-paused" - | "unsafe-to-auto-mutate:checked-out"; + | "unsafe-to-auto-mutate:checked-out" + | "workspace-task"; candidates?: Array<{ branch: string; aheadCount: number }>; }; @@ -3884,6 +3885,21 @@ export class SelfHealingManager { for (const task of tasks) { if (options?.includeTaskIds && !options.includeTaskIds.has(task.id)) continue; + /* + FNXC:Workspace 2026-06-24-23:10: + A workspace task is NEVER a branch-rebind candidate. Its attachment is the per-sub-repo + worktrees in `task.workspaceWorktrees`, and its `fusion/` branches live inside each + sub-repo — not in `this.options.rootDir`, which for a workspace is the non-git browse-only + root. A null `task.branch` is its HEALTHY steady state, so trying to rebind a root branch is + meaningless (every git probe below would fail-soft against the non-git root anyway). Skip it + explicitly. The slim list select now carries `workspaceWorktrees`, so `isWorkspaceTask` is + accurate on these slim rows. + */ + if (isWorkspaceTask(task)) { + result.outcomes.push({ taskId: task.id, result: "skipped", reason: "workspace-task" }); + continue; + } + const existingBinding = task.branch; if (existingBinding) { try { diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index bfa7ab5b7e..b5c6a11aa1 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/i18n +## 0.39.10 + +### Patch Changes + +- @fusion/core@0.47.0 + ## 0.39.9 ### Patch Changes diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 24b7df3ecd..b39db188a7 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/i18n", - "version": "0.39.9", + "version": "0.39.10", "license": "MIT", "description": "Fusion i18n: authored translation catalogs and shared i18next configuration for the Fusion dashboard and terminal UI.", "type": "module", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 2a3a2dd6e5..6713d4c6d4 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/mobile +## 0.47.0 + ## 0.46.0 ## 0.45.0 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b14f44a958..d28b5caf82 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/mobile", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion mobile: Capacitor wrapper around the Fusion dashboard for iOS and Android.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/pi-claude-cli/CHANGELOG.md b/packages/pi-claude-cli/CHANGELOG.md index b6b7531318..7c4cd3b4b4 100644 --- a/packages/pi-claude-cli/CHANGELOG.md +++ b/packages/pi-claude-cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/pi-claude-cli +## 0.47.0 + ## 0.46.0 ## 0.45.0 diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index 7d381047f8..66b03d43f5 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/pi-claude-cli", - "version": "0.46.0", + "version": "0.47.0", "description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.", "license": "MIT", "private": true, diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 78c80dd0c3..ff729146d3 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/plugin-sdk +## 0.47.0 + +### Patch Changes + +- @fusion/core@0.47.0 + ## 0.46.0 ### Patch Changes diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index aab31f59e6..001d5b6b22 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/plugin-sdk", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "description": "Fusion plugin SDK: types and helpers for authoring third-party plugins that extend the Fusion dashboard and engine.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md b/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md index c287265e8e..3074873bee 100644 --- a/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/auto-label +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-auto-label/package.json b/plugins/examples/fusion-plugin-auto-label/package.json index 3e95563a72..bf5c44455d 100644 --- a/plugins/examples/fusion-plugin-auto-label/package.json +++ b/plugins/examples/fusion-plugin-auto-label/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/auto-label", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Automatically labels tasks based on description content", "keywords": [ diff --git a/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md b/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md index b154d552c4..ad1f284eb8 100644 --- a/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/ci-status +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-ci-status/package.json b/plugins/examples/fusion-plugin-ci-status/package.json index a8cdbc3519..36d5152b6e 100644 --- a/plugins/examples/fusion-plugin-ci-status/package.json +++ b/plugins/examples/fusion-plugin-ci-status/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/ci-status", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Polls CI status for branches and provides a custom API to query results", "keywords": [ diff --git a/plugins/examples/fusion-plugin-notification/CHANGELOG.md b/plugins/examples/fusion-plugin-notification/CHANGELOG.md index 75e3f35d88..3994810d99 100644 --- a/plugins/examples/fusion-plugin-notification/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-notification/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/notification +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-notification/package.json b/plugins/examples/fusion-plugin-notification/package.json index 140308ac90..1acc9c8af6 100644 --- a/plugins/examples/fusion-plugin-notification/package.json +++ b/plugins/examples/fusion-plugin-notification/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/notification", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Example Fusion plugin that sends webhook notifications on task lifecycle events", "keywords": [ diff --git a/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md b/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md index a8bc25aab3..32c1983159 100644 --- a/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/settings-demo +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-settings-demo/package.json b/plugins/examples/fusion-plugin-settings-demo/package.json index 18aad70aa4..a90b932f2d 100644 --- a/plugins/examples/fusion-plugin-settings-demo/package.json +++ b/plugins/examples/fusion-plugin-settings-demo/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/settings-demo", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Example Fusion plugin demonstrating settings schema and runtime configuration", "keywords": [ diff --git a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md index 8326d8c6a1..00dd7430f4 100644 --- a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/acp-runtime +## 0.1.10 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.9 ### Patch Changes diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json index 327fcc7377..dcc9d4dda0 100644 --- a/plugins/fusion-plugin-acp-runtime/package.json +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/acp-runtime", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "description": "ACP (Agent Client Protocol) runtime plugin for Fusion — drives any ACP-compatible agent over JSON-RPC/stdio", "keywords": [ diff --git a/plugins/fusion-plugin-agent-browser/CHANGELOG.md b/plugins/fusion-plugin-agent-browser/CHANGELOG.md index a777d3f39e..581a4932c5 100644 --- a/plugins/fusion-plugin-agent-browser/CHANGELOG.md +++ b/plugins/fusion-plugin-agent-browser/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/agent-browser +## 0.1.30 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.1.29 ### Patch Changes diff --git a/plugins/fusion-plugin-agent-browser/package.json b/plugins/fusion-plugin-agent-browser/package.json index b614a3de0c..749ae0b9b3 100644 --- a/plugins/fusion-plugin-agent-browser/package.json +++ b/plugins/fusion-plugin-agent-browser/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/agent-browser", - "version": "0.1.29", + "version": "0.1.30", "type": "module", "description": "Agent Browser runtime and prompt/skill/workflow contributions for Fusion", "private": true, diff --git a/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md b/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md index c4eb241d67..79784f6e71 100644 --- a/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md +++ b/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/cli-printing-press +## 0.1.27 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.26 ### Patch Changes diff --git a/plugins/fusion-plugin-cli-printing-press/package.json b/plugins/fusion-plugin-cli-printing-press/package.json index b69a0911f5..0ad47f6ed6 100644 --- a/plugins/fusion-plugin-cli-printing-press/package.json +++ b/plugins/fusion-plugin-cli-printing-press/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/cli-printing-press", - "version": "0.1.26", + "version": "0.1.27", "type": "module", "description": "CLI Printing Press plugin package for Fusion", "private": true, diff --git a/plugins/fusion-plugin-compound-engineering/CHANGELOG.md b/plugins/fusion-plugin-compound-engineering/CHANGELOG.md index a468ad9498..ac24252083 100644 --- a/plugins/fusion-plugin-compound-engineering/CHANGELOG.md +++ b/plugins/fusion-plugin-compound-engineering/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/compound-engineering +## 0.1.10 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.9 ### Patch Changes diff --git a/plugins/fusion-plugin-compound-engineering/package.json b/plugins/fusion-plugin-compound-engineering/package.json index 811cb10a70..5eb220c28b 100644 --- a/plugins/fusion-plugin-compound-engineering/package.json +++ b/plugins/fusion-plugin-compound-engineering/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/compound-engineering", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "description": "Compound Engineering plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md b/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md index 6a6ac603a3..99ec05c8ac 100644 --- a/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/cursor-runtime +## 0.1.29 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.1.28 ### Patch Changes diff --git a/plugins/fusion-plugin-cursor-runtime/package.json b/plugins/fusion-plugin-cursor-runtime/package.json index 9bdc0baccf..cdda955421 100644 --- a/plugins/fusion-plugin-cursor-runtime/package.json +++ b/plugins/fusion-plugin-cursor-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/cursor-runtime", - "version": "0.1.28", + "version": "0.1.29", "type": "module", "description": "Cursor CLI runtime plugin for Fusion", "keywords": [ diff --git a/plugins/fusion-plugin-dependency-graph/CHANGELOG.md b/plugins/fusion-plugin-dependency-graph/CHANGELOG.md index 14a611c924..a0ddd15b4f 100644 --- a/plugins/fusion-plugin-dependency-graph/CHANGELOG.md +++ b/plugins/fusion-plugin-dependency-graph/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/dependency-graph +## 0.1.41 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.40 ### Patch Changes diff --git a/plugins/fusion-plugin-dependency-graph/package.json b/plugins/fusion-plugin-dependency-graph/package.json index d05d7accf2..3231e0404c 100644 --- a/plugins/fusion-plugin-dependency-graph/package.json +++ b/plugins/fusion-plugin-dependency-graph/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/dependency-graph", - "version": "0.1.40", + "version": "0.1.41", "type": "module", "description": "Dependency graph dashboard view plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-droid-runtime/CHANGELOG.md b/plugins/fusion-plugin-droid-runtime/CHANGELOG.md index 15bd27b736..19d4f27b8d 100644 --- a/plugins/fusion-plugin-droid-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-droid-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.36 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.1.35 ### Patch Changes diff --git a/plugins/fusion-plugin-droid-runtime/package.json b/plugins/fusion-plugin-droid-runtime/package.json index 12cb62b3f0..f7ba56bd9b 100644 --- a/plugins/fusion-plugin-droid-runtime/package.json +++ b/plugins/fusion-plugin-droid-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/droid-runtime", - "version": "0.1.35", + "version": "0.1.36", "type": "module", "description": "Droid runtime plugin for Fusion", "keywords": [ diff --git a/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md b/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md index 9709b7f44c..4b829508e5 100644 --- a/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md +++ b/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/even-realities-glasses +## 0.1.29 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.28 ### Patch Changes diff --git a/plugins/fusion-plugin-even-realities-glasses/package.json b/plugins/fusion-plugin-even-realities-glasses/package.json index b23e113917..efa1f12382 100644 --- a/plugins/fusion-plugin-even-realities-glasses/package.json +++ b/plugins/fusion-plugin-even-realities-glasses/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/even-realities-glasses", - "version": "0.1.28", + "version": "0.1.29", "type": "module", "description": "Canonical Even Realities Fusion plugin with board/task cards, actions, notifications, and webhook transport", "keywords": [ diff --git a/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md b/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md index 35a42d1a0a..97cdce1e9e 100644 --- a/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/hermes-runtime +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/fusion-plugin-hermes-runtime/package.json b/plugins/fusion-plugin-hermes-runtime/package.json index 8f45888e5e..f63a88ded7 100644 --- a/plugins/fusion-plugin-hermes-runtime/package.json +++ b/plugins/fusion-plugin-hermes-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/hermes-runtime", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Hermes AI runtime plugin for Fusion - provides AI agent execution runtime", "keywords": [ diff --git a/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md b/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md index 88415e4676..3f8c87ab6d 100644 --- a/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/openclaw-runtime +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/fusion-plugin-openclaw-runtime/package.json b/plugins/fusion-plugin-openclaw-runtime/package.json index a5736afa58..b39d88fd75 100644 --- a/plugins/fusion-plugin-openclaw-runtime/package.json +++ b/plugins/fusion-plugin-openclaw-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/openclaw-runtime", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Provides OpenClaw runtime for Fusion AI agents", "keywords": [ diff --git a/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md b/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md index e9424e6033..c6a9bfcc0e 100644 --- a/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/paperclip-runtime +## 0.2.60 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.2.59 ### Patch Changes diff --git a/plugins/fusion-plugin-paperclip-runtime/package.json b/plugins/fusion-plugin-paperclip-runtime/package.json index 56685a3f73..d977764e04 100644 --- a/plugins/fusion-plugin-paperclip-runtime/package.json +++ b/plugins/fusion-plugin-paperclip-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/paperclip-runtime", - "version": "0.2.59", + "version": "0.2.60", "type": "module", "description": "Paperclip runtime plugin for Fusion — provides AI agent web access capabilities", "keywords": [ diff --git a/plugins/fusion-plugin-reports/CHANGELOG.md b/plugins/fusion-plugin-reports/CHANGELOG.md index 54b707847c..7f81b22003 100644 --- a/plugins/fusion-plugin-reports/CHANGELOG.md +++ b/plugins/fusion-plugin-reports/CHANGELOG.md @@ -1,5 +1,13 @@ # @fusion-plugin-examples/reports +## 0.1.29 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/dashboard@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.28 ### Patch Changes diff --git a/plugins/fusion-plugin-reports/package.json b/plugins/fusion-plugin-reports/package.json index 8b7e76cea7..b5699cf68d 100644 --- a/plugins/fusion-plugin-reports/package.json +++ b/plugins/fusion-plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/reports", - "version": "0.1.28", + "version": "0.1.29", "type": "module", "description": "Reports plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-roadmap/CHANGELOG.md b/plugins/fusion-plugin-roadmap/CHANGELOG.md index e5a287646f..85d00fda9f 100644 --- a/plugins/fusion-plugin-roadmap/CHANGELOG.md +++ b/plugins/fusion-plugin-roadmap/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/roadmap +## 0.1.29 + +### Patch Changes + +- @fusion/core@0.47.0 +- @fusion/plugin-sdk@0.47.0 + ## 0.1.28 ### Patch Changes diff --git a/plugins/fusion-plugin-roadmap/package.json b/plugins/fusion-plugin-roadmap/package.json index c575b8656d..3142960367 100644 --- a/plugins/fusion-plugin-roadmap/package.json +++ b/plugins/fusion-plugin-roadmap/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/roadmap", - "version": "0.1.28", + "version": "0.1.29", "type": "module", "description": "Roadmap plugin package for Fusion", "private": true, diff --git a/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md b/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md index 7379daed09..d090b69fa0 100644 --- a/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md +++ b/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/whatsapp-chat +## 0.1.29 + +### Patch Changes + +- @fusion/plugin-sdk@0.47.0 + ## 0.1.28 ### Patch Changes diff --git a/plugins/fusion-plugin-whatsapp-chat/package.json b/plugins/fusion-plugin-whatsapp-chat/package.json index b83695e212..8255ee221c 100644 --- a/plugins/fusion-plugin-whatsapp-chat/package.json +++ b/plugins/fusion-plugin-whatsapp-chat/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/whatsapp-chat", - "version": "0.1.28", + "version": "0.1.29", "type": "module", "description": "WhatsApp Web (Baileys) chat bridge for Fusion agents", "keywords": [ diff --git a/scripts/__tests__/changeset-schema.test.mjs b/scripts/__tests__/changeset-schema.test.mjs new file mode 100644 index 0000000000..e28c814173 --- /dev/null +++ b/scripts/__tests__/changeset-schema.test.mjs @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + parseChangesetBody, + parseChangesetFile, + validateChangeset, + MAX_SUMMARY_LENGTH, + CATEGORIES, +} from "../lib/changeset-schema.mjs"; + +// --- parseChangesetBody --- + +test("parses well-formed structured changeset with all three fields", () => { + const body = "summary: Add LOC backfill control.\ncategory: feature\ndev: Uses fn_backfill_loc tool."; + const result = parseChangesetBody(body); + assert.deepEqual(result, { + summary: "Add LOC backfill control.", + category: "feature", + dev: "Uses fn_backfill_loc tool.", + legacy: false, + }); +}); + +test("parses multi-line dev field", () => { + const body = "summary: Fix mobile keyboard.\ncategory: fix\ndev: Line one.\nLine two.\nLine three."; + const result = parseChangesetBody(body); + assert.equal(result.dev, "Line one.\nLine two.\nLine three."); + assert.equal(result.legacy, false); +}); + +test("parses structured changeset without dev field", () => { + const body = "summary: Fix crash on startup.\ncategory: fix"; + const result = parseChangesetBody(body); + assert.equal(result.summary, "Fix crash on startup."); + assert.equal(result.category, "fix"); + assert.equal(result.dev, undefined); + assert.equal(result.legacy, false); +}); + +test("treats freeform paragraph as legacy", () => { + const body = "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."; + const result = parseChangesetBody(body); + assert.equal(result.legacy, true); + assert.equal(result.category, "internal"); + assert.ok(result.summary.length > 0); + assert.equal(result.summary, body.trim()); +}); + +test("treats multi-line freeform paragraph as legacy with first line as summary", () => { + const body = "First line of the changeset.\nSecond line with more detail.\nThird line."; + const result = parseChangesetBody(body); + assert.equal(result.legacy, true); + assert.equal(result.summary, "First line of the changeset."); +}); + +test("returns null for empty body", () => { + assert.equal(parseChangesetBody(""), null); + assert.equal(parseChangesetBody(" \n \n "), null); +}); + +test("returns null for undefined body", () => { + assert.equal(parseChangesetBody(undefined), null); +}); + +test("handles fields in any order", () => { + const body = "category: fix\nsummary: Fix the bug."; + const result = parseChangesetBody(body); + assert.equal(result.category, "fix"); + assert.equal(result.summary, "Fix the bug."); + assert.equal(result.legacy, false); +}); + +test("handles summary at exactly max length boundary", () => { + const summary = "a".repeat(MAX_SUMMARY_LENGTH); + const body = `summary: ${summary}\ncategory: feature`; + const result = parseChangesetBody(body); + assert.equal(result.summary.length, MAX_SUMMARY_LENGTH); + const validation = validateChangeset(result); + assert.equal(validation.valid, true); +}); + +test("handles summary over max length", () => { + const summary = "a".repeat(MAX_SUMMARY_LENGTH + 1); + const body = `summary: ${summary}\ncategory: feature`; + const result = parseChangesetBody(body); + const validation = validateChangeset(result); + assert.equal(validation.valid, false); + assert.ok(validation.errors[0].includes("exceeds max length")); +}); + +// --- validateChangeset --- + +test("validates clean structured changeset", () => { + const parsed = { summary: "Good summary.", category: "feature", legacy: false }; + const result = validateChangeset(parsed); + assert.equal(result.valid, true); + assert.equal(result.errors.length, 0); +}); + +test("flags missing category on structured changeset", () => { + const parsed = { summary: "Good summary.", category: "", legacy: false }; + const result = validateChangeset(parsed); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.includes("missing required `category`"))); +}); + +test("flags invalid category value", () => { + const parsed = { summary: "Good summary.", category: "enhancement", legacy: false }; + const result = validateChangeset(parsed); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.includes("invalid") && e.includes("enhancement"))); + assert.ok(result.errors.some((e) => e.includes(CATEGORIES.join(", ")))); +}); + +test("skips validation for legacy changesets", () => { + const parsed = { summary: "x".repeat(500), category: "internal", legacy: true }; + const result = validateChangeset(parsed); + assert.equal(result.valid, true); +}); + +test("flags missing summary on structured changeset", () => { + const parsed = { summary: "", category: "fix", legacy: false }; + const result = validateChangeset(parsed); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.includes("missing required `summary`"))); +}); + +// --- parseChangesetFile --- + +test("parses full changeset file with frontmatter", () => { + const raw = "---\n\"@runfusion/fusion\": minor\n---\nsummary: New feature.\ncategory: feature\ndev: Implementation detail."; + const result = parseChangesetFile(raw); + assert.equal(result.frontmatter, '"@runfusion/fusion": minor'); + assert.ok(result.body.includes("summary:")); + assert.equal(result.parsed.summary, "New feature."); + assert.equal(result.parsed.category, "feature"); + assert.equal(result.parsed.dev, "Implementation detail."); +}); + +test("parses legacy changeset file with frontmatter", () => { + const raw = "---\n\"@runfusion/fusion\": patch\n---\nFix a bug in the parser that caused crashes on startup."; + const result = parseChangesetFile(raw); + assert.equal(result.parsed.legacy, true); + assert.equal(result.parsed.summary, "Fix a bug in the parser that caused crashes on startup."); +}); + +test("handles file without frontmatter gracefully", () => { + const raw = "summary: No frontmatter.\ncategory: feature"; + const result = parseChangesetFile(raw); + assert.equal(result.frontmatter, ""); + assert.equal(result.parsed.summary, "No frontmatter."); +}); diff --git a/scripts/__tests__/check-changeset-format.test.mjs b/scripts/__tests__/check-changeset-format.test.mjs new file mode 100644 index 0000000000..0f62c9bc0b --- /dev/null +++ b/scripts/__tests__/check-changeset-format.test.mjs @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { scanChangesets } from "../check-changeset-format.mjs"; +import { + mkdirSync, + writeFileSync, + rmSync, + existsSync, +} from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +function createTempChangesetDir(changesets) { + const dir = mkdtempSync(join(tmpdir(), "changeset-lint-test-")); + for (const [name, content] of Object.entries(changesets)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +const validStructured = `--- +"@runfusion/fusion": minor +--- + +summary: Add a new dashboard widget. +category: feature +dev: Uses the widget framework. +`; + +const validMinimal = `--- +"@runfusion/fusion": patch +--- + +summary: Fix a typo. +category: fix +`; + +const legacyFreeform = `--- +"@runfusion/fusion": patch +--- + +Fix ntfy test notifications to honor unsaved Settings form config so users can test before saving. +`; + +const missingCategory = `--- +"@runfusion/fusion": minor +--- + +summary: Add something. +`; + +const invalidCategory = `--- +"@runfusion/fusion": minor +--- + +summary: Add something. +category: enhancement +`; + +const overLengthSummary = `--- +"@runfusion/fusion": minor +--- + +summary: ${"a".repeat(121)} +category: feature +`; + +const emptyBody = `--- +"@runfusion/fusion": minor +--- + +`; + +test("valid structured changeset passes with no errors", () => { + const dir = createTempChangesetDir({ "valid.md": validStructured }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("legacy freeform changeset passes with warning in transition mode", () => { + const dir = createTempChangesetDir({ "legacy.md": legacyFreeform }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes("legacy")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("missing category fails with error", () => { + const dir = createTempChangesetDir({ "no-cat.md": missingCategory }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("missing required `category`")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("invalid category value fails with error", () => { + const dir = createTempChangesetDir({ "bad-cat.md": invalidCategory }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("invalid")); + assert.ok(errors[0].includes("enhancement")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("over-length summary fails with error", () => { + const dir = createTempChangesetDir({ "long.md": overLengthSummary }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("exceeds max length")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("empty body fails with error", () => { + const dir = createTempChangesetDir({ "empty.md": emptyBody }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("empty or unparseable")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("empty directory passes with no errors or warnings", () => { + const dir = mkdtempSync(join(tmpdir(), "changeset-lint-test-")); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("nonexistent directory passes with no errors or warnings", () => { + const { errors, warnings } = scanChangesets("/nonexistent/path"); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); +}); + +test("mixed valid and invalid changesets report all errors", () => { + const dir = createTempChangesetDir({ + "valid.md": validStructured, + "no-cat.md": missingCategory, + "bad-cat.md": invalidCategory, + "legacy.md": legacyFreeform, + }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 2); + assert.equal(warnings.length, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("valid minimal structured changeset (no dev) passes", () => { + const dir = createTempChangesetDir({ "minimal.md": validMinimal }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/__tests__/distill-release-notes.test.mjs b/scripts/__tests__/distill-release-notes.test.mjs new file mode 100644 index 0000000000..a646332237 --- /dev/null +++ b/scripts/__tests__/distill-release-notes.test.mjs @@ -0,0 +1,175 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + distillDeterministic, + buildDistillationPrompt, + DISTILLATION_SYSTEM_PROMPT, +} from "../lib/distill-release-notes.mjs"; + +// --- distillDeterministic --- + +test("groups entries by category in display order", () => { + const entries = [ + { summary: "Fix mobile keyboard popup.", category: "fix", legacy: false }, + { summary: "Add LOC backfill control.", category: "feature", legacy: false }, + { summary: "Fix stale agent assignments.", category: "fix", legacy: false }, + { summary: "Remove deprecated API.", category: "breaking", legacy: false }, + ]; + const { notes, source } = distillDeterministic(entries, "1.0.0"); + assert.equal(source, "deterministic"); + + // Feature section comes first. + const featureIdx = notes.indexOf("### New"); + const fixIdx = notes.indexOf("### Fixed"); + const breakingIdx = notes.indexOf("### Breaking"); + assert.ok(featureIdx > -1); + assert.ok(featureIdx < fixIdx); + assert.ok(fixIdx < breakingIdx); +}); + +test("omits empty categories", () => { + const entries = [ + { summary: "Add feature X.", category: "feature", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + assert.match(notes, /### New/); + assert.doesNotMatch(notes, /### Fixed/); + assert.doesNotMatch(notes, /### Breaking/); + assert.doesNotMatch(notes, /### Security/); + assert.doesNotMatch(notes, /### Performance/); +}); + +test("groups multiple entries in same category", () => { + const entries = [ + { summary: "Fix bug A.", category: "fix", legacy: false }, + { summary: "Fix bug B.", category: "fix", legacy: false }, + { summary: "Fix bug C.", category: "fix", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + assert.match(notes, /### Fixed/); + assert.match(notes, /Fix bug A\./); + assert.match(notes, /Fix bug B\./); + assert.match(notes, /Fix bug C\./); + // All three should be in the same section. + const fixedSection = notes.split("### Fixed")[1]; + assert.ok(fixedSection.includes("Fix bug A.")); + assert.ok(fixedSection.includes("Fix bug B.")); + assert.ok(fixedSection.includes("Fix bug C.")); +}); + +test("handles empty entries array", () => { + const { notes, source } = distillDeterministic([], "1.0.0"); + assert.equal(source, "deterministic"); + assert.match(notes, /No changes in v1\.0\.0/); +}); + +test("handles null/undefined entries", () => { + const { notes } = distillDeterministic(null, "1.0.0"); + assert.match(notes, /No changes/); +}); + +test("single entry produces well-formed notes", () => { + const entries = [ + { summary: "Add cool feature.", category: "feature", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "2.0.0"); + assert.match(notes, /^### New\n\n- Add cool feature\.$/); +}); + +test("includes internal category when entries exist", () => { + const entries = [ + { summary: "Refactor internal modules.", category: "internal", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + assert.match(notes, /### Internal/); +}); + +test("handles legacy entries with category defaulting to internal", () => { + const entries = [ + { summary: "Fix a bug in the parser.", category: "internal", legacy: true }, + { summary: "Add new dashboard widget.", category: "feature", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + assert.match(notes, /### New/); + assert.match(notes, /### Internal/); + // Feature comes before internal in display order. + assert.ok(notes.indexOf("### New") < notes.indexOf("### Internal")); +}); + +test("unknown category falls back to internal", () => { + const entries = [ + { summary: "Mystery change.", category: "unknown_cat", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + assert.match(notes, /### Internal/); + assert.match(notes, /Mystery change\./); +}); + +test("preserves entry order within categories", () => { + const entries = [ + { summary: "First fix.", category: "fix", legacy: false }, + { summary: "Second fix.", category: "fix", legacy: false }, + { summary: "A feature.", category: "feature", legacy: false }, + { summary: "Third fix.", category: "fix", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + const fixedSection = notes.split("### Fixed")[1]; + const firstIdx = fixedSection.indexOf("First fix."); + const secondIdx = fixedSection.indexOf("Second fix."); + const thirdIdx = fixedSection.indexOf("Third fix."); + assert.ok(firstIdx < secondIdx); + assert.ok(secondIdx < thirdIdx); +}); + +test("multiple categories render in correct order", () => { + const entries = [ + { summary: "Security patch.", category: "security", legacy: false }, + { summary: "New feature.", category: "feature", legacy: false }, + { summary: "Performance boost.", category: "performance", legacy: false }, + { summary: "Breaking change.", category: "breaking", legacy: false }, + { summary: "Bug fix.", category: "fix", legacy: false }, + { summary: "Internal cleanup.", category: "internal", legacy: false }, + ]; + const { notes } = distillDeterministic(entries, "1.0.0"); + const order = ["### New", "### Fixed", "### Breaking", "### Security", "### Performance", "### Internal"] + .map((h) => notes.indexOf(h)); + // Each should be found and in ascending order. + for (let i = 0; i < order.length - 1; i++) { + assert.ok(order[i] > -1, `heading ${i} not found`); + assert.ok(order[i] < order[i + 1], `headings ${i} and ${i + 1} out of order`); + } +}); + +// --- buildDistillationPrompt --- + +test("builds prompt with all entries", () => { + const entries = [ + { summary: "Add feature.", category: "feature", legacy: false, dev: "Uses tool X." }, + { summary: "Fix bug.", category: "fix", legacy: false }, + ]; + const prompt = buildDistillationPrompt(entries); + assert.match(prompt, /\[1\]/); + assert.match(prompt, /\[2\]/); + assert.match(prompt, /category: feature/); + assert.match(prompt, /summary: Add feature\./); + assert.match(prompt, /dev: Uses tool X\./); +}); + +test("builds prompt without dev for entries lacking it", () => { + const entries = [ + { summary: "Fix bug.", category: "fix", legacy: false }, + ]; + const prompt = buildDistillationPrompt(entries); + assert.doesNotMatch(prompt, /dev:/); +}); + +// --- DISTILLATION_SYSTEM_PROMPT --- + +test("system prompt contains key instructions", () => { + assert.match(DISTILLATION_SYSTEM_PROMPT, /release notes/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /operator/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/); + assert.match(DISTILLATION_SYSTEM_PROMPT, /### Fixed/); + assert.match(DISTILLATION_SYSTEM_PROMPT, /omit empty sections/i); +}); diff --git a/scripts/__tests__/extract-version-notes.test.mjs b/scripts/__tests__/extract-version-notes.test.mjs index 8ca5bca0b8..e3d8b218a3 100644 --- a/scripts/__tests__/extract-version-notes.test.mjs +++ b/scripts/__tests__/extract-version-notes.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { extractVersionNotes } from "../lib/extract-version-notes.mjs"; +import { extractVersionNotes, replaceVersionSection } from "../lib/extract-version-notes.mjs"; const changelog = `# Fusion changelog @@ -69,3 +69,40 @@ test("does not bleed into adjacent version sections", () => { assert.doesNotMatch(notes, /Initial release\./); assert.doesNotMatch(notes, /Added release integration\./); }); + +// --- replaceVersionSection --- + +test("replaces version section with distilled notes", () => { + const result = replaceVersionSection(changelog, "1.2.0", "### New\n\n- Distilled entry."); + assert.match(result, /### New/); + assert.match(result, /Distilled entry\./); + // Other versions preserved. + assert.match(result, /Fixed parser bug\./); + assert.match(result, /Initial release\./); + // Old content removed. + assert.doesNotMatch(result, /Added release integration\./); +}); + +test("returns original content when version not found", () => { + const result = replaceVersionSection(changelog, "9.9.9", "### New\n\n- Entry."); + assert.equal(result, changelog); +}); + +test("preserves version heading", () => { + const result = replaceVersionSection(changelog, "1.1.0", "### Fixed\n\n- New fix."); + assert.match(result, /## 1\.1\.0/); + assert.match(result, /## 1\.2\.0/); + assert.match(result, /## 1\.0\.0/); +}); + +test("replaces last version section correctly", () => { + const result = replaceVersionSection(changelog, "1.0.0", "### Fixed\n\n- Replaced."); + assert.match(result, /Replaced\./); + // Versions above 1.0.0 are preserved. + assert.match(result, /## 1\.1\.0/); +}); + +test("handles null content gracefully", () => { + const result = replaceVersionSection(null, "1.0.0", "Body."); + assert.equal(result, null); +}); diff --git a/scripts/check-changeset-format.mjs b/scripts/check-changeset-format.mjs new file mode 100644 index 0000000000..8b1a1c8a2b --- /dev/null +++ b/scripts/check-changeset-format.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/* + * FNXC:Changelog 2026-06-24-15:00: + * Changeset format linter. Validates that all .changeset/*.md files follow + * the structured schema (summary, category, dev labeled fields). During the + * transition period, legacy freeform changesets produce warnings (exit 0). + * Structurally invalid changesets (partial fields, bad category, over-length + * summary) always produce errors (exit 1). Use --strict to fail on legacy + * changesets. + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseChangesetFile, validateChangeset, CATEGORIES } from "./lib/changeset-schema.mjs"; + +const STRICT = process.argv.includes("--strict"); +const CHANGESET_DIR = ".changeset"; + +/** + * Scan all .changeset/*.md files (excluding README.md) and return findings. + * @returns {{errors: string[], warnings: string[]}} + */ +export function scanChangesets(dir = CHANGESET_DIR) { + const errors = []; + const warnings = []; + + if (!existsSync(dir)) { + return { errors, warnings }; + } + + const files = readdirSync(dir).filter( + (f) => f.endsWith(".md") && f !== "README.md", + ); + + for (const file of files) { + const filePath = join(dir, file); + const raw = readFileSync(filePath, "utf8"); + const { parsed } = parseChangesetFile(raw); + + if (!parsed) { + errors.push(`${file}: empty or unparseable body`); + continue; + } + + if (parsed.legacy && !STRICT) { + warnings.push( + `${file}: legacy freeform format (no labeled fields). Expected: summary, category, dev.`, + ); + continue; + } + + if (parsed.legacy && STRICT) { + errors.push( + `${file}: legacy freeform format not allowed in --strict mode. Migrate to labeled fields (summary, category, dev).`, + ); + continue; + } + + const validation = validateChangeset(parsed); + if (!validation.valid) { + for (const err of validation.errors) { + errors.push(`${file}: ${err}`); + } + } + } + + return { errors, warnings }; +} + +export function main() { + const { errors, warnings } = scanChangesets(); + + for (const w of warnings) { + console.warn(` WARN ${w}`); + } + + if (errors.length > 0) { + for (const e of errors) { + console.error(` FAIL ${e}`); + } + console.error( + `\nChangeset format check failed. Valid categories: ${CATEGORIES.join(", ")}.`, + ); + console.error( + "Expected body format:\n summary: One-line user-facing description.\n category: \n dev: Optional developer detail.", + ); + return 1; + } + + if (warnings.length > 0) { + console.warn( + `\nChangeset format check passed with ${warnings.length} legacy warning(s).`, + ); + } + + return 0; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = main(); +} diff --git a/scripts/ci-distill-release-notes.mjs b/scripts/ci-distill-release-notes.mjs new file mode 100644 index 0000000000..f0019836af --- /dev/null +++ b/scripts/ci-distill-release-notes.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/* + * FNXC:Changelog 2026-06-24-17:00: + * CI distillation entrypoint. Runs AFTER `changeset version` has consumed + * the changesets and produced per-package CHANGELOGs, but BEFORE the version + * PR commit. Reads the root CHANGELOG.md, distills the current version's + * section from the structured entries, and writes the distilled notes back. + * + * When no model is configured (no model secret in CI), it falls back to the + * deterministic distillation — a model outage never blocks a release. + * + * Usage: + * node scripts/ci-distill-release-notes.mjs --version + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { parseChangesetBody } from "./lib/changeset-schema.mjs"; +import { distillDeterministic } from "./lib/distill-release-notes.mjs"; +import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs"; + +const args = process.argv.slice(2); +const versionIdx = args.indexOf("--version"); +const version = versionIdx > -1 ? args[versionIdx + 1] : null; + +if (!version) { + console.error("Usage: node scripts/ci-distill-release-notes.mjs --version "); + process.exit(1); +} + +const CHANGELOG_PATH = "CHANGELOG.md"; + +if (!existsSync(CHANGELOG_PATH)) { + console.log(`[ci-distill] No CHANGELOG.md found; skipping distillation.`); + process.exit(0); +} + +/** + * Extract entries for the current version from the CLI package CHANGELOG. + * The changesets have already been consumed by `changeset version`, so we + * read the per-package CHANGELOG to find the version's structured entries. + */ +function extractVersionEntries(ver) { + const cliChangelogPath = join("packages", "cli", "CHANGELOG.md"); + const entries = []; + + if (!existsSync(cliChangelogPath)) { + return entries; + } + + const raw = readFileSync(cliChangelogPath, "utf8"); + const notes = extractVersionNotes(raw, ver); + + for (const line of notes.split(/\r?\n/)) { + const bulletMatch = line.match(/^-\s+(.*)/); + if (!bulletMatch) continue; + + const body = bulletMatch[1].trim(); + + if (body.includes("summary:") || body.includes("category:")) { + const parsed = parseChangesetBody(body); + if (parsed) entries.push(parsed); + } else { + entries.push({ + summary: body.split("\n")[0].trim(), + category: "internal", + legacy: true, + }); + } + } + + return entries; +} + +const entries = extractVersionEntries(version); + +if (entries.length === 0) { + console.log(`[ci-distill] No structured entries found for v${version}; skipping.`); + process.exit(0); +} + +const { notes: distilledNotes, source } = distillDeterministic(entries, version); +const changelogContent = readFileSync(CHANGELOG_PATH, "utf8"); +const updated = replaceVersionSection(changelogContent, version, distilledNotes); + +if (updated !== changelogContent) { + writeFileSync(CHANGELOG_PATH, updated); + console.log(`[ci-distill] Root CHANGELOG.md updated with distilled notes (source: ${source}).`); +} else { + console.log(`[ci-distill] Version section not found in CHANGELOG.md; skipping.`); +} diff --git a/scripts/lib/changeset-schema.mjs b/scripts/lib/changeset-schema.mjs new file mode 100644 index 0000000000..50cadf3d37 --- /dev/null +++ b/scripts/lib/changeset-schema.mjs @@ -0,0 +1,177 @@ +/* + * FNXC:Changelog 2026-06-24-14:30: + * Structured changeset body schema. Each changeset body uses labeled fields + * (summary, category, dev) instead of freeform paragraphs. The `summary` is + * the only content that flows into end-user release notes by default. The + * `dev` field is preserved in per-package CHANGELOGs but excluded from + * distilled release notes. Legacy freeform changesets are detected and + * flagged so the linter can warn during the transition period. + */ + +/** Maximum character length for the `summary` field. */ +export const MAX_SUMMARY_LENGTH = 120; + +/** Valid category values, in display order for release notes grouping. */ +export const CATEGORIES = [ + "feature", + "fix", + "breaking", + "security", + "performance", + "internal", +]; + +/** Human-readable headings for each category in release notes. */ +export const CATEGORY_HEADINGS = { + feature: "New", + fix: "Fixed", + breaking: "Breaking", + security: "Security", + performance: "Performance", + internal: "Internal", +}; + +/** + * Parse labeled fields from a changeset body. + * + * The body format is: + * summary: One-line user-facing description. + * category: feature + * dev: Optional developer detail (can span multiple lines). + * + * If no labeled fields are found, the entire body is treated as legacy + * content: the first non-empty line becomes `summary`, and `category` + * defaults to `internal` with `legacy: true`. + * + * @param {string} body - The changeset body (after frontmatter). + * @returns {{summary: string, category: string, dev?: string, legacy: boolean} | null} + */ +export function parseChangesetBody(body) { + if (!body || !body.trim()) { + return null; + } + + const fields = extractLabeledFields(body); + + if (fields.summary !== undefined || fields.category !== undefined || fields.dev !== undefined) { + return { + summary: (fields.summary ?? "").trim(), + category: fields.category ?? "", + dev: fields.dev?.trim() || undefined, + legacy: false, + }; + } + + // Legacy freeform: first non-empty line is the summary. + const firstLine = body + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l.length > 0); + + if (!firstLine) { + return null; + } + + return { + summary: firstLine, + category: "internal", + legacy: true, + }; +} + +/** + * Extract `key: value` labeled fields from the changeset body. + * `dev` allows multi-line content until the next labeled field or EOF. + * Returns an empty object if no labeled fields are found. + */ +function extractLabeledFields(body) { + const knownLabels = ["summary", "category", "dev"]; + const lines = body.split(/\r?\n/); + const fields = {}; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const match = line.match(/^(\w+):\s*(.*)$/); + + if (match && knownLabels.includes(match[1])) { + const label = match[1]; + const value = match[2]; + + if (label === "dev") { + // Multi-line: collect subsequent non-labeled lines. + const devLines = [value]; + i += 1; + while (i < lines.length) { + const nextLine = lines[i]; + const nextMatch = nextLine.match(/^(\w+):\s*(.*)$/); + if (nextMatch && knownLabels.includes(nextMatch[1])) { + break; + } + devLines.push(nextLine); + i += 1; + } + fields.dev = devLines.join("\n").trim(); + } else { + fields[label] = value.trim(); + i += 1; + } + } else { + i += 1; + } + } + + return fields; +} + +/** + * Validate a parsed changeset against the schema. + * Returns errors for missing required fields, invalid categories, + * or over-length summaries. + * + * @param {{summary: string, category: string, dev?: string, legacy: boolean}} parsed + * @returns {{valid: boolean, errors: string[]}} + */ +export function validateChangeset(parsed) { + const errors = []; + + if (parsed.legacy) { + return { valid: true, errors: [] }; + } + + if (!parsed.summary) { + errors.push("missing required `summary` field"); + } else if (parsed.summary.length > MAX_SUMMARY_LENGTH) { + errors.push( + `\`summary\` exceeds max length (${parsed.summary.length}/${MAX_SUMMARY_LENGTH} chars)`, + ); + } + + if (!parsed.category) { + errors.push("missing required `category` field"); + } else if (!CATEGORIES.includes(parsed.category)) { + errors.push( + `invalid \`category\` value "${parsed.category}"; valid values: ${CATEGORIES.join(", ")}`, + ); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Parse a full changeset markdown file (frontmatter + body). + * Splits on the `---` delimited frontmatter and parses the body. + * + * @param {string} raw - Full file contents. + * @returns {{frontmatter: string, body: string, parsed: object|null}} + */ +export function parseChangesetFile(raw) { + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!fmMatch) { + return { frontmatter: "", body: raw, parsed: parseChangesetBody(raw) }; + } + + const frontmatter = fmMatch[1]; + const body = fmMatch[2]; + return { frontmatter, body, parsed: parseChangesetBody(body) }; +} diff --git a/scripts/lib/distill-release-notes.mjs b/scripts/lib/distill-release-notes.mjs new file mode 100644 index 0000000000..3c42d4274b --- /dev/null +++ b/scripts/lib/distill-release-notes.mjs @@ -0,0 +1,112 @@ +/* + * FNXC:Changelog 2026-06-24-15:30: + * Release-notes distillation module. Transforms parsed changeset entries + * into grouped, end-user-facing release notes. The deterministic fallback + * builds a category-grouped bullet list directly from the structured + * `summary` fields — no model call. When a model is available, the prompt + * and system prompt defined here can be used to produce curated, polished + * notes via `createFnAgent`. + * + * Audience is Fusion operators: behavior, fixes, what changed — minimal + * internals. The `dev` field is preserved in per-package CHANGELOGs but + * excluded from distilled release notes by default. + */ + +import { CATEGORIES, CATEGORY_HEADINGS } from "./changeset-schema.mjs"; + +/** + * System prompt for AI distillation via `createFnAgent`. + * Instructs the model to produce grouped markdown release notes for a + * Fusion operator audience, using only the `summary` fields as input. + */ +export const DISTILLATION_SYSTEM_PROMPT = [ + "You are a release-notes writer for Fusion, a model-agnostic AI agent orchestration product.", + "Your audience is Fusion operators — developers using the product, not its internals.", + "Produce clean, grouped markdown release notes from the provided changeset entries.", + "Group under these headings (omit empty sections):", + " ### New (features)", + " ### Fixed (bug fixes)", + " ### Breaking (breaking changes)", + " ### Security (security fixes)", + " ### Performance (performance improvements)", + " ### Internal (internal-only changes)", + "Rules:", + "- Use the `summary` text verbatim or lightly edited for clarity and grouping.", + "- Do NOT include internal class names, file paths, or implementation detail.", + "- Do NOT include the `dev` field content unless it is user-relevant migration guidance.", + "- Write one bullet per entry, prefixed with `- `.", + "- Omit empty sections entirely.", + "- Do NOT add a title or version heading — only the grouped sections.", + "- Respond with markdown only, no preamble or explanation.", +].join("\n"); + +/** + * Build the user-facing prompt for AI distillation. + * Lists each entry as `[N] category: X / summary: Y / dev: Z`. + * + * @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries + * @returns {string} + */ +export function buildDistillationPrompt(entries) { + const lines = ["Produce release notes from these changeset entries:\n"]; + entries.forEach((entry, i) => { + const num = i + 1; + lines.push(`[${num}]`); + lines.push(` category: ${entry.category}`); + lines.push(` summary: ${entry.summary}`); + if (entry.dev) { + lines.push(` dev: ${entry.dev}`); + } + lines.push(""); + }); + return lines.join("\n"); +} + +/** + * Deterministic fallback: build category-grouped release notes directly + * from the structured `summary` fields — no model call. + * + * Used when: + * - The model call fails, times out, or returns unparseable output + * - No model is configured (CI without model secret) + * - As a pre-model preview in dry-runs + * + * @param {Array<{summary: string, category: string, legacy?: boolean}>} entries + * @param {string} version - Target version string (e.g. "0.47.0") + * @returns {{notes: string, source: "deterministic"}} + */ +export function distillDeterministic(entries, version) { + if (!entries || entries.length === 0) { + return { + notes: `No changes in v${version}.`, + source: "deterministic", + }; + } + + // Group entries by category, preserving entry order within each group. + const groups = new Map(); + for (const cat of CATEGORIES) { + groups.set(cat, []); + } + + for (const entry of entries) { + const cat = groups.has(entry.category) ? entry.category : "internal"; + groups.get(cat).push(entry.summary); + } + + // Build sections in display order, omitting empty categories. + const sections = []; + for (const cat of CATEGORIES) { + const summaries = groups.get(cat); + if (summaries.length === 0) continue; + + const heading = CATEGORY_HEADINGS[cat]; + const bullets = summaries.map((s) => `- ${s}`).join("\n"); + sections.push(`### ${heading}\n\n${bullets}`); + } + + return { + notes: sections.join("\n\n"), + source: "deterministic", + }; +} diff --git a/scripts/lib/extract-version-notes.mjs b/scripts/lib/extract-version-notes.mjs index eae98fdfaa..3e3bf12d9b 100644 --- a/scripts/lib/extract-version-notes.mjs +++ b/scripts/lib/extract-version-notes.mjs @@ -30,3 +30,43 @@ export function extractVersionNotes(content, version) { const body = lines.slice(startIndex + 1, endIndex).join("\n").trim(); return body || fallback; } + +/** + * Replace the changelog section for a specific version with new content. + * + * FNXC:Changelog 2026-06-24-16:00: + * After syncRootChangelog aggregates per-package CHANGELOGs into the root + * CHANGELOG, the distilled end-user notes replace the raw per-package + * aggregate for the current version. Historical versions are preserved. + * + * @param {string} content - Full CHANGELOG.md content + * @param {string} version - Bare version string (e.g. "0.47.0") + * @param {string} newBody - New markdown body for the version section + * @returns {string} Updated CHANGELOG.md content, or original if version not found + */ +export function replaceVersionSection(content, version, newBody) { + if (!content || !version) { + return content; + } + + const lines = content.split(/\r?\n/); + const header = `## ${version}`; + const startIndex = lines.findIndex((line) => line.trim() === header); + + if (startIndex === -1) { + return content; + } + + let endIndex = lines.length; + for (let i = startIndex + 1; i < lines.length; i += 1) { + if (lines[i].startsWith("## ")) { + endIndex = i; + break; + } + } + + const before = lines.slice(0, startIndex + 1); + const after = lines.slice(endIndex); + + return [...before, "", newBody.trim(), "", ...after].join("\n").replace(/\n{3,}/g, "\n\n"); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index f281ef9aeb..e556f26126 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -28,7 +28,9 @@ import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { evaluateReleaseAuthorization } from "./lib/release-authorization-gate.mjs"; -import { extractVersionNotes } from "./lib/extract-version-notes.mjs"; +import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs"; +import { parseChangesetFile } from "./lib/changeset-schema.mjs"; +import { distillDeterministic } from "./lib/distill-release-notes.mjs"; import { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs"; const args = new Set(process.argv.slice(2)); @@ -564,6 +566,17 @@ if (!(await confirm(`Proceed with release v${chosenVersion} (build, publish, tag // --- Version bump --------------------------------------------------------- +/* + * FNXC:Changelog 2026-06-24-16:15: + * Capture and parse structured changeset entries BEFORE `changeset version` + * runs — versioning consumes and deletes the .changeset/*.md files. + * The captured entries feed the post-version distillation step. + */ +const capturedEntries = changesetSummaries.map(({ file }) => { + const raw = readFileSync(join(".changeset", file), "utf8"); + return parseChangesetFile(raw).parsed; +}).filter(Boolean); + info("Applying changesets (version bump + CHANGELOG)…"); run("pnpm release:version"); @@ -588,6 +601,23 @@ info("Syncing root CHANGELOG.md from packages/cli/CHANGELOG.md…"); syncRootChangelog(); ok("Root CHANGELOG.md updated."); +/* + * FNXC:Changelog 2026-06-24-16:30: + * Distill end-user-facing release notes from the captured changeset entries + * and replace the raw per-package aggregate in the root CHANGELOG for this + * version. Historical version sections are preserved untouched. + */ +info("Distilling release notes…"); +const { notes: distilledNotes, source: distillSource } = distillDeterministic(capturedEntries, version); +const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8"); +const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes); +if (changelogAfterDistill !== changelogBeforeDistill) { + writeFileSync("CHANGELOG.md", changelogAfterDistill); + ok(`Root CHANGELOG.md updated with distilled notes (source: ${distillSource}).`); +} else { + warn(`Could not locate version section in CHANGELOG.md for distillation; leaving raw aggregate.`); +} + // --- Build ---------------------------------------------------------------- info("Building all packages…"); diff --git a/scripts/run-ci-distill.mjs b/scripts/run-ci-distill.mjs new file mode 100644 index 0000000000..181d0bc80e --- /dev/null +++ b/scripts/run-ci-distill.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * FNXC:Changelog 2026-06-24-17:30: + * Wrapper that runs the CI distillation step after `changeset version` and + * `sync-workspace-version` have bumped versions. Auto-detects the new version + * from packages/cli/package.json. Chained into the `release:version` script + * so both local and CI versioning flows get distilled notes. + * + * Degrades gracefully: if no CHANGELOG.md exists or the version section + * cannot be found, it logs and exits 0 (does not block the release). + */ + +import { readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; + +const cliPkg = JSON.parse(readFileSync("packages/cli/package.json", "utf8")); +const version = cliPkg.version; + +if (!version) { + console.log("[distill] No version found in packages/cli/package.json; skipping."); + process.exit(0); +} + +try { + execSync(`node scripts/ci-distill-release-notes.mjs --version "${version}"`, { + stdio: "inherit", + }); +} catch { + // Distillation failure should never block a release. + console.log("[distill] Distillation failed; release continues with raw CHANGELOG."); + process.exit(0); +}