Merge remote-tracking branch 'origin/main' into fix/test-timeout-failures
# Conflicts: # scripts/test-changed.mjs
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Quick Entry no longer auto-focuses when the board or dashboard becomes visible.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Skip custom workflow pre-merge prompt, script, and gate nodes when a task runs in fast execution mode.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Resolve the standard triage planning prompt from the selected workflow IR planning node instead of the removed engine-side `TRIAGE_SYSTEM_PROMPT` duplicate. The built-in `default-triage` prompt is now the canonical policy source for `builtin:coding`; where the old copies disagreed, the surviving canonical subtask-split threshold is `MORE THAN 7 implementation steps` (with the matching `MORE THAN 3 different packages/modules` guidance). Fast-mode triage continues to use `FAST_TRIAGE_SYSTEM_PROMPT` unchanged.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30.
|
||||
|
||||
These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Resolve the built-in reviewer base prompt from the workflow IR `review` node instead of an engine-local `REVIEWER_SYSTEM_PROMPT` duplicate. The canonical reviewer policy now lives in the `default-reviewer` agent prompt / built-in workflow seam, with reconciled superset content that preserves the FN-5928/FN-6229 surface-enumeration and symptom-verification gates, undersplit-task guidance, test-quality rules, worktree-boundary review, and the embedded port-4040 safety rule.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Fast-mode triage is now expressed as workflow-declared policy: the lean prompt lives in the built-in `default-triage-fast` agent prompt and `planning-fast` seam, while `leanPlanning` and `autoApproveSpec` are workflow-native settings for prompt selection and spec-review auto-approval.
|
||||
|
||||
The internal `FAST_TRIAGE_SYSTEM_PROMPT` engine constant was removed. Existing `executionMode: "fast"` tasks remain byte-equivalent through a single legacy execution-mode-to-resolved-policy bridge.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Allow engineer-role agents to opt into no-task backlog auto-claim for implementation tasks while preserving executor-only default pickup behavior.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings.
|
||||
5
.changeset/ce-recover-stale-sessions.md
Normal file
5
.changeset/ce-recover-stale-sessions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Recover stale Compound Engineering sessions on plugin load and session reads so persisted active rows without live agent handles no longer leave the dashboard stuck waiting for work that is not running.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected.
|
||||
|
||||
The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix custom provider models not appearing in model dropdowns. The `/models` endpoint filtered results to providers configured in Fusion's auth stores, which excluded custom providers (stored in global settings). Their registry keys are now added to the allowlist so their models surface in pickers.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll mutations were blurring the focused composer textarea:
|
||||
|
||||
1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus.
|
||||
|
||||
2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock.
|
||||
|
||||
3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op.
|
||||
|
||||
4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@fusion/engine": patch
|
||||
---
|
||||
|
||||
Allow narrowly scoped plan-only operational tasks to complete without source commits when their prompt or metadata explicitly declares no-source/no-code intent and their recorded evidence satisfies the task. The commit guard still rejects missing commits for normal implementation tasks and still enforces worktree and branch invariants before applying the no-commit exemption.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Move AI-merge clean-room worktrees into a repo-local cleanup-exempt root, guard cleanup sweeps by active merge ownership, and classify missing clean-room worktree failures as transient so merges can retry cleanly.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix task detail Pull Request and Review surfaces so they use the live project auto-merge setting instead of a stale modal-open snapshot. Create PR / manual merge affordances now appear immediately when auto-merge is toggled off, and the automatic auto-merge hint returns when it is toggled back on.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Self-heal compound-engineering answer submission for restarted awaiting-input sessions by rehydrating the interactive session before sending the answer.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add a bounded persisted auto-retry for transient workflow-graph resume failures after engine restart or unpause, while preserving terminal failures for genuine graph errors.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Re-fire durable-agent assignment wakes that were skipped because the agent was mid-heartbeat, so newly assigned tasks are worked when the active run completes instead of waiting for the next timer tick.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Expose Google Generative AI as a selectable custom-provider API type in the dashboard settings UI and documentation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix workflow graph execution for the built-in coding workflow's merge-policy primitive region by collapsing any merge-region entry back to the legacy `merge` seam until the workflow interpreter owns merge policy execution.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Allow tasks to be archived from any live board column and restored to their pre-archive column.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add workflow-declared optional steps and expose Browser Verification as the built-in coding workflow's opt-in optional step for task creation and editing.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix non-English Active Agents next-heartbeat translations so localized strings interpolate the provided elapsed heartbeat value instead of showing a raw placeholder.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Self-healing now automatically re-dispatches an assigned in-progress task when its durable agent loses both the heartbeat run and active execution session, preventing the task from stranding until the next engine restart.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix built-in branching workflow selection so interpreter-deferred coding workflows can be selected or used as project defaults without throwing during legacy step materialization.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Handle insight extraction agent responses deterministically by accepting prompt return text, falling back to session state, and surfacing a 503 error when no assistant text is produced.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Sync workflow setting values across nodes in settings push, pull, receive, and status flows.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add workflow work-item storage primitives for workflow-owned merge migration.
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -19,6 +19,20 @@ Any task integrating a third-party tool (CLI, daemon, downloadable binary, insta
|
||||
|
||||
Missing evidence is a blocking REVISE. Never invent release URLs, binary names, or hashes.
|
||||
|
||||
Example evidence section shape:
|
||||
|
||||
```markdown
|
||||
## External Integration Evidence
|
||||
|
||||
- Canonical upstream repo URL: https://github.com/max-sixty/worktrunk
|
||||
- Docs / homepage URL: https://worktrunk.dev/
|
||||
- Release / download URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz
|
||||
- Binary / CLI name: `wt`
|
||||
- Checksum: `sha256-<digest>` (or `upstream-pending-verification` until the checksum is pinned)
|
||||
```
|
||||
|
||||
See `docs/contributing.md` for the fuller spec-authoring guidance and accepted labeled layout variants.
|
||||
|
||||
### Finalizing Changes
|
||||
|
||||
When a change affects published `@runfusion/fusion`, add a changeset (example: `.changeset/<name>.md` with `"@runfusion/fusion": patch`).
|
||||
|
||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -2,6 +2,201 @@
|
||||
|
||||
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.42.0
|
||||
|
||||
### @fusion/dashboard
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- Updated dependencies [630b2a8]
|
||||
- @fusion/engine@0.42.0
|
||||
- @fusion/core@0.42.0
|
||||
- @fusion/i18n@0.39.4
|
||||
- @fusion-plugin-examples/cli-printing-press@0.1.21
|
||||
- @fusion-plugin-examples/compound-engineering@0.1.4
|
||||
- @fusion-plugin-examples/dependency-graph@0.1.35
|
||||
- @fusion-plugin-examples/roadmap@0.1.23
|
||||
- @fusion-plugin-examples/cursor-runtime@0.1.23
|
||||
- @fusion-plugin-examples/droid-runtime@0.1.30
|
||||
- @fusion-plugin-examples/hermes-runtime@0.2.54
|
||||
- @fusion-plugin-examples/openclaw-runtime@0.2.54
|
||||
- @fusion-plugin-examples/paperclip-runtime@0.2.54
|
||||
|
||||
### @fusion/desktop
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- @fusion/dashboard@0.42.0
|
||||
- @fusion/core@0.42.0
|
||||
|
||||
### @fusion/engine
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- 630b2a8: Allow narrowly scoped plan-only operational tasks to complete without source commits when their prompt or metadata explicitly declares no-source/no-code intent and their recorded evidence satisfies the task. The commit guard still rejects missing commits for normal implementation tasks and still enforces worktree and branch invariants before applying the no-commit exemption.
|
||||
- @fusion/core@0.42.0
|
||||
- @fusion/pi-claude-cli@0.42.0
|
||||
|
||||
### @fusion/plugin-sdk
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- @fusion/core@0.42.0
|
||||
|
||||
### @runfusion/fusion
|
||||
|
||||
#### Minor Changes
|
||||
|
||||
- e22afec: Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30.
|
||||
|
||||
These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings.
|
||||
|
||||
- 039d3ce: Fast-mode triage is now expressed as workflow-declared policy: the lean prompt lives in the built-in `default-triage-fast` agent prompt and `planning-fast` seam, while `leanPlanning` and `autoApproveSpec` are workflow-native settings for prompt selection and spec-review auto-approval.
|
||||
|
||||
The internal `FAST_TRIAGE_SYSTEM_PROMPT` engine constant was removed. Existing `executionMode: "fast"` tasks remain byte-equivalent through a single legacy execution-mode-to-resolved-policy bridge.
|
||||
|
||||
- 167f9b0: Allow engineer-role agents to opt into no-task backlog auto-claim for implementation tasks while preserving executor-only default pickup behavior.
|
||||
- 1c4ec5f: Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings.
|
||||
- eb607c6: Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width.
|
||||
- f7f2cae: Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior.
|
||||
- 4e6df03: Add a verified no-op/duplicate task completion path so executors can close already-satisfied tasks without fabricating commits by using an audited `fn_task_done` sentinel summary.
|
||||
- 7ffea9f: Expose Google Generative AI as a selectable custom-provider API type in the dashboard settings UI and documentation.
|
||||
- 508551c: Allow tasks to be archived from any live board column and restored to their pre-archive column.
|
||||
- bd87ce7: Add workflow-declared optional steps and expose Browser Verification as the built-in coding workflow's opt-in optional step for task creation and editing.
|
||||
- 72661fa: Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters.
|
||||
- 07d5262: Sync workflow setting values across nodes in settings push, pull, receive, and status flows.
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- 8eb99ed: Quick Entry no longer auto-focuses when the board or dashboard becomes visible.
|
||||
- 36f5ecd: Skip custom workflow pre-merge prompt, script, and gate nodes when a task runs in fast execution mode.
|
||||
- 1a716f2: Resolve the standard triage planning prompt from the selected workflow IR planning node instead of the removed engine-side `TRIAGE_SYSTEM_PROMPT` duplicate. The built-in `default-triage` prompt is now the canonical policy source for `builtin:coding`; where the old copies disagreed, the surviving canonical subtask-split threshold is `MORE THAN 7 implementation steps` (with the matching `MORE THAN 3 different packages/modules` guidance). Fast-mode triage continues to use `FAST_TRIAGE_SYSTEM_PROMPT` unchanged.
|
||||
- fb2c6e5: Resolve the built-in reviewer base prompt from the workflow IR `review` node instead of an engine-local `REVIEWER_SYSTEM_PROMPT` duplicate. The canonical reviewer policy now lives in the `default-reviewer` agent prompt / built-in workflow seam, with reconciled superset content that preserves the FN-5928/FN-6229 surface-enumeration and symptom-verification gates, undersplit-task guidance, test-quality rules, worktree-boundary review, and the embedded port-4040 safety rule.
|
||||
- c0ff360: Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control.
|
||||
- 12621aa: Record explicit `builtin:coding` project-default workflow selections even when the compiled built-in has zero materialized steps, while preserving interpreter-deferred `builtin:stepwise-coding` fallback behavior.
|
||||
- 30e747b: Standalone plugin scaffolds now declare the dev toolchain they generate scripts and config for: `@types/node`, `vitest`, and `typescript`. This lets projects created with `fn plugin new` install, build, test, and load through `fn plugin dev . --once` via the documented external-author path without relying on transitive or hoisted dependencies.
|
||||
|
||||
Manual spot-check for release validation:
|
||||
|
||||
```sh
|
||||
npx @runfusion/fusion@latest plugin new proof-point-plugin
|
||||
cd proof-point-plugin
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm test
|
||||
fn plugin dev . --once
|
||||
```
|
||||
|
||||
- 8c16395: Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run.
|
||||
- d5b45c8: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location.
|
||||
- f0d2415: Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected.
|
||||
|
||||
The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty.
|
||||
|
||||
- a83c2d8: Fix custom provider models not appearing in model dropdowns. The `/models` endpoint filtered results to providers configured in Fusion's auth stores, which excluded custom providers (stored in global settings). Their registry keys are now added to the allowlist so their models surface in pickers.
|
||||
- cbc3157: Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll mutations were blurring the focused composer textarea:
|
||||
|
||||
1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus.
|
||||
|
||||
2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock.
|
||||
|
||||
3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op.
|
||||
|
||||
4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor.
|
||||
|
||||
- cbc3157: Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected.
|
||||
- e5036b1: Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap.
|
||||
- 535c40d: Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again.
|
||||
- e35f3dd: Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics.
|
||||
- 3a729f5: Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks.
|
||||
- c285f3f: Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution.
|
||||
- 9a78814: Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed.
|
||||
- 2085610: Move AI-merge clean-room worktrees into a repo-local cleanup-exempt root, guard cleanup sweeps by active merge ownership, and classify missing clean-room worktree failures as transient so merges can retry cleanly.
|
||||
- d23c5d9: Fix task detail Pull Request and Review surfaces so they use the live project auto-merge setting instead of a stale modal-open snapshot. Create PR / manual merge affordances now appear immediately when auto-merge is toggled off, and the automatic auto-merge hint returns when it is toggled back on.
|
||||
- 4fc00b6: Self-heal compound-engineering answer submission for restarted awaiting-input sessions by rehydrating the interactive session before sending the answer.
|
||||
- 65251d2: Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work.
|
||||
- bffae81: Add `autoMergeProvenance` so Fusion can distinguish explicit per-task auto-merge overrides from legacy review-entry stamps. Startup now marks ambiguous legacy in-review `autoMerge: true` rows as `legacy-stamp` without changing behavior, and the operator-visible `reconcileLegacyAutoMergeStamps` action (dry-run by default) can clear those legacy stamps so global auto-merge OFF is respected while genuine user overrides are preserved.
|
||||
- 0897b2a: Add a bounded persisted auto-retry for transient workflow-graph resume failures after engine restart or unpause, while preserving terminal failures for genuine graph errors.
|
||||
- ec4b247: Re-fire durable-agent assignment wakes that were skipped because the agent was mid-heartbeat, so newly assigned tasks are worked when the active run completes instead of waiting for the next timer tick.
|
||||
- 751d942: Fix workflow graph execution for the built-in coding workflow's merge-policy primitive region by collapsing any merge-region entry back to the legacy `merge` seam until the workflow interpreter owns merge policy execution.
|
||||
- 93237c3: Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat.
|
||||
- 480e55f: Fix non-English Active Agents next-heartbeat translations so localized strings interpolate the provided elapsed heartbeat value instead of showing a raw placeholder.
|
||||
- 0a135c9: Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates.
|
||||
- 66591ec: Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup.
|
||||
- a9b1139: Self-healing now automatically re-dispatches an assigned in-progress task when its durable agent loses both the heartbeat run and active execution session, preventing the task from stranding until the next engine restart.
|
||||
- f2054d0: Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow.
|
||||
- 34ada00: Show user-sent task-detail Chat steering messages as You bubbles and keep them visible after steering requests persist.
|
||||
- 35554e6: Keep the task-detail Chat composer pinned and visible while the transcript scrolls internally on mobile and desktop.
|
||||
- e0ec3d1: Steering messages sent from task chat now reach active step-session and workflow runs, including parallel step sessions, and the misleading inactive-session "next session" composer copy was removed.
|
||||
- f68775a: Ensure only explicit user actions unpause user-paused tasks. Engine self-healing, agent resume cascades, dashboard agent-state resume fallback, heartbeat recovery, and approval-decision resume no longer clear `userPaused` or auto-unpause tasks the user paused.
|
||||
- 4ea9d66: Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults.
|
||||
- 44b756d: Fix built-in branching workflow selection so interpreter-deferred coding workflows can be selected or used as project defaults without throwing during legacy step materialization.
|
||||
- e6eef1a: Handle insight extraction agent responses deterministically by accepting prompt return text, falling back to session state, and surfacing a 503 error when no assistant text is produced.
|
||||
- e305b1a: Respect per-task pause state during triage planning so paused tasks do not auto-advance after specification approval.
|
||||
- 40cb0d3: Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations.
|
||||
- f16b038: Add workflow work-item storage primitives for workflow-owned merge migration.
|
||||
|
||||
### runfusion.ai
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb99ed]
|
||||
- Updated dependencies [36f5ecd]
|
||||
- Updated dependencies [1a716f2]
|
||||
- Updated dependencies [e22afec]
|
||||
- Updated dependencies [fb2c6e5]
|
||||
- Updated dependencies [039d3ce]
|
||||
- Updated dependencies [c0ff360]
|
||||
- Updated dependencies [167f9b0]
|
||||
- Updated dependencies [1c4ec5f]
|
||||
- Updated dependencies [12621aa]
|
||||
- Updated dependencies [30e747b]
|
||||
- Updated dependencies [eb607c6]
|
||||
- Updated dependencies [8c16395]
|
||||
- Updated dependencies [d5b45c8]
|
||||
- Updated dependencies [f0d2415]
|
||||
- Updated dependencies [a83c2d8]
|
||||
- Updated dependencies [cbc3157]
|
||||
- Updated dependencies [cbc3157]
|
||||
- Updated dependencies [e5036b1]
|
||||
- Updated dependencies [535c40d]
|
||||
- Updated dependencies [e35f3dd]
|
||||
- Updated dependencies [3a729f5]
|
||||
- Updated dependencies [c285f3f]
|
||||
- Updated dependencies [f7f2cae]
|
||||
- Updated dependencies [9a78814]
|
||||
- Updated dependencies [2085610]
|
||||
- Updated dependencies [d23c5d9]
|
||||
- Updated dependencies [4fc00b6]
|
||||
- Updated dependencies [65251d2]
|
||||
- Updated dependencies [4e6df03]
|
||||
- Updated dependencies [bffae81]
|
||||
- Updated dependencies [0897b2a]
|
||||
- Updated dependencies [ec4b247]
|
||||
- Updated dependencies [7ffea9f]
|
||||
- Updated dependencies [751d942]
|
||||
- Updated dependencies [508551c]
|
||||
- Updated dependencies [93237c3]
|
||||
- Updated dependencies [bd87ce7]
|
||||
- Updated dependencies [72661fa]
|
||||
- Updated dependencies [480e55f]
|
||||
- Updated dependencies [0a135c9]
|
||||
- Updated dependencies [66591ec]
|
||||
- Updated dependencies [a9b1139]
|
||||
- Updated dependencies [f2054d0]
|
||||
- Updated dependencies [34ada00]
|
||||
- Updated dependencies [35554e6]
|
||||
- Updated dependencies [e0ec3d1]
|
||||
- Updated dependencies [f68775a]
|
||||
- Updated dependencies [4ea9d66]
|
||||
- Updated dependencies [44b756d]
|
||||
- Updated dependencies [e6eef1a]
|
||||
- Updated dependencies [07d5262]
|
||||
- Updated dependencies [e305b1a]
|
||||
- Updated dependencies [40cb0d3]
|
||||
- Updated dependencies [f16b038]
|
||||
- @runfusion/fusion@0.42.0
|
||||
|
||||
## 0.41.0
|
||||
|
||||
### @fusion/dashboard
|
||||
@@ -8700,6 +8895,14 @@ for reference.
|
||||
- Updated dependencies [a2ed6d0]
|
||||
- @runfusion/fusion@0.1.0
|
||||
|
||||
## 0.39.4
|
||||
|
||||
### @fusion/i18n
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- @fusion/core@0.42.0
|
||||
|
||||
## 0.39.3
|
||||
|
||||
### @fusion/i18n
|
||||
@@ -8724,6 +8927,14 @@ for reference.
|
||||
|
||||
- @fusion/core@0.40.0
|
||||
|
||||
## 0.11.30
|
||||
|
||||
### @fusion/droid-cli
|
||||
|
||||
#### Patch Changes
|
||||
|
||||
- @fusion-plugin-examples/droid-runtime@0.1.30
|
||||
|
||||
## 0.11.29
|
||||
|
||||
### @fusion/droid-cli
|
||||
|
||||
20
README.md
20
README.md
@@ -69,13 +69,13 @@ Every task shows its plan, its reviews, its diffs, and its file changes in real
|
||||
| | |
|
||||
|---|---|
|
||||
| 🧠 **AI planning** | Describe a task in plain language. Planning agents turn it into a `PROMPT.md` plan with steps, file scope, and acceptance criteria. |
|
||||
| 🔁 **Workflow gates** | Plan → Review → Execute → Review on every step. Pre-merge gates block bad code; post-merge gates run informational checks. |
|
||||
| 🔁 **Workflow gates** | Plan → Review → Execute → Review on every step. Pre-merge gates block bad code; post-merge gates run informational checks; workflow-declared optional steps such as [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) can be enabled per task. |
|
||||
| 🌳 **Worktree isolation** | Each task runs in its own branch and worktree (`fusion/{task-id}`). Parallel tasks. Zero conflicts. Optional [worktrunk](https://github.com/max-sixty/worktrunk) delegation via [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (see [WorktreeBackend abstraction](./docs/architecture.md#worktreebackend-abstraction)). |
|
||||
| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere. |
|
||||
| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere, or let tasks follow the live global auto-merge default unless they have an explicit per-task override. |
|
||||
| 🛰️ **Multi-node mesh** | Laptop, Mac mini, Linux server, cloud VM, phone — all synced. Desktop, mobile, web. |
|
||||
| 🧩 **Any model** | Anthropic, OpenAI, Ollama, and more. Local and cloud coexist. |
|
||||
| 🧩 **Any model** | Anthropic, OpenAI, Ollama, Google Generative AI, and user-defined [custom providers](./docs/dashboard-guide.md#custom-providers). Local and cloud coexist, with workflow model lanes configurable per project. |
|
||||
| 🏢 **Agent companies** | Import pre-built teams — 440+ agents across 16 companies — and run them autonomously for weeks. |
|
||||
| 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate. |
|
||||
| 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate; engineer-role agents can opt into backlog auto-claim when you want implementation help beyond executor-only pickup. |
|
||||
| 🗨️ **Multi-agent Chat Rooms** | Project-scoped group conversations where multiple room members can reply: mentioned members are direct responders, and additional ambient members may respond up to a cap. Currently **experimental** — enable `chatRooms` in **Settings → Experimental Features → Chat Rooms**. ([Chat Rooms docs](./docs/dashboard-guide.md#chat-rooms)) |
|
||||
| 🗺️ **Missions** | Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot and validation contracts. |
|
||||
| 🔬 **Research** | Bounded research runs with web search, GitHub, local docs, and LLM synthesis (plus runtime builtin WebSearch/WebFetch support in planning + synthesis flows when available). Turn findings into tasks. ([Docs](./docs/research.md)) |
|
||||
@@ -299,12 +299,15 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md).
|
||||
- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria
|
||||
- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step
|
||||
- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch)
|
||||
- **Workflow Steps** — Configurable quality gates (pre-merge: blocks merge; post-merge: informational)
|
||||
- **Workflow Steps** — Configurable quality gates (pre-merge: blocks merge; post-merge: informational), plus workflow-declared optional steps such as opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps)
|
||||
- **Workflow-native policy** — Fast-mode planning (`leanPlanning` / `autoApproveSpec`) and typed triage thresholds are workflow settings, not hard-coded engine constants ([Settings Reference](./docs/settings-reference.md#workflow-native-triage-policy-settings); [fast-mode step behavior](./docs/workflow-steps.md#execution-modes))
|
||||
- **GitHub Integration** — Import issues, create PRs, real-time PR/issue badges
|
||||
- **Dashboard** — Real-time kanban board, agent management, terminal, git manager, mission planner
|
||||
- **Dashboard** — Real-time kanban board, agent management, terminal, git manager, mission planner, custom provider setup, and workflow model lanes
|
||||
- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, and blocked-handoff semantics
|
||||
- **Multi-Project** — Manage multiple projects from a single installation with project isolation
|
||||
- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users
|
||||
- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in Project Models and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers); [settings shape](./docs/settings-reference.md#customproviders))
|
||||
- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior ([Settings Reference](./docs/settings-reference.md#project-settings))
|
||||
- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim for implementation tasks ([Settings Reference](./docs/settings-reference.md#project-settings))
|
||||
- **Chat Rooms (Experimental)** — Project-scoped group chat where mentioned members are routed as direct responders and additional ambient members may reply up to a cap (enable via **Settings → Experimental Features → Chat Rooms**; details in [Dashboard Guide → Chat Rooms](./docs/dashboard-guide.md#chat-rooms))
|
||||
|
||||
### Provider authentication
|
||||
@@ -316,6 +319,7 @@ Fusion supports OAuth-based authentication for AI providers configured via **Set
|
||||
- **Factory AI — via Droid CLI** *(optional)* — requires local Droid CLI install + `droid auth login`; detection follows the effective runtime binary path (default `droid`, or plugin `droidBinaryPath` when configured), then enable in **Settings → Authentication** and restart Fusion
|
||||
- **llama.cpp — via HTTP server** *(optional)* — configure your llama.cpp server URL (default `http://127.0.0.1:8080`) and optional API key, then enable in **Settings → Authentication**
|
||||
- **Other providers** — Authenticate via API key entry in Settings (including Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code aliases)
|
||||
- **Custom providers** — Add user-defined OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI endpoints from **Settings → Authentication → Custom Providers**; saved model IDs become selectable in project and workflow model lanes ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers))
|
||||
|
||||
### Model system
|
||||
|
||||
@@ -329,6 +333,8 @@ Fusion uses a dual-scope model hierarchy with five independent lanes. Global set
|
||||
| Title Summarization | Auto-title generation | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
|
||||
| Workflow Step Refinement | AI prompt refinement | (uses `defaultProvider`/`defaultModelId`) | (uses `modelProvider`/`modelId` on WorkflowStep) |
|
||||
|
||||
**Workflow lanes:** The default workflow exposes Plan/Triage, Executor, and Reviewer model lanes in **Settings → Project Models**, and advanced workflow settings can declare additional typed model/policy values ([Settings Reference](./docs/settings-reference.md#workflow-settings)).
|
||||
|
||||
**Per-Task Overrides:** Tasks can override the executor, validator, and planning lanes with per-task model fields (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`).
|
||||
|
||||
**Precedence:** Per-task → Project override → Global lane → `defaultProvider`/`defaultModelId` → Automatic resolution.
|
||||
|
||||
@@ -1224,6 +1224,7 @@ Task steps use statuses: `pending`, `in-progress`, `done`, `skipped`.
|
||||
### Task pause ownership
|
||||
- Only explicit user actions pause ordinary tasks: the dashboard/CLI task pause controls and manual `in-progress → todo` moves. System safety pauses remain reserved for explicit approval waits and bounded guardrails such as token-budget, worktrunk-failure, and dispatch-oscillation protection.
|
||||
- Agent pause/sleep and heartbeat recovery never pause assigned tasks. Assigned tasks stay in their current column and retain their existing `paused`/`pausedByAgentId` state so the scheduler can re-dispatch unpaused work and user-paused work remains intentionally parked.
|
||||
- Only explicit user unpause actions may clear `task.userPaused`; engine self-healing, heartbeat/agent resume cascades, and approval resume paths must leave user-paused tasks parked.
|
||||
|
||||
### User cancel via move-to-todo
|
||||
- `TaskStore.moveTask()` accepts `moveSource: "user" | "engine"` (default `"engine"`) and emits `task:moved` with `source` so listeners can distinguish manual moves from engine rebounds.
|
||||
@@ -1791,8 +1792,9 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
|
||||
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat.
|
||||
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.
|
||||
- **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused.
|
||||
- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode).
|
||||
- **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off.
|
||||
- **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter.
|
||||
|
||||
@@ -591,6 +591,8 @@ Create a pull request for a task with `fn pr create <task-id>`.
|
||||
|
||||
Alias: `fn task pr-create <task-id>`
|
||||
|
||||
Maintenance: `fn pr automerge-cleanup` performs a dry run of legacy auto-merge stamps left by older `in-review` task behavior and prints affected task IDs/columns. Add `--apply` to clear those stamps after reviewing the list, and `--json` for machine-readable output.
|
||||
|
||||
Flags:
|
||||
- `--title <title>`: Set the PR title.
|
||||
- `--base <branch>`: Target base branch (default from repo/CLI settings).
|
||||
@@ -605,6 +607,8 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo
|
||||
fn pr create FN-001
|
||||
fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main
|
||||
fn task pr-create FN-001 --title "Fix login race" --body "Prevents duplicate session refresh." --base main
|
||||
fn pr automerge-cleanup --json
|
||||
fn pr automerge-cleanup --apply
|
||||
fn task import owner/repo --labels bug --limit 10
|
||||
fn task import owner/repo --interactive
|
||||
```
|
||||
|
||||
@@ -94,7 +94,7 @@ GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard
|
||||
- `pnpm test:full` remains the explicit full workspace suite; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`.
|
||||
- `pnpm verify:workspace` remains the deep opt-in lint -> test -> build verification.
|
||||
|
||||
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test -- --shard <index>/<count>`. This keeps coverage reproducible while improving shard balance.
|
||||
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test --shard <index>/<count>` (no bare `--`, because Vitest's cac parser would otherwise treat the shard flag as a filter separator). This keeps coverage reproducible while improving shard balance.
|
||||
|
||||
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It runs the merge-gate suite first, then the affected set. The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`); shared-infrastructure changes and unresolvable diffs widen the affected set but never escalate to an implicit full-suite run (the old escalation was the local OOM path).
|
||||
|
||||
@@ -112,6 +112,34 @@ Fusion tests must run against disposable test data, never live local state:
|
||||
|
||||
If you add or change test entrypoints, keep this isolation guard path intact and ensure guard + test execution share the same disposable HOME so changed/full/cached paths stay consistent.
|
||||
|
||||
## Spec authoring: provenance evidence for outside tooling
|
||||
|
||||
Any task that wires in an outside command-line program, daemon, separately-fetched program, or package-managed dependency must include provenance evidence in its `PROMPT.md`. The deterministic spec-validation gate (`detectExternalIntegrationEvidenceGaps`) REVISEs specs that mention this kind of outside tooling without enough provenance to audit where it comes from and what command or artifact is expected.
|
||||
|
||||
Use a dedicated `## External Integration Evidence` or `## External-Integration Evidence` section when possible. The gate accepts semantically labeled bullets; labels may include or omit a trailing `URL`/`name`, may use `/` or `:` separators (for example `Docs / homepage URL:` or `Docs/homepage:`), and URLs may be bare or backtick-wrapped.
|
||||
|
||||
Include all five evidence fields:
|
||||
|
||||
1. Canonical upstream repo URL — a GitHub URL with distinct owner/repo; duplicate owner/owner placeholders are rejected.
|
||||
2. Docs / homepage URL — a distinct non-GitHub, non-artifact URL.
|
||||
3. Release / download URL — a GitHub `…/releases/…` URL, a generic `…download…` URL, an npm `registry.npmjs.org/<pkg>/-/<name>-<ver>.tgz` URL, or any `.tgz`/`.tar.gz` artifact URL.
|
||||
4. Binary / CLI name — the command name in backticks, such as `` `wt` ``.
|
||||
5. Checksum — a `sha256`/`sha512` digest, a pinned-manifest token, or the literal `upstream-pending-verification` marker. The marker is accepted for the checksum field only; never use it in place of source, docs, or artifact URLs.
|
||||
|
||||
Never fabricate source URLs, command names, release locations, or checksums. Cite real provenance, or use `upstream-pending-verification` only for the checksum field while the digest is being pinned.
|
||||
|
||||
<!-- evidence-example:start -->
|
||||
```markdown
|
||||
## External Integration Evidence
|
||||
|
||||
- Canonical upstream repo URL: https://github.com/max-sixty/worktrunk
|
||||
- Docs / homepage URL: https://worktrunk.dev/
|
||||
- Release / download URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz
|
||||
- Binary / CLI name: `wt`
|
||||
- Checksum: `sha256-<digest>` (or `upstream-pending-verification` until the checksum is pinned)
|
||||
```
|
||||
<!-- evidence-example:end -->
|
||||
|
||||
## Quality Gate Checklist
|
||||
|
||||
Before submitting changes, verify:
|
||||
|
||||
@@ -111,10 +111,10 @@ Behavior:
|
||||
- Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology.
|
||||
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
|
||||
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.
|
||||
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
|
||||
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
|
||||
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
|
||||
- Mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop.
|
||||
- Mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Settings keeps the Definitions/Values tab split.
|
||||
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop.
|
||||
- Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split.
|
||||
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
|
||||
|
||||
## Custom Providers
|
||||
@@ -690,6 +690,7 @@ For related global/project configuration behavior, see [Settings reference](./se
|
||||
Inspect task definition, logs, review feedback, comments, documents, workflow outcomes, model overrides, and task routing from a single modal.
|
||||
|
||||
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
|
||||
- The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
||||
@@ -710,6 +711,12 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
||||
- For shared `branch_groups` (tasks with `branchContext.groupId`), PR merge mode opens and tracks one group-level PR from the group integration branch to the project default branch; member tasks share that PR state.
|
||||
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
|
||||
|
||||
### Legacy auto-merge stamp cleanup
|
||||
|
||||
Settings → Merge includes **Legacy auto-merge stamp cleanup** for operators auditing tasks that inherited historical in-review `autoMerge` stamps. The panel loads a dry-run candidate list, shows task IDs and current columns, and only reveals the destructive **Clear legacy stamps** action when candidates exist. Applying the cleanup requires the browser confirmation prompt, calls the maintenance apply endpoint, and then refreshes the dry-run list so cleared tasks disappear.
|
||||
|
||||
Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows before relying on per-task auto-merge overrides. It only targets stamps tagged as legacy provenance; explicit user overrides remain intact.
|
||||
|
||||
### Identifying high-impact blockers
|
||||
|
||||
Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
|
||||
@@ -728,7 +735,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
|
||||
### Logs → Agent Log view
|
||||
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint.
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint.
|
||||
|
||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||
|
||||
@@ -1183,7 +1190,7 @@ Dark/light modes via `data-theme`; 54 color themes via `data-color-theme` (lazy-
|
||||
|
||||
Reuse existing primitives from `styles.css`:
|
||||
- **Buttons**: `.btn`, `.btn-primary`, `.btn-danger`, `.btn-warning`, `.btn-sm`, `.btn-icon`, `.btn-icon--active`, `.btn-badge`. All inherit `:focus-visible` via `--focus-ring-strong` and `:active` via `transform: scale(0.97)`.
|
||||
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors.
|
||||
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors. Resizable modals using `useModalResizePersist(...)` get a shared bottom-right touch/mouse resize grip on tablet and desktop; mobile sheets stay full-screen and grip-free.
|
||||
- **Forms**: `.form-group`, `.input`, `.select`, `.checkbox-label`, `.form-error`. Inputs in `.form-group` get focus styles automatically.
|
||||
- **Cards**: `.card`, `.card-header`, `.card-id`, `.card-title`, `.card-meta`, `.card-status-badge--{triage,todo,in-progress,in-review,done,archived}`.
|
||||
- **Utility**: `.touch-target` (44px min), `.visually-hidden`.
|
||||
@@ -1198,7 +1205,7 @@ Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-wi
|
||||
|
||||
**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar stays pinned to page bottom cross-platform; the executor footer keyboard-collapse pin is iOS-only. On Android (`interactive-widget=resizes-content`), the footer keeps its stacked position above the nav bar to avoid overlap after keyboard dismiss.
|
||||
|
||||
**Footer-safe fill layouts:** View wrappers that reserve footer/mobile-nav space (for example `.project-content`) should be flex containers with `min-height: 0` / `min-width: 0`, and child surfaces like `.board` should use `flex: 1 1 auto` plus the same min-size guards. This keeps the board/columns stretched between the header and fixed bottom bars across desktop, tablet, and mobile while allowing internal scroll regions to own overflow.
|
||||
**Footer-safe fill layouts:** View wrappers that reserve footer/mobile-nav space (for example `.project-content`) should be flex containers with `min-height: 0` / `min-width: 0`, and child surfaces like `.board` should use `flex: 1 1 auto` plus the same min-size guards. Workflow-mode board wrappers (`.board-workflow-view` → `.board-workflow-columns`) also keep a definite `height: 100%`/`max-height: 100%` chain so the workflow toolbar and columns split the available space on tablet as well as desktop/mobile. This keeps the board/columns stretched between the header and fixed bottom bars across desktop, tablet, and mobile while allowing internal scroll regions to own overflow.
|
||||
|
||||
**Touch targets:** Standing button-freeze directive supersedes per-button touch-target guidance. For non-button elements, primary controls (nav bar, FAB, tab action rows, modal CTAs, list-row tap targets, form controls) must be ≥36px on mobile. Secondary controls inside a card/list-row where the row itself is the tap target stay compact (24–28px or small chips).
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
|
||||
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |
|
||||
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
|
||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
|
||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
|
||||
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
|
||||
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
||||
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "Mobile board iOS horizontal overscroll containment"
|
||||
date: 2026-06-13
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/styles.css
|
||||
problem_type: ui_bug
|
||||
component: frontend_css
|
||||
symptoms:
|
||||
- "On iOS Safari/PWA, dragging the kanban board past the first or last column rubber-bands the column strip off screen"
|
||||
- "Horizontal edge overscroll can expose empty space and chain to the document even though the board's inner column scroll is intentional"
|
||||
root_cause: css_scroll_containment_gap
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/components/Lane.css
|
||||
- packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts
|
||||
tags:
|
||||
- ios-safari
|
||||
- mobile-board
|
||||
- overscroll-behavior
|
||||
- scroll-snap
|
||||
- css-regression-test
|
||||
- kanban
|
||||
applies_when:
|
||||
- "A horizontally scrollable board or lane strip uses `overflow-x: auto` with mobile momentum scrolling"
|
||||
- "Edge dragging should keep native inner scrolling but must not chain or park content off screen"
|
||||
---
|
||||
|
||||
# Mobile board iOS horizontal overscroll containment
|
||||
|
||||
## Problem
|
||||
|
||||
The mobile kanban board intentionally scrolls horizontally between columns using `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. On iOS Safari/PWA, that same momentum scroller can rubber-band past its first or last column if the scroller does not contain horizontal overscroll. The visible result is that the columns slide away from the viewport edge, exposing empty space and sometimes chaining the drag to the document.
|
||||
|
||||
## Root cause
|
||||
|
||||
The board had page-level mobile overscroll protection on `html, body`, but the board itself is the horizontal scroll container. The base `.board` and the mobile `@media (max-width: 768px) .board` rules declared the intended scroll and snap properties without `overscroll-behavior-x`, so iOS edge overscroll was not contained at the board boundary. Workflow and multi-lane board variants in `Lane.css` had the same independent horizontal scrollers.
|
||||
|
||||
## Solution
|
||||
|
||||
Add axis-specific containment to each horizontal board strip:
|
||||
|
||||
```css
|
||||
.board,
|
||||
.board.board-workflow-columns,
|
||||
.lane-columns {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
```
|
||||
|
||||
Keep `contain` rather than `none`: the board can retain its native inner scroll feel while edge overscroll stops at the board/lane container instead of chaining outward. Do not replace this with `overflow: hidden`/`clip`, and do not switch snap back to `x mandatory`; both would regress intentional mobile column navigation.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Use a CSS-fixture test that loads the combined dashboard CSS and asserts:
|
||||
|
||||
- the mobile `.board` rule still has `overflow-x: auto` and `scroll-snap-type: x proximity`;
|
||||
- the mobile `.board` rule declares `overscroll-behavior-x: contain`;
|
||||
- the base `.board`, `.board.board-workflow-columns`, and `.lane-columns` horizontal scrollers also declare containment;
|
||||
- no checked board path uses `scroll-snap-type: x mandatory`.
|
||||
|
||||
For FN-6378 this lives in `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts`.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: "Mobile document horizontal pan containment"
|
||||
date: 2026-06-13
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/styles.css
|
||||
problem_type: ui_bug
|
||||
component: frontend_css
|
||||
symptoms:
|
||||
- "On mobile, the entire dashboard can be horizontally panned into a shifted state"
|
||||
- "Header, board, and footer slide left together while a dark empty void appears on the right"
|
||||
- "The inner kanban board should scroll horizontally, but the document/page itself must not"
|
||||
root_cause: mobile_viewport_containment
|
||||
resolution_type: css_fix
|
||||
severity: high
|
||||
related_components:
|
||||
- packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts
|
||||
- packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts
|
||||
- packages/dashboard/app/__tests__/board-tablet-overflow.test.ts
|
||||
tags:
|
||||
- mobile
|
||||
- viewport
|
||||
- overflow
|
||||
- touch-action
|
||||
- visual-viewport
|
||||
- kanban-board
|
||||
---
|
||||
|
||||
# Mobile document horizontal pan containment
|
||||
|
||||
## Problem
|
||||
|
||||
The mobile dashboard can enter a broken off-axis state where the whole page chrome shifts left and exposes an empty dark strip on the right. The screenshot for FN-6365 showed the header, board, and footer all shifted together, which means the document/visual viewport was panned horizontally — not just the intended `.board` column strip.
|
||||
|
||||
## Root cause
|
||||
|
||||
The mobile global CSS locked `overflow: hidden` on `html`, `body`, and `#root`, but every element was also assigned `touch-action: pan-x pan-y`. That allowed horizontal gestures that began on root chrome, fixed bars, modal chrome, or other non-board surfaces to be interpreted as page-level horizontal panning. The board was the intended horizontal scroller, but the document root did not explicitly enforce vertical-only touch handling, `overflow-x: hidden`, and `overscroll-behavior-x: none` as separate invariants.
|
||||
|
||||
Fullscreen mobile overlays were also only constrained by `width/max-width: 100%`; adding logical inline-size constraints keeps modal/overlay chrome from widening the document when the layout viewport and visual viewport diverge.
|
||||
|
||||
## Fix
|
||||
|
||||
In the mobile `@media (max-width: 768px)` global block:
|
||||
|
||||
- Lock `html`, `body`, and `#root` to the viewport inline axis with `width/max-width: 100%`, `overflow-x: hidden`, and `overscroll-behavior-x: none`.
|
||||
- Make document-root/default touch handling vertical-only with `touch-action: pan-y`.
|
||||
- Opt the known legitimate horizontal scrollers back into `touch-action: pan-x pan-y`: `.board`, `pre`, `code`, `.code-block`, and `table`.
|
||||
- Keep `.board` horizontally scrollable with `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`.
|
||||
- Constrain mobile fullscreen overlay/modal chrome with `inline-size: 100%`, `max-inline-size: 100%`, and `min-width: 0` where appropriate.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts` asserts the containment contract directly from CSS fixtures:
|
||||
|
||||
- Mobile root has `overflow-x: hidden`, `overscroll-behavior-x: none`, and `touch-action: pan-y`.
|
||||
- The mobile `.board` still has `overflow-x: auto` and `scroll-snap-type: x proximity`.
|
||||
- Code/table opt-in horizontal scrollers keep `touch-action: pan-x pan-y`.
|
||||
- Fullscreen overlay/modal chrome is constrained to the viewport inline size.
|
||||
- The tablet `.board` overflow rule remains unchanged.
|
||||
|
||||
## Pitfall
|
||||
|
||||
Do not fix this class by blanket-clipping all descendants or removing `.board` horizontal scrolling. The board, code blocks, and tables are valid inner horizontal scrollers; the invariant is that the document/visual viewport itself must stay at horizontal offset zero.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Mobile iOS restore document scroll drift"
|
||||
date: 2026-06-13
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/hooks/useMobileScrollLock
|
||||
problem_type: ui_bug
|
||||
component: frontend_mobile_layout
|
||||
applies_when: "An iOS Safari/PWA dashboard tab is restored from background or bfcache after the document has stale scroll or orphaned body offset."
|
||||
symptoms:
|
||||
- "Returning to Fusion on iOS can leave the header/board pushed above the top of the screen"
|
||||
- "A large empty gap appears at the bottom even though the soft keyboard is down"
|
||||
- "The dashboard resting layout should have document scroll at the origin because body overflow is hidden"
|
||||
root_cause: ios_restore_left_stale_document_scroll_or_body_offset
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/App.tsx
|
||||
- packages/dashboard/app/hooks/useMobileScrollLock.ts
|
||||
- packages/dashboard/app/hooks/useMobileKeyboard.ts
|
||||
- FN-6362
|
||||
- FN-6364
|
||||
tags:
|
||||
- ios-safari
|
||||
- mobile-keyboard
|
||||
- document-scroll
|
||||
- visualviewport
|
||||
- bfcache
|
||||
---
|
||||
|
||||
# Mobile iOS restore document scroll drift
|
||||
|
||||
## Problem
|
||||
|
||||
On iOS Safari/PWA, switching away from Fusion and returning can leave the layout viewport visually misaligned with the dashboard. The document may retain `window.scrollY > 0`, or a stale inline body offset from an earlier lock, even though Fusion's base shell uses `body { overflow: hidden }` and the resting document scroll position should be `(0, 0)`.
|
||||
|
||||
The visible symptom is the board/header appearing shifted upward with an empty gap at the bottom after foregrounding the app, including cases where no input is currently focused.
|
||||
|
||||
## Solution
|
||||
|
||||
Keep keyboard metrics recovery and document-scroll recovery as separate concerns:
|
||||
|
||||
- FN-6362 resets `useMobileKeyboard` metrics on `visibilitychange`/`pageshow` so `--vv-offset-top` consumers stop seeing a stale keyboard-open state.
|
||||
- FN-6364 adds `useMobileViewportRestoreReset` in `useMobileScrollLock.ts` and wires it once from `App.tsx` for mobile layouts.
|
||||
|
||||
The restore hook only runs on iOS mobile devices. On `document.visibilitychange` it acts only when `document.visibilityState === "visible"`, and on `window.pageshow` it handles normal and bfcache restores. If no fullscreen scroll lock or keyboard viewport lock is active, it clears orphaned body fixed-position offset styles and calls `window.scrollTo(0, 0)` when stale document scroll is present.
|
||||
|
||||
Do not run this reset on Android or desktop, and do not run it while `useMobileScrollLock` or `useMobileKeyboardViewportLock` is active; live locks own their own restore path.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Cover the invariant at the `useMobileScrollLock` hook seam:
|
||||
|
||||
- iOS mobile + `visibilitychange` to visible + `scrollY > 0` calls `scrollTo(0, 0)`.
|
||||
- iOS mobile + `pageshow` with `persisted: false` calls `scrollTo(0, 0)`.
|
||||
- Android and desktop restore events are no-ops.
|
||||
- `visibilitychange` to hidden is a no-op.
|
||||
- Active fullscreen scroll locks and keyboard viewport locks prevent the restore hook from fighting the live lock.
|
||||
- `scrollY === 0` is idempotent.
|
||||
- Orphaned body `position: fixed` / `top` offset is cleared only when no lock is active.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Mobile keyboard restore stale viewport reset"
|
||||
date: 2026-06-13
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/hooks/useMobileKeyboard
|
||||
problem_type: ui_bug
|
||||
component: frontend_mobile_layout
|
||||
applies_when: "A mobile browser restores the page from hidden/pageshow after the soft keyboard collapses while the focused input remains active."
|
||||
symptoms:
|
||||
- "Returning to the dashboard on iOS can leave mobile layout in a keyboard-open state after the keyboard is already down"
|
||||
- "Viewport height/offset metrics remain stale when an input stays focused across the hidden → visible or pageshow transition"
|
||||
- "Footer/mobile-nav spacing can stay suppressed until a later resize or blur event corrects the metrics"
|
||||
root_cause: stale_visualviewport_sample_held_after_restore
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/App.tsx
|
||||
- packages/dashboard/app/utils/mobileBarKeyboardFlags.ts
|
||||
- FN-5155
|
||||
- FN-6362
|
||||
tags:
|
||||
- mobile-keyboard
|
||||
- visualviewport
|
||||
- ios
|
||||
- pageshow
|
||||
- visibilitychange
|
||||
- viewport-metrics
|
||||
---
|
||||
|
||||
# Mobile keyboard restore stale viewport reset
|
||||
|
||||
## Problem
|
||||
|
||||
`useMobileKeyboard` protects normal in-session keyboard handling from impossible iOS samples: when an input is focused, a transient sample that reports a restored full viewport but still carries stale open-keyboard metrics can be held so the dashboard does not flicker. That FN-5155 guard is useful while the page is active, but it also masked a real restore transition.
|
||||
|
||||
When the app returned from `hidden`/`pageshow` with the soft keyboard collapsed and the focused input still active, the hook reused the previous open-keyboard metrics. Because focus remained on the input, the impossible-sample hold treated the collapsed restore sample as suspicious and kept `keyboardOpen`, `viewportHeight`, and `offsetTop` stale until another resize or blur arrived.
|
||||
|
||||
## Solution
|
||||
|
||||
Handle page restore as a distinct sampling path rather than weakening the normal in-session guard.
|
||||
|
||||
- On `visibilitychange` back to `visible` and on `pageshow`, take an immediate restore sample.
|
||||
- If the restore sample is a collapsed/full-height viewport, reset the baseline viewport height and bypass the impossible-sample hold for that one sample.
|
||||
- Keep FN-5155's impossible-sample hold in place for regular resize/focus/tail updates.
|
||||
- Continue scheduling delayed tail updates after restore so later iOS viewport corrections still land.
|
||||
|
||||
This lets a collapsed restore clear `keyboardOpen`, `viewportHeight`, and `offsetTop` even when `document.activeElement` is still an input, while a genuinely open restored keyboard remains open.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Cover restore as a surface invariant, not only the single iOS reproduction:
|
||||
|
||||
- `visibilitychange` from hidden to visible with retained focus and a collapsed viewport resets stale open-keyboard metrics.
|
||||
- `pageshow` with stale positive `visualViewport.offsetTop` drift clears the keyboard state when the viewport is full height.
|
||||
- A genuinely shrunken restored viewport remains keyboard-open.
|
||||
- Android-style shrink metrics reset without carrying iOS offset drift.
|
||||
- Existing FN-5155 in-session impossible-sample coverage remains green, proving the normal guard was not removed.
|
||||
|
||||
The hook-level test seam is preferable here because callers already consume the hook-provided `keyboardOpen` and viewport values; no consumer-specific behavior needed to change.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fusion-workspace",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# runfusion.ai
|
||||
|
||||
## 0.42.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb99ed]
|
||||
- Updated dependencies [36f5ecd]
|
||||
- Updated dependencies [1a716f2]
|
||||
- Updated dependencies [e22afec]
|
||||
- Updated dependencies [fb2c6e5]
|
||||
- Updated dependencies [039d3ce]
|
||||
- Updated dependencies [c0ff360]
|
||||
- Updated dependencies [167f9b0]
|
||||
- Updated dependencies [1c4ec5f]
|
||||
- Updated dependencies [12621aa]
|
||||
- Updated dependencies [30e747b]
|
||||
- Updated dependencies [eb607c6]
|
||||
- Updated dependencies [8c16395]
|
||||
- Updated dependencies [d5b45c8]
|
||||
- Updated dependencies [f0d2415]
|
||||
- Updated dependencies [a83c2d8]
|
||||
- Updated dependencies [cbc3157]
|
||||
- Updated dependencies [cbc3157]
|
||||
- Updated dependencies [e5036b1]
|
||||
- Updated dependencies [535c40d]
|
||||
- Updated dependencies [e35f3dd]
|
||||
- Updated dependencies [3a729f5]
|
||||
- Updated dependencies [c285f3f]
|
||||
- Updated dependencies [f7f2cae]
|
||||
- Updated dependencies [9a78814]
|
||||
- Updated dependencies [2085610]
|
||||
- Updated dependencies [d23c5d9]
|
||||
- Updated dependencies [4fc00b6]
|
||||
- Updated dependencies [65251d2]
|
||||
- Updated dependencies [4e6df03]
|
||||
- Updated dependencies [bffae81]
|
||||
- Updated dependencies [0897b2a]
|
||||
- Updated dependencies [ec4b247]
|
||||
- Updated dependencies [7ffea9f]
|
||||
- Updated dependencies [751d942]
|
||||
- Updated dependencies [508551c]
|
||||
- Updated dependencies [93237c3]
|
||||
- Updated dependencies [bd87ce7]
|
||||
- Updated dependencies [72661fa]
|
||||
- Updated dependencies [480e55f]
|
||||
- Updated dependencies [0a135c9]
|
||||
- Updated dependencies [66591ec]
|
||||
- Updated dependencies [a9b1139]
|
||||
- Updated dependencies [f2054d0]
|
||||
- Updated dependencies [34ada00]
|
||||
- Updated dependencies [35554e6]
|
||||
- Updated dependencies [e0ec3d1]
|
||||
- Updated dependencies [f68775a]
|
||||
- Updated dependencies [4ea9d66]
|
||||
- Updated dependencies [44b756d]
|
||||
- Updated dependencies [e6eef1a]
|
||||
- Updated dependencies [07d5262]
|
||||
- Updated dependencies [e305b1a]
|
||||
- Updated dependencies [40cb0d3]
|
||||
- Updated dependencies [f16b038]
|
||||
- @runfusion/fusion@0.42.0
|
||||
|
||||
## 0.41.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "runfusion.ai",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"license": "MIT",
|
||||
"description": "Launch Fusion with `npx runfusion.ai` — tiny alias for @runfusion/fusion.",
|
||||
"homepage": "https://runfusion.ai",
|
||||
|
||||
@@ -1,5 +1,98 @@
|
||||
# @runfusion/fusion
|
||||
|
||||
## 0.42.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e22afec: Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30.
|
||||
|
||||
These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings.
|
||||
|
||||
- 039d3ce: Fast-mode triage is now expressed as workflow-declared policy: the lean prompt lives in the built-in `default-triage-fast` agent prompt and `planning-fast` seam, while `leanPlanning` and `autoApproveSpec` are workflow-native settings for prompt selection and spec-review auto-approval.
|
||||
|
||||
The internal `FAST_TRIAGE_SYSTEM_PROMPT` engine constant was removed. Existing `executionMode: "fast"` tasks remain byte-equivalent through a single legacy execution-mode-to-resolved-policy bridge.
|
||||
|
||||
- 167f9b0: Allow engineer-role agents to opt into no-task backlog auto-claim for implementation tasks while preserving executor-only default pickup behavior.
|
||||
- 1c4ec5f: Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings.
|
||||
- eb607c6: Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width.
|
||||
- f7f2cae: Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior.
|
||||
- 4e6df03: Add a verified no-op/duplicate task completion path so executors can close already-satisfied tasks without fabricating commits by using an audited `fn_task_done` sentinel summary.
|
||||
- 7ffea9f: Expose Google Generative AI as a selectable custom-provider API type in the dashboard settings UI and documentation.
|
||||
- 508551c: Allow tasks to be archived from any live board column and restored to their pre-archive column.
|
||||
- bd87ce7: Add workflow-declared optional steps and expose Browser Verification as the built-in coding workflow's opt-in optional step for task creation and editing.
|
||||
- 72661fa: Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters.
|
||||
- 07d5262: Sync workflow setting values across nodes in settings push, pull, receive, and status flows.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8eb99ed: Quick Entry no longer auto-focuses when the board or dashboard becomes visible.
|
||||
- 36f5ecd: Skip custom workflow pre-merge prompt, script, and gate nodes when a task runs in fast execution mode.
|
||||
- 1a716f2: Resolve the standard triage planning prompt from the selected workflow IR planning node instead of the removed engine-side `TRIAGE_SYSTEM_PROMPT` duplicate. The built-in `default-triage` prompt is now the canonical policy source for `builtin:coding`; where the old copies disagreed, the surviving canonical subtask-split threshold is `MORE THAN 7 implementation steps` (with the matching `MORE THAN 3 different packages/modules` guidance). Fast-mode triage continues to use `FAST_TRIAGE_SYSTEM_PROMPT` unchanged.
|
||||
- fb2c6e5: Resolve the built-in reviewer base prompt from the workflow IR `review` node instead of an engine-local `REVIEWER_SYSTEM_PROMPT` duplicate. The canonical reviewer policy now lives in the `default-reviewer` agent prompt / built-in workflow seam, with reconciled superset content that preserves the FN-5928/FN-6229 surface-enumeration and symptom-verification gates, undersplit-task guidance, test-quality rules, worktree-boundary review, and the embedded port-4040 safety rule.
|
||||
- c0ff360: Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control.
|
||||
- 12621aa: Record explicit `builtin:coding` project-default workflow selections even when the compiled built-in has zero materialized steps, while preserving interpreter-deferred `builtin:stepwise-coding` fallback behavior.
|
||||
- 30e747b: Standalone plugin scaffolds now declare the dev toolchain they generate scripts and config for: `@types/node`, `vitest`, and `typescript`. This lets projects created with `fn plugin new` install, build, test, and load through `fn plugin dev . --once` via the documented external-author path without relying on transitive or hoisted dependencies.
|
||||
|
||||
Manual spot-check for release validation:
|
||||
|
||||
```sh
|
||||
npx @runfusion/fusion@latest plugin new proof-point-plugin
|
||||
cd proof-point-plugin
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm test
|
||||
fn plugin dev . --once
|
||||
```
|
||||
|
||||
- 8c16395: Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run.
|
||||
- d5b45c8: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location.
|
||||
- f0d2415: Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected.
|
||||
|
||||
The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty.
|
||||
|
||||
- a83c2d8: Fix custom provider models not appearing in model dropdowns. The `/models` endpoint filtered results to providers configured in Fusion's auth stores, which excluded custom providers (stored in global settings). Their registry keys are now added to the allowlist so their models surface in pickers.
|
||||
- cbc3157: Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll mutations were blurring the focused composer textarea:
|
||||
|
||||
1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus.
|
||||
|
||||
2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock.
|
||||
|
||||
3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op.
|
||||
|
||||
4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor.
|
||||
|
||||
- cbc3157: Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected.
|
||||
- e5036b1: Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap.
|
||||
- 535c40d: Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again.
|
||||
- e35f3dd: Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics.
|
||||
- 3a729f5: Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks.
|
||||
- c285f3f: Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution.
|
||||
- 9a78814: Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed.
|
||||
- 2085610: Move AI-merge clean-room worktrees into a repo-local cleanup-exempt root, guard cleanup sweeps by active merge ownership, and classify missing clean-room worktree failures as transient so merges can retry cleanly.
|
||||
- d23c5d9: Fix task detail Pull Request and Review surfaces so they use the live project auto-merge setting instead of a stale modal-open snapshot. Create PR / manual merge affordances now appear immediately when auto-merge is toggled off, and the automatic auto-merge hint returns when it is toggled back on.
|
||||
- 4fc00b6: Self-heal compound-engineering answer submission for restarted awaiting-input sessions by rehydrating the interactive session before sending the answer.
|
||||
- 65251d2: Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work.
|
||||
- bffae81: Add `autoMergeProvenance` so Fusion can distinguish explicit per-task auto-merge overrides from legacy review-entry stamps. Startup now marks ambiguous legacy in-review `autoMerge: true` rows as `legacy-stamp` without changing behavior, and the operator-visible `reconcileLegacyAutoMergeStamps` action (dry-run by default) can clear those legacy stamps so global auto-merge OFF is respected while genuine user overrides are preserved.
|
||||
- 0897b2a: Add a bounded persisted auto-retry for transient workflow-graph resume failures after engine restart or unpause, while preserving terminal failures for genuine graph errors.
|
||||
- ec4b247: Re-fire durable-agent assignment wakes that were skipped because the agent was mid-heartbeat, so newly assigned tasks are worked when the active run completes instead of waiting for the next timer tick.
|
||||
- 751d942: Fix workflow graph execution for the built-in coding workflow's merge-policy primitive region by collapsing any merge-region entry back to the legacy `merge` seam until the workflow interpreter owns merge policy execution.
|
||||
- 93237c3: Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat.
|
||||
- 480e55f: Fix non-English Active Agents next-heartbeat translations so localized strings interpolate the provided elapsed heartbeat value instead of showing a raw placeholder.
|
||||
- 0a135c9: Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates.
|
||||
- 66591ec: Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup.
|
||||
- a9b1139: Self-healing now automatically re-dispatches an assigned in-progress task when its durable agent loses both the heartbeat run and active execution session, preventing the task from stranding until the next engine restart.
|
||||
- f2054d0: Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow.
|
||||
- 34ada00: Show user-sent task-detail Chat steering messages as You bubbles and keep them visible after steering requests persist.
|
||||
- 35554e6: Keep the task-detail Chat composer pinned and visible while the transcript scrolls internally on mobile and desktop.
|
||||
- e0ec3d1: Steering messages sent from task chat now reach active step-session and workflow runs, including parallel step sessions, and the misleading inactive-session "next session" composer copy was removed.
|
||||
- f68775a: Ensure only explicit user actions unpause user-paused tasks. Engine self-healing, agent resume cascades, dashboard agent-state resume fallback, heartbeat recovery, and approval-decision resume no longer clear `userPaused` or auto-unpause tasks the user paused.
|
||||
- 4ea9d66: Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults.
|
||||
- 44b756d: Fix built-in branching workflow selection so interpreter-deferred coding workflows can be selected or used as project defaults without throwing during legacy step materialization.
|
||||
- e6eef1a: Handle insight extraction agent responses deterministically by accepting prompt return text, falling back to session state, and surfacing a 503 error when no assistant text is produced.
|
||||
- e305b1a: Respect per-task pause state during triage planning so paused tasks do not auto-advance after specification approval.
|
||||
- 40cb0d3: Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations.
|
||||
- f16b038: Add workflow work-item storage primitives for workflow-owned merge migration.
|
||||
|
||||
## 0.41.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@runfusion/fusion",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.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",
|
||||
@@ -51,6 +51,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot",
|
||||
"test:ci-shape": "vitest run src/__tests__/ci-workflow.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:docs-index": "vitest run src/__tests__/docs-readme-index.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot",
|
||||
|
||||
@@ -47,6 +47,7 @@ const commandMocks = vi.hoisted(() => ({
|
||||
runPrMerge: vi.fn(),
|
||||
runPrClose: vi.fn(),
|
||||
runPrAutomerge: vi.fn(),
|
||||
runPrAutomergeCleanup: vi.fn(),
|
||||
|
||||
runSettingsShow: vi.fn(),
|
||||
runSettingsSet: vi.fn(),
|
||||
@@ -194,6 +195,7 @@ vi.mock("../commands/pr.js", () => ({
|
||||
runPrMerge: commandMocks.runPrMerge,
|
||||
runPrClose: commandMocks.runPrClose,
|
||||
runPrAutomerge: commandMocks.runPrAutomerge,
|
||||
runPrAutomergeCleanup: commandMocks.runPrAutomergeCleanup,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/settings.js", () => ({
|
||||
@@ -923,6 +925,14 @@ describe("bin command routing and fallbacks", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Try: fn pr create <task-id>"));
|
||||
});
|
||||
|
||||
it("routes pr automerge-cleanup flags", async () => {
|
||||
await runBin(["pr", "automerge-cleanup", "--apply", "--json", "--project", "ops"]);
|
||||
expect(commandMocks.runPrAutomergeCleanup).toHaveBeenCalledWith(
|
||||
{ apply: true, json: true },
|
||||
"ops",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes task delete with allow-resurrection flag", async () => {
|
||||
await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]);
|
||||
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined);
|
||||
|
||||
@@ -3,6 +3,21 @@ import { writeFile, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const previewPlan = vi.fn();
|
||||
const finalize = vi.fn();
|
||||
const init = vi.fn();
|
||||
@@ -18,12 +33,12 @@ const mockErrors = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn(() => ({ init, getExperimentSessionStore })),
|
||||
TaskStore: makeConstructibleMock(() => ({ init, getExperimentSessionStore })),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
defaultGitOps: vi.fn(() => ({})),
|
||||
ExperimentFinalizeService: vi.fn(() => ({ previewPlan, finalize })),
|
||||
ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan, finalize })),
|
||||
ExperimentFinalizeStateError: class extends Error { code = "state_error" as const; },
|
||||
ExperimentFinalizeNoKeptRunsError: class extends Error { code = "no_kept_runs" as const; },
|
||||
ExperimentFinalizePlanError: class extends Error { code = "plan_error" as const; },
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const previewPlanMock = vi.hoisted(() => vi.fn());
|
||||
const finalizeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -18,7 +33,7 @@ const mockErrors = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getExperimentSessionStore: vi.fn(() => ({})),
|
||||
})),
|
||||
@@ -43,7 +58,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
fetchWebContent: vi.fn(),
|
||||
defaultGitOps: vi.fn(() => ({})),
|
||||
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
|
||||
ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
|
||||
ExperimentFinalizeStateError: mockErrors.StateError,
|
||||
ExperimentFinalizeNoKeptRunsError: mockErrors.NoKeptError,
|
||||
ExperimentFinalizePlanError: mockErrors.PlanError,
|
||||
|
||||
@@ -93,6 +93,26 @@ describe("CLI package.json publishing config", () => {
|
||||
expect(deps).toContain("ioredis");
|
||||
});
|
||||
|
||||
it("defines test:docs-index as a single-file docs README index lane", () => {
|
||||
const script = pkg.scripts?.["test:docs-index"];
|
||||
const parts = script?.trim().split(/\s+/) ?? [];
|
||||
const docsIndexPath = "src/__tests__/docs-readme-index.test.ts";
|
||||
|
||||
expect(script).toBeDefined();
|
||||
expect(script).toContain("vitest run");
|
||||
expect(parts).toEqual([
|
||||
"vitest",
|
||||
"run",
|
||||
docsIndexPath,
|
||||
"--silent=passed-only",
|
||||
"--reporter=dot",
|
||||
]);
|
||||
expect(parts.filter((part) => part.endsWith(".test.ts"))).toEqual([docsIndexPath]);
|
||||
expect(parts).not.toContain("--");
|
||||
expect(script).not.toMatch(/vitest\s+run\s+(?:--silent=passed-only\s+)?(?:--reporter=dot\s+)?$/);
|
||||
expect(script).not.toContain("docs-readme-index ");
|
||||
});
|
||||
|
||||
it("prepack manifest rewrite strips workspace-only plugin/tooling devDependencies", () => {
|
||||
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-claude-cli"]');
|
||||
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-llama-cpp"]');
|
||||
@@ -276,17 +296,18 @@ describe("Workspace bootstrap script contract", () => {
|
||||
const defaultTest = dashboardPkg.scripts?.test;
|
||||
const defaultAppQuality = dashboardPkg.scripts?.["test:quality:app"];
|
||||
const defaultApiQuality = dashboardPkg.scripts?.["test:quality:api"];
|
||||
const appSettings = dashboardPkg.scripts?.["test:quality:app:settings"];
|
||||
const apiCurated = dashboardPkg.scripts?.["test:quality:api:curated"];
|
||||
const deepTest = dashboardPkg.scripts?.["test:deep"];
|
||||
|
||||
expect(defaultTest).toBe("pnpm run test:quality:app && pnpm run test:quality:api");
|
||||
expect(defaultAppQuality).toContain("test:quality:app:foundation-api");
|
||||
expect(defaultAppQuality).toContain("test:quality:app:settings");
|
||||
// The api lane chains curated + backfill sub-lanes; the curated sub-lane
|
||||
// carries the explicit quality project, and the backfill lane is the
|
||||
// curated-gate completeness net (broad glob minus curated minus skip-list).
|
||||
expect(defaultApiQuality).toContain("test:quality:api:curated");
|
||||
expect(defaultApiQuality).toContain("test:quality:api:backfill");
|
||||
expect(defaultTest).toBe("node scripts/run-quality-tests.mjs");
|
||||
expect(defaultAppQuality).toBe("node scripts/run-quality-tests.mjs --group app");
|
||||
expect(defaultApiQuality).toBe("node scripts/run-quality-tests.mjs --group api");
|
||||
expect(defaultAppQuality).toContain("--group app");
|
||||
expect(appSettings).toContain("dashboard-app-quality-settings");
|
||||
// The default quality runner dispatches grouped quality lanes by script
|
||||
// name; the curated API sub-lane still carries the explicit quality project.
|
||||
expect(defaultApiQuality).toContain("--group api");
|
||||
expect(hasProjectArg(apiCurated, "dashboard-api-quality")).toBe(true);
|
||||
expect(hasProjectArg(defaultTest, "dashboard-app")).toBe(false);
|
||||
expect(hasProjectArg(defaultTest, "dashboard-api")).toBe(false);
|
||||
|
||||
@@ -3,6 +3,21 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const pluginCommandMocks = vi.hoisted(() => {
|
||||
const store = {
|
||||
registerPlugin: vi.fn(async () => ({ id: "fusion-plugin-dev-test", enabled: true })),
|
||||
@@ -16,8 +31,8 @@ const pluginCommandMocks = vi.hoisted(() => {
|
||||
return {
|
||||
store,
|
||||
loader,
|
||||
createPluginStore: vi.fn(async () => store),
|
||||
createPluginLoader: vi.fn(async () => ({ store, loader })),
|
||||
createPluginStore: makeConstructibleMock(async () => store),
|
||||
createPluginLoader: makeConstructibleMock(async () => ({ store, loader })),
|
||||
resolvePluginEntryFile: vi.fn(async (dir: string) => join(dir, "dist", "index.js")),
|
||||
loadManifestFromPath: vi.fn(async () => ({
|
||||
manifest: {
|
||||
|
||||
@@ -8,6 +8,13 @@ import { runPluginCreate, runPluginNew } from "../commands/plugin-scaffold.js";
|
||||
|
||||
describe("plugin-scaffold", () => {
|
||||
const tmpBase = join(tmpdir(), `fn-scaffold-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const standaloneDevDependencyKeys = [
|
||||
"@runfusion/fusion",
|
||||
"@types/node",
|
||||
"typescript",
|
||||
"vitest",
|
||||
];
|
||||
const caretRangePattern = /^\^\d+\.\d+\.\d+$/;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(tmpBase, { recursive: true });
|
||||
@@ -64,6 +71,7 @@ describe("plugin-scaffold", () => {
|
||||
private?: boolean;
|
||||
keywords: string[];
|
||||
exports: { ".": { types: string; import: string } };
|
||||
scripts: { build: string; test: string };
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -72,8 +80,10 @@ describe("plugin-scaffold", () => {
|
||||
expect(packageJson.private).toBeUndefined();
|
||||
expect(packageJson.exports["."].types).toBe("./dist/index.d.ts");
|
||||
expect(packageJson.exports["."].import).toBe("./dist/index.js");
|
||||
expect(Object.keys(packageJson.devDependencies)).toEqual(["@runfusion/fusion"]);
|
||||
expect(packageJson.devDependencies["@runfusion/fusion"]).toMatch(/^\^\d+\.\d+\.\d+$/);
|
||||
expect(Object.keys(packageJson.devDependencies)).toEqual(standaloneDevDependencyKeys);
|
||||
for (const dependencyName of standaloneDevDependencyKeys) {
|
||||
expect(packageJson.devDependencies[dependencyName]).toMatch(caretRangePattern);
|
||||
}
|
||||
|
||||
const packageContents = readFileSync(join(outputDir, "package.json"), "utf-8");
|
||||
const indexContents = readFileSync(join(outputDir, "src/index.ts"), "utf-8");
|
||||
@@ -87,8 +97,16 @@ describe("plugin-scaffold", () => {
|
||||
|
||||
const tsconfig = JSON.parse(readFileSync(join(outputDir, "tsconfig.json"), "utf-8")) as {
|
||||
extends?: string;
|
||||
compilerOptions: { types?: string[] };
|
||||
};
|
||||
expect(tsconfig.extends).toBeUndefined();
|
||||
for (const typeName of tsconfig.compilerOptions.types ?? []) {
|
||||
expect(packageJson.devDependencies[`@types/${typeName}`]).toBeDefined();
|
||||
}
|
||||
expect(packageJson.scripts.test.split(/\s+/)[0]).toBe("vitest");
|
||||
expect(packageJson.devDependencies.vitest).toBeDefined();
|
||||
expect(packageJson.scripts.build.split(/\s+/)[0]).toBe("tsc");
|
||||
expect(packageJson.devDependencies.typescript).toBeDefined();
|
||||
});
|
||||
|
||||
it("supports scoped package names", async () => {
|
||||
@@ -96,8 +114,10 @@ describe("plugin-scaffold", () => {
|
||||
await runPluginNew("scoped-plugin", { output: outputDir, scope: "acme" });
|
||||
const packageJson = JSON.parse(readFileSync(join(outputDir, "package.json"), "utf-8")) as {
|
||||
name: string;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.name).toBe("@acme/fusion-plugin-scoped-plugin");
|
||||
expect(Object.keys(packageJson.devDependencies)).toEqual(standaloneDevDependencyKeys);
|
||||
});
|
||||
|
||||
it("rejects invalid plugin names", async () => {
|
||||
|
||||
107
packages/cli/src/__tests__/pr-automerge-cleanup.test.ts
Normal file
107
packages/cli/src/__tests__/pr-automerge-cleanup.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
releaseHeldTaskByEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
GitHubClient: class {},
|
||||
generatePrMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core/gh-cli", () => ({
|
||||
classifyGhError: vi.fn(() => ({ message: "err" })),
|
||||
getGhErrorMessage: vi.fn(() => "err"),
|
||||
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
|
||||
isGhAuthenticated: vi.fn(() => true),
|
||||
isGhAvailable: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
const { resolveProject } = await import("../project-context.js");
|
||||
const { runPrAutomergeCleanup } = await import("../commands/pr.js");
|
||||
|
||||
function mockStore(results: Array<{ taskId: string; column: string; cleared: boolean }>) {
|
||||
const reconcileLegacyAutoMergeStamps = vi.fn().mockResolvedValue(results);
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
store: { reconcileLegacyAutoMergeStamps } as never,
|
||||
projectPath: "/tmp/project",
|
||||
projectName: "proj",
|
||||
} as never);
|
||||
return { reconcileLegacyAutoMergeStamps };
|
||||
}
|
||||
|
||||
describe("fn pr automerge-cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("dry-runs by default and lists store-provided candidates", async () => {
|
||||
const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]);
|
||||
|
||||
await runPrAutomergeCleanup();
|
||||
|
||||
expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith();
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("candidate"));
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("FN-101"));
|
||||
});
|
||||
|
||||
it("passes apply only when --apply is requested", async () => {
|
||||
const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: true }]);
|
||||
|
||||
await runPrAutomergeCleanup({ apply: true });
|
||||
|
||||
expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true });
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Cleared 1 legacy auto-merge stamp"));
|
||||
});
|
||||
|
||||
it("prints well-formed JSON for non-empty dry-run results", async () => {
|
||||
mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]);
|
||||
|
||||
await runPrAutomergeCleanup({ json: true });
|
||||
|
||||
const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as {
|
||||
mode: string;
|
||||
count: number;
|
||||
candidates: Array<{ taskId: string; column: string; cleared: boolean }>;
|
||||
};
|
||||
expect(payload).toEqual({
|
||||
mode: "dry-run",
|
||||
count: 1,
|
||||
candidates: [{ taskId: "FN-101", column: "in-review", cleared: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("prints well-formed JSON for empty apply results", async () => {
|
||||
const store = mockStore([]);
|
||||
|
||||
await runPrAutomergeCleanup({ apply: true, json: true });
|
||||
|
||||
expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true });
|
||||
const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as {
|
||||
mode: string;
|
||||
count: number;
|
||||
cleared: unknown[];
|
||||
};
|
||||
expect(payload).toEqual({ mode: "apply", count: 0, cleared: [] });
|
||||
});
|
||||
|
||||
it("zero candidates is a successful no-op message", async () => {
|
||||
const store = mockStore([]);
|
||||
|
||||
await runPrAutomergeCleanup();
|
||||
|
||||
expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith();
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("No legacy auto-merge stamps to clean up"));
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||
}));
|
||||
@@ -41,7 +56,7 @@ vi.mock("@fusion/core", async () => {
|
||||
},
|
||||
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
|
||||
mockIsValidSqliteDatabaseFile(...args),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Mock node:readline/promises before importing
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(),
|
||||
@@ -8,7 +23,7 @@ vi.mock("node:readline/promises", () => ({
|
||||
// Mock @fusion/core before importing
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||
TaskStore: vi.fn(),
|
||||
TaskStore: makeConstructibleMock(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
triage: "Triage",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Mock node:readline/promises before importing
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(),
|
||||
@@ -8,7 +23,7 @@ vi.mock("node:readline/promises", () => ({
|
||||
// Mock @fusion/core before importing
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||
TaskStore: vi.fn(),
|
||||
TaskStore: makeConstructibleMock(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
triage: "Triage",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const CLI_PACKAGE_VERSION = (
|
||||
JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as { version: string }
|
||||
).version;
|
||||
@@ -16,7 +31,7 @@ const { cacheDir, mockResolveGlobalDir } = vi.hoisted(() => {
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
resolveGlobalDir: mockResolveGlobalDir,
|
||||
GlobalSettingsStore: vi.fn(),
|
||||
GlobalSettingsStore: makeConstructibleMock(),
|
||||
}));
|
||||
|
||||
const { getCachedUpdateStatus } = await import("../update-cache.js");
|
||||
|
||||
@@ -120,7 +120,7 @@ async function loadCommandHandlers() {
|
||||
const { runDaemon } = await import("./commands/daemon.js");
|
||||
const { runDesktop } = await import("./commands/desktop.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js");
|
||||
const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge } = await import("./commands/pr.js");
|
||||
const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge, runPrAutomergeCleanup } = await import("./commands/pr.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
@@ -186,6 +186,7 @@ async function loadCommandHandlers() {
|
||||
runPrMerge,
|
||||
runPrClose,
|
||||
runPrAutomerge,
|
||||
runPrAutomergeCleanup,
|
||||
runSettingsShow,
|
||||
runSettingsSet,
|
||||
runSettingsExport,
|
||||
@@ -331,6 +332,8 @@ PR:
|
||||
fn pr merge <pr-id> Force-merge the PR via its merge release
|
||||
fn pr close <pr-id> Close the PR terminally
|
||||
fn pr automerge <pr-id> [on|off] Toggle auto-merge for the PR
|
||||
fn pr automerge-cleanup [--apply] [--json]
|
||||
Dry-run or apply legacy auto-merge stamp cleanup
|
||||
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
|
||||
Create and optionally wait for a cited-research run (search/fetch/synthesis)
|
||||
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
||||
@@ -667,6 +670,7 @@ async function main() {
|
||||
runPrMerge,
|
||||
runPrClose,
|
||||
runPrAutomerge,
|
||||
runPrAutomergeCleanup,
|
||||
runSettingsShow,
|
||||
runSettingsSet,
|
||||
runSettingsExport,
|
||||
@@ -901,9 +905,15 @@ async function main() {
|
||||
await runPrAutomerge(args[2], enabled, projectName);
|
||||
break;
|
||||
}
|
||||
case "automerge-cleanup":
|
||||
await runPrAutomergeCleanup({
|
||||
apply: args.includes("--apply"),
|
||||
json: args.includes("--json"),
|
||||
}, projectName);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown subcommand: pr ${subcommand || ""}`);
|
||||
console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off]");
|
||||
console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off] | automerge-cleanup [--apply] [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Mock AgentStore ──────────────────────────────────────────────────
|
||||
|
||||
const mockGetAgent = vi.fn();
|
||||
@@ -9,7 +24,7 @@ const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
// AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest.
|
||||
// We return a plain object from the constructor which becomes the instance.
|
||||
vi.mock("@fusion/core", () => ({
|
||||
AgentStore: vi.fn().mockImplementation(() => ({
|
||||
AgentStore: makeConstructibleMock(() => ({
|
||||
init: mockInit,
|
||||
getAgent: mockGetAgent,
|
||||
updateAgentState: mockUpdateAgentState,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const {
|
||||
mockListBackups,
|
||||
mockListBackupPairs,
|
||||
@@ -20,7 +35,7 @@ const {
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
BackupManager: vi.fn(),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: mockGetSettings,
|
||||
fusionDir: "/cwd/.fusion",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Hoist mocks so they are evaluated before module imports
|
||||
const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({
|
||||
mockGetDatabase: vi.fn(),
|
||||
@@ -8,7 +23,7 @@ const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn(),
|
||||
getDatabase: mockGetDatabase,
|
||||
})),
|
||||
|
||||
@@ -122,7 +122,9 @@ const mocks = vi.hoisted(() => {
|
||||
server,
|
||||
app,
|
||||
spawn,
|
||||
taskStoreCtor: vi.fn(() => store),
|
||||
taskStoreCtor: vi.fn(function () {
|
||||
return store;
|
||||
}),
|
||||
createServer: vi.fn(() => app),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -11,6 +11,21 @@ import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { GitRepositoryInitializationError } from "@fusion/core";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const mockCentralInit = vi.fn();
|
||||
@@ -27,7 +42,7 @@ vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
CentralCore: makeConstructibleMock(() => ({
|
||||
init: mockCentralInit,
|
||||
close: mockCentralClose,
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const {
|
||||
mockListBackups,
|
||||
mockRestoreBackup,
|
||||
@@ -15,7 +30,7 @@ const {
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: mockGetSettings,
|
||||
fusionDir: "/cwd/.fusion",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Mock MessageStore ────────────────────────────────────────────────
|
||||
|
||||
const mockGetInbox = vi.fn();
|
||||
@@ -17,7 +32,7 @@ vi.mock("@fusion/core", () => {
|
||||
};
|
||||
return {
|
||||
createDatabase: vi.fn().mockReturnValue(mockDb),
|
||||
MessageStore: vi.fn().mockImplementation(() => ({
|
||||
MessageStore: makeConstructibleMock(() => ({
|
||||
getInbox: mockGetInbox,
|
||||
getOutbox: mockGetOutbox,
|
||||
getMailbox: mockGetMailbox,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockListNodes = vi.fn();
|
||||
@@ -13,7 +28,7 @@ const mockQuestion = vi.fn();
|
||||
const mockRlClose = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
CentralCore: makeConstructibleMock(() => ({
|
||||
init: mockInit,
|
||||
close: mockClose,
|
||||
listNodes: mockListNodes,
|
||||
|
||||
@@ -3,6 +3,21 @@ import { dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const pluginStoreInstances: Array<{
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
@@ -15,9 +30,9 @@ const mocks = vi.hoisted(() => {
|
||||
let loaderTaskStore: { getRootDir?: () => string } | undefined;
|
||||
let loaderRootDir: string | undefined;
|
||||
|
||||
const PluginStore = vi.fn();
|
||||
const PluginStore = makeConstructibleMock();
|
||||
|
||||
const PluginLoader = vi.fn();
|
||||
const PluginLoader = makeConstructibleMock();
|
||||
|
||||
const setupDefaults = () => {
|
||||
PluginStore.mockImplementation(() => {
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mockListProjects = vi.fn();
|
||||
const mockRegisterProject = vi.fn();
|
||||
const mockEnsureProjectForPath = vi.fn(async (...args: unknown[]) => ({
|
||||
@@ -29,7 +44,7 @@ const mockEnsureMemoryFileWithBackend = vi.fn();
|
||||
|
||||
// Mock @fusion/core
|
||||
vi.mock("@fusion/core", () => ({
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
CentralCore: makeConstructibleMock(() => ({
|
||||
init: mockInit.mockResolvedValue(undefined),
|
||||
close: mockClose.mockResolvedValue(undefined),
|
||||
listProjects: mockListProjects,
|
||||
@@ -41,11 +56,11 @@ vi.mock("@fusion/core", () => ({
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
getProjectHealth: mockGetProjectHealth,
|
||||
})),
|
||||
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
||||
GlobalSettingsStore: makeConstructibleMock(() => ({
|
||||
init: mockGlobalInit.mockResolvedValue(undefined),
|
||||
getSettings: mockGetSettings,
|
||||
})),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: mockTaskStoreInit,
|
||||
listTasks: mockTaskStoreListTasks,
|
||||
})),
|
||||
|
||||
@@ -4,6 +4,21 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }),
|
||||
@@ -590,7 +605,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
storeToken: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
GlobalSettingsStore: vi.fn().mockImplementation(function () {
|
||||
GlobalSettingsStore: makeConstructibleMock(function () {
|
||||
return {};
|
||||
}),
|
||||
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
|
||||
|
||||
@@ -4,6 +4,21 @@ import { join, resolve } from "node:path";
|
||||
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
|
||||
import { resolveProject } from "../../project-context.js";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
@@ -11,7 +26,7 @@ vi.mock("node:fs/promises", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: mockStoreInit,
|
||||
})),
|
||||
exportSettings: vi.fn(),
|
||||
|
||||
@@ -3,6 +3,21 @@ import { existsSync } from "node:fs";
|
||||
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
|
||||
import { resolveProject } from "../../project-context.js";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
@@ -10,7 +25,7 @@ vi.mock("node:fs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: mockStoreInit,
|
||||
})),
|
||||
importSettings: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
const DEFAULT_SETTINGS = {
|
||||
maxConcurrent: 2,
|
||||
@@ -23,7 +38,7 @@ vi.mock("@fusion/core", () => {
|
||||
};
|
||||
|
||||
return {
|
||||
GlobalSettingsStore: vi.fn(),
|
||||
GlobalSettingsStore: makeConstructibleMock(),
|
||||
DEFAULT_SETTINGS,
|
||||
SUPPORTED_LOCALES: ["en", "zh-CN", "zh-TW", "fr", "es", "ko"],
|
||||
resolveWorktrunkSettings: (globalValue: any, projectValue: any) => ({
|
||||
|
||||
@@ -2460,6 +2460,7 @@ describe("runTaskRetry", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
completionHandoffLimboRecoveryCount: 0,
|
||||
graphResumeRetryCount: 0,
|
||||
mergeAuditBounceCount: 0,
|
||||
mergeRetries: 0,
|
||||
resumeLimboCount: 0,
|
||||
@@ -2534,6 +2535,7 @@ describe("runTaskRetry", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
completionHandoffLimboRecoveryCount: 0,
|
||||
graphResumeRetryCount: 0,
|
||||
mergeAuditBounceCount: 0,
|
||||
mergeRetries: 0,
|
||||
resumeLimboCount: 0,
|
||||
|
||||
@@ -12,6 +12,9 @@ import { fileURLToPath } from "node:url";
|
||||
// Valid plugin name pattern: kebab-case
|
||||
const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const DEFAULT_RUNFUSION_VERSION = "0.39.0";
|
||||
const SCAFFOLD_TYPES_NODE_VERSION = "^22.0.0";
|
||||
const SCAFFOLD_VITEST_VERSION = "^4.1.0";
|
||||
const SCAFFOLD_TYPESCRIPT_VERSION = "^5.7.0";
|
||||
|
||||
/**
|
||||
* Convert a kebab-case string to Title Case
|
||||
@@ -167,6 +170,9 @@ function generateStandalonePackageJson(name: string, scope?: string): string {
|
||||
},
|
||||
devDependencies: {
|
||||
"@runfusion/fusion": resolveFusionCaretVersion(),
|
||||
"@types/node": SCAFFOLD_TYPES_NODE_VERSION,
|
||||
typescript: SCAFFOLD_TYPESCRIPT_VERSION,
|
||||
vitest: SCAFFOLD_VITEST_VERSION,
|
||||
},
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -374,3 +374,44 @@ export async function runPrAutomerge(id: string, enabled: boolean | undefined, p
|
||||
const updated = store.updatePrEntity(id, { autoMerge: next });
|
||||
console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`);
|
||||
}
|
||||
|
||||
export interface PrAutomergeCleanupOptions {
|
||||
apply?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPrAutomergeCleanup(options: PrAutomergeCleanupOptions = {}, projectName?: string) {
|
||||
const { store } = await getPrContext(projectName);
|
||||
const results = options.apply
|
||||
? await store.reconcileLegacyAutoMergeStamps({ apply: true })
|
||||
: await store.reconcileLegacyAutoMergeStamps();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
mode: options.apply ? "apply" : "dry-run",
|
||||
count: results.length,
|
||||
candidates: options.apply ? undefined : results,
|
||||
cleared: options.apply ? results : undefined,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log("\n ✓ No legacy auto-merge stamps to clean up.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.apply) {
|
||||
console.log(`\n ✓ Cleared ${results.length} legacy auto-merge stamp${results.length === 1 ? "" : "s"}:`);
|
||||
} else {
|
||||
console.log(`\n Legacy auto-merge stamp candidate${results.length === 1 ? "" : "s"} (${results.length}):`);
|
||||
}
|
||||
for (const result of results) {
|
||||
console.log(` - ${result.taskId} (${result.column})`);
|
||||
}
|
||||
if (!options.apply) {
|
||||
console.log("\n Re-run with --apply to clear these legacy non-override stamps. Genuine per-task overrides are preserved.\n");
|
||||
} else {
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @fusion/core
|
||||
|
||||
## 0.42.0
|
||||
|
||||
## 0.41.0
|
||||
|
||||
## 0.40.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fusion/core",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.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",
|
||||
|
||||
@@ -6,15 +6,62 @@
|
||||
* the run-local worker/home directories as leaks.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner";
|
||||
|
||||
let workerRootRmSync = rmSync;
|
||||
let workerRootSleepMsSync = sleepMsSync;
|
||||
|
||||
export function __setWorkerRootRmSyncForTests(nextRmSync: typeof rmSync): void {
|
||||
workerRootRmSync = typeof nextRmSync === "function" ? nextRmSync : rmSync;
|
||||
}
|
||||
|
||||
export function __setWorkerRootSleepMsSyncForTests(nextSleep: (ms: number) => void): void {
|
||||
workerRootSleepMsSync = typeof nextSleep === "function" ? nextSleep : sleepMsSync;
|
||||
}
|
||||
|
||||
function sleepMsSync(ms: number): void {
|
||||
if (ms <= 0) return;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function isEnoent(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||
}
|
||||
|
||||
export function removeWorkerRootWithRetry(workerRoot: string, retries = 3, delayMs = 75): void {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
workerRootRmSync(workerRoot, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return;
|
||||
lastError = error;
|
||||
if (attempt < retries) {
|
||||
workerRootSleepMsSync(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
console.warn(`[vitest-teardown] failed to remove worker root ${workerRoot} after ${retries} attempts: ${message}`);
|
||||
}
|
||||
|
||||
export default function setup(): () => Promise<void> {
|
||||
// Use a fresh root for each Vitest invocation. A static shared root makes the
|
||||
// setup-time redirect sweep proportional to stale directories left by every
|
||||
// prior interrupted run.
|
||||
const workerRoot = resolve(mkdtempSync(join(tmpdir(), "fusion-test-workers-")));
|
||||
try {
|
||||
writeFileSync(join(workerRoot, WORKER_ROOT_OWNER_FILE), `${process.pid}\n`);
|
||||
} catch {
|
||||
// Best effort only. The marker protects active roots from external orphan
|
||||
// pruning; teardown still owns this root by absolute path.
|
||||
}
|
||||
process.env.FUSION_TEST_WORKER_ROOT = workerRoot;
|
||||
|
||||
return async function teardown() {
|
||||
@@ -23,12 +70,9 @@ export default function setup(): () => Promise<void> {
|
||||
} catch {
|
||||
// Ignore — cleanup below is best-effort and uses an absolute path.
|
||||
}
|
||||
try {
|
||||
rmSync(workerRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore — interrupted or still-active workers may leave a per-run root
|
||||
// behind, but future runs no longer sweep it because every invocation gets
|
||||
// a fresh root.
|
||||
}
|
||||
// FN-6360: macOS can report transient EBUSY/ENOTEMPTY while SQLite WALs or
|
||||
// redirected temp dirs are still closing. Retry boundedly so a brief busy-fd
|
||||
// race does not leak the per-invocation fusion-test-workers-* root.
|
||||
removeWorkerRootWithRetry(workerRoot);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ describe("ArchiveDatabase FTS maintenance", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = "alpha ".repeat(1200);
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const payload = "alpha ".repeat(400);
|
||||
for (let i = 0; i < 72; i++) {
|
||||
archive.upsert(makeEntry("FN-ARCHIVE-1", {
|
||||
archivedAt: new Date(1717372800000 + i * 1000).toISOString(),
|
||||
updatedAt: new Date(1717372800000 + i * 1000).toISOString(),
|
||||
@@ -81,7 +81,7 @@ describe("ArchiveDatabase FTS maintenance", () => {
|
||||
expect(rebuiltBytes).not.toBeNull();
|
||||
expect(rebuiltBytes!).toBeLessThan(grownBytes!);
|
||||
expect(rebuiltBytes!).toBeLessThan(1 * 1024 * 1024);
|
||||
expect(archive.search("release-note-179", 10).map((entry) => entry.id)).toContain("FN-ARCHIVE-1");
|
||||
expect(archive.search("release-note-71", 10).map((entry) => entry.id)).toContain("FN-ARCHIVE-1");
|
||||
} finally {
|
||||
archive.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
@@ -380,10 +380,24 @@ describe("built-in workflows", () => {
|
||||
expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
|
||||
const reservedCodingTask = await store.createTaskWithReservedId(
|
||||
{ description: "reserved default builtin coding" },
|
||||
{ taskId: "reserved-default-builtin-coding" },
|
||||
);
|
||||
expect((await store.getTask(reservedCodingTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] });
|
||||
|
||||
await store.setDefaultWorkflowId("builtin:stepwise-coding");
|
||||
const stepwiseTask = await store.createTask({ description: "default builtin stepwise" });
|
||||
expect((await store.getTask(stepwiseTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(stepwiseTask.id)).toBeUndefined();
|
||||
|
||||
const reservedStepwiseTask = await store.createTaskWithReservedId(
|
||||
{ description: "reserved default builtin stepwise" },
|
||||
{ taskId: "reserved-default-builtin-stepwise" },
|
||||
);
|
||||
expect((await store.getTask(reservedStepwiseTask.id)).enabledWorkflowSteps ?? []).toEqual([]);
|
||||
expect(store.getTaskWorkflowSelection(reservedStepwiseTask.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects selecting the PR lifecycle fragment for a task", async () => {
|
||||
|
||||
@@ -715,7 +715,7 @@ describe("schema migration", () => {
|
||||
|
||||
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -748,7 +748,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -798,7 +798,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -827,7 +827,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -868,7 +868,7 @@ describe("schema migration", () => {
|
||||
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
|
||||
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -902,7 +902,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -939,7 +939,7 @@ describe("schema migration", () => {
|
||||
|
||||
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1000,7 +1000,7 @@ describe("schema migration", () => {
|
||||
expect(customFieldsColumn).toBeDefined();
|
||||
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1038,7 +1038,7 @@ describe("schema migration", () => {
|
||||
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1120,7 +1120,7 @@ describe("schema migration", () => {
|
||||
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
|
||||
expect(indexNames).toContain("idx_cli_sessions_project_state");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1152,7 +1152,7 @@ describe("schema migration", () => {
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1162,7 +1162,7 @@ describe("schema migration", () => {
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toContain("cli_sessions");
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1219,20 +1219,20 @@ describe("schema migration", () => {
|
||||
.get() as { migrated_fragment_id: string | null };
|
||||
expect(stepRow.migrated_fragment_id).toBeNull();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migration 109 is idempotent on re-init", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
db.close();
|
||||
|
||||
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
|
||||
const reopened = new Database(fusionDir);
|
||||
reopened.init();
|
||||
expect(reopened.getSchemaVersion()).toBe(116);
|
||||
expect(reopened.getSchemaVersion()).toBe(117);
|
||||
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
|
||||
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
|
||||
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -393,7 +393,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1463,7 +1463,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1488,15 +1488,15 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1572,7 +1572,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1644,7 +1644,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1884,7 +1884,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1958,7 +1958,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1982,7 +1982,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -2086,7 +2086,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2305,7 +2305,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(116);
|
||||
expect(localDb.getSchemaVersion()).toBe(117);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(116);
|
||||
expect(migrated.getSchemaVersion()).toBe(117);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(116);
|
||||
expect(fresh.getSchemaVersion()).toBe(117);
|
||||
const names = new Set(
|
||||
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(116);
|
||||
expect(migrated.getSchemaVersion()).toBe(117);
|
||||
const names = new Set(
|
||||
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(116);
|
||||
expect(fresh.getSchemaVersion()).toBe(117);
|
||||
const table = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(116);
|
||||
expect(migrated.getSchemaVersion()).toBe(117);
|
||||
const table = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(116);
|
||||
expect(migrated.getSchemaVersion()).toBe(117);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(116);
|
||||
expect(fresh.getSchemaVersion()).toBe(117);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -3216,9 +3216,10 @@ describe("Database.recoverIfCorrupt startup guard", () => {
|
||||
const dbPath = join(fusionDir, "fusion.db");
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
// Span many pages so mid-file corruption lands on a B-tree page.
|
||||
// Span enough pages so mid-file corruption lands on a B-tree page
|
||||
// without overfeeding sqlite3 .recover.
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
db.prepare("INSERT INTO activityLog (id, timestamp, type, details) VALUES (?, ?, 'test', '{}')").run(
|
||||
`row-${i}`,
|
||||
new Date().toISOString(),
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(116);
|
||||
expect(db1.getSchemaVersion()).toBe(117);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(116);
|
||||
expect(db3.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(116);
|
||||
expect(db1.getSchemaVersion()).toBe(117);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(116);
|
||||
expect(db2.getSchemaVersion()).toBe(117);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(116);
|
||||
expect(db1.getSchemaVersion()).toBe(117);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user