diff --git a/.changeset/fair-lizards-jump.md b/.changeset/fair-lizards-jump.md new file mode 100644 index 0000000000..fcbc80115c --- /dev/null +++ b/.changeset/fair-lizards-jump.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Recover failed Planning Mode session loads into the existing retryable error view instead of dropping back to the empty planner. Failed or malformed persisted planning sessions now keep their session id so Retry/Dismiss recovery remains available, while deleted sessions still quietly fall back to a new session. diff --git a/.changeset/fn-5897-linked-goals-read-paths.md b/.changeset/fn-5897-linked-goals-read-paths.md new file mode 100644 index 0000000000..ba3976b90f --- /dev/null +++ b/.changeset/fn-5897-linked-goals-read-paths.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Surface mission-linked goals across mission read paths, including `fn_mission_show`, mission detail API payloads, and dashboard mission detail navigation into anchored goal cards. diff --git a/.changeset/fn-5898-mission-goal-unlinked-indicator.md b/.changeset/fn-5898-mission-goal-unlinked-indicator.md new file mode 100644 index 0000000000..5e8361fe3d --- /dev/null +++ b/.changeset/fn-5898-mission-goal-unlinked-indicator.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Document mission-to-goal linkage behavior, including the explicit no-backfill decision for existing missions, and surface an Unlinked badge for active missions without linked goals in Mission Manager. diff --git a/.changeset/fn-5902-mission-validation-ai-run.md b/.changeset/fn-5902-mission-validation-ai-run.md new file mode 100644 index 0000000000..c56b14adbd --- /dev/null +++ b/.changeset/fn-5902-mission-validation-ai-run.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Mission validation now AI-validates all mission criteria by lazily ensuring a per-feature managed assertion at runtime and removing the zero-assertion auto-pass path. Milestone acceptance criteria are threaded into validator prompts, and the dashboard now presents mission criteria as AI-validated instead of informational-only. \ No newline at end of file diff --git a/.changeset/fn-5907-planning-create-fetch.md b/.changeset/fn-5907-planning-create-fetch.md new file mode 100644 index 0000000000..03a08faecd --- /dev/null +++ b/.changeset/fn-5907-planning-create-fetch.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix a Planning Mode reliability bug where creating a single task could fail with a browser-level `Failed to fetch` error when post-create side effects threw or rejected before the dashboard finished responding. diff --git a/.changeset/fn-5909-merge-install-script.md b/.changeset/fn-5909-merge-install-script.md new file mode 100644 index 0000000000..ee3e282489 --- /dev/null +++ b/.changeset/fn-5909-merge-install-script.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Run the configured `worktreeInitCommand` on merge worktrees before AI merge verification across warm and cold integration modes, so merge verification uses the same project-specific bootstrap as executor worktrees. diff --git a/.changeset/fn-5911-agent-log-jsonl.md b/.changeset/fn-5911-agent-log-jsonl.md new file mode 100644 index 0000000000..f027f00df3 --- /dev/null +++ b/.changeset/fn-5911-agent-log-jsonl.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Move agent logs out of the SQLite `agentLogEntries` table into per-task `.fusion/tasks/{ID}/agent-log.jsonl` files, add one-time migration + source-ref rewrite support, preserve soft-deleted log files for forensics while hiding them from live reads, and switch goal-citation source refs to `agentLog:{taskId}:{lineNo}`. diff --git a/.changeset/fn-5916-cli-bin-launcher.md b/.changeset/fn-5916-cli-bin-launcher.md new file mode 100644 index 0000000000..70bf7c23ed --- /dev/null +++ b/.changeset/fn-5916-cli-bin-launcher.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix fresh-install `pnpm install` bin-link warnings by pointing the published `fn`/`fusion` bins at a committed `bin.mjs` launcher that forwards to the built CLI output. diff --git a/.changeset/fn-5917-codex-oauth-login.md b/.changeset/fn-5917-codex-oauth-login.md new file mode 100644 index 0000000000..d8f1c848bc --- /dev/null +++ b/.changeset/fn-5917-codex-oauth-login.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard OAuth login flow for ChatGPT Plus/Pro (Codex Subscription) so multi-option provider selection prompts no longer cancel the login before browser auth starts. diff --git a/.changeset/fn-5924-oauth-alert-throttle.md b/.changeset/fn-5924-oauth-alert-throttle.md new file mode 100644 index 0000000000..e229d0123d --- /dev/null +++ b/.changeset/fn-5924-oauth-alert-throttle.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +persist the OAuth expiry alert/notification throttle so users are alerted at most once per provider every 12 hours, even across server restarts. diff --git a/.changeset/fn-5936-auto-merge-mobile-fix.md b/.changeset/fn-5936-auto-merge-mobile-fix.md new file mode 100644 index 0000000000..83a144b12a --- /dev/null +++ b/.changeset/fn-5936-auto-merge-mobile-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard auto-merge toggle blanking on mobile by keeping board stabilization tied to viewport events instead of a one-shot resize listener. + +The in-review board now stays visible when auto-merge is toggled across Android mobile, iOS mobile, tablet, and desktop layouts, with regression coverage for populated and empty columns plus rollback and error-boundary paths. diff --git a/.changeset/four-oranges-film.md b/.changeset/four-oranges-film.md new file mode 100644 index 0000000000..4f38d4075c --- /dev/null +++ b/.changeset/four-oranges-film.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a dashboard browse-and-install flow for skills.sh catalog entries, including the new `POST /api/skills/install` API route and Skills view install actions that refresh discovered skills after a successful install. diff --git a/.changeset/minimax-usage-missing-rows.md b/.changeset/minimax-usage-missing-rows.md new file mode 100644 index 0000000000..22921874b6 --- /dev/null +++ b/.changeset/minimax-usage-missing-rows.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix missing model rows in the Minimax provider usage panel. The primary `general` model meters quota purely via `current_interval_remaining_percent` (its count fields are `0`), so the previous count-based visibility filter dropped it entirely. + +Minimax usage now prefers the authoritative `*_remaining_percent` field (with a count-based fallback) and renders a window only when a model exposes any quota signal. Each model's separate weekly quota window (`current_weekly_remaining_percent`, `weekly_*` timing) is now surfaced as its own indicator alongside the interval window. diff --git a/.changeset/per-task-automerge-override.md b/.changeset/per-task-automerge-override.md new file mode 100644 index 0000000000..ae495efe7e --- /dev/null +++ b/.changeset/per-task-automerge-override.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Respect per-task auto-merge overrides when the global auto-merge setting is off. Tasks with auto-merge explicitly enabled now get enqueued for merge and covered by the in-review self-healing sweeps (stall surfacing, merged-task finalization, retry recovery) even when the project-level setting is disabled; tasks without an explicit override keep the PR-based/manual review flow untouched. diff --git a/AGENTS.md b/AGENTS.md index 055015dfa4..401c38436d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,6 @@ ## Essential rules -### STANDING DIRECTIVE: Buttons Are Frozen (2026-05-13) - -Do not file, plan, or implement tasks that adjust button mobile-responsiveness, touch-target sizing, or mobile reflow of header/action button rows anywhere in the dashboard (TaskCard, SettingsModal, ChatView, MissionManager, AgentsView, FAB, etc.). **Keep buttons as they are.** - -This supersedes earlier guidance about mobile touch targets, primary/secondary control sizing on mobile, and `.touch-target` minimums for buttons. The `Frontend UX Design` workflow step (WS-006) is disabled and must stay disabled. - -If you find yourself opening `SettingsModal.css`, `TaskCard.css`, `ChatView.css`, etc. inside an `@media (max-width: 768px)` block to touch a `.btn`, `.modal-close`, `.settings-header-actions`, or `.card-*` button — stop. Confirm with the user in chat before proceeding. - -Exception: explicit named user request in chat that overrides this directive. - ### Spec Generation Hygiene - Do not cite `.fusion/tasks//` paths in Context/Steps/File Scope unless the file already exists, is explicitly created as a `(new)` Artifact, or is sibling `PROMPT.md`/`task.json`/`attachments/*`. @@ -97,8 +87,10 @@ pnpm verify:workspace ### Standing Rule: Fix the Invariant, Not the Repro (FN-5893) - When fixing a bug, the regression test must assert the general invariant across ALL known surfaces — not only the single reported reproduction. -- Enumerate the surfaces before filing or closing the fix: every provider/bridge for streaming and agent paths, both desktop and mobile breakpoints for UI behavior, and empty/undefined/populated data states. -- Motivating incidents: streamed-response spacing was fixed three times before the invariant was fully covered (FN-5787, FN-5789, FN-5803), and the auto-merge blank-dashboard fix re-opened after desktop-only coverage missed mobile Android (FN-5751). +- Surface enumeration is now an enforced bug-fix artifact: the spec must include a `## Surface Enumeration` section, planning must REVISE when that section is missing, and review must REVISE any repro-only regression test. +- Enumerate the surfaces before filing or closing the fix: every provider/bridge for streaming and agent paths, both desktop and mobile breakpoints for UI behavior, empty/undefined/duplicate/populated data states, and every shared hook/component/module/helper that reuses the affected logic. +- Use the canonical checklist in `docs/testing.md` → **Surface Enumeration checklist** so planning and review enumerate the same surfaces. +- Motivating incidents: streamed-response spacing was fixed three times before the invariant was fully covered (FN-5787, FN-5789, FN-5803), the usage "Show hidden" button regressed three times before broader coverage stuck (FN-5797, FN-5875, FN-5919), and the auto-merge blank-dashboard fix re-opened after desktop-only coverage missed mobile Android (FN-5751). - If a regression test only proves the exact reported case, it is incomplete; extend it until the invariant holds across all known surfaces. ### Port 4040 is Reserved @@ -163,39 +155,16 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes. -### Reliability Mechanism Coverage - -- FN-5432 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` extends FN-5256 coverage with long-cycle ambiguous sweep, write-boundary/sweep race, self-defeating+cycle non-contradiction across one maintenance flow, and audit-event shape regression; core regression cases (long cycle, self-loop via update, incremental-update closes a loop, moveTask seam invariant, DependencyCycleError shape) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. User-facing pull/stash audit event behavior (`pull:fast-forward`, `stash:pop-conflict`) is documented in `docs/dashboard-guide.md` under Merge Advance Notice / Smart Pull. -- FN-5403 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-stop-aborts-execution.test.ts` locks stop-ordering behavior so engine shutdown aborts executor AI sessions before drain wait and preserves task-row lifecycle semantics. -- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` guards reclaim/unpause no-progress oscillation recovery by capping repeated no-progress resumes, escalating to preserve-work `todo` rebound, and emitting `task:resume-limbo-escalated` audit metadata while exempting progress/user-paused/autoMerge-off cases. -- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion. -- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission-loop coverage so zero-assertion auto-pass deterministically advances to `loopState="passed"` and emits `validation_auto_passed_no_assertions` without duplicate recovery re-fire. -- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 write-only-shadow merge-request record + handoff-accepted marker seam (flag OFF = no-op, ON = shadow-only non-authoritative). -- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe parity (dependency + lease diffs, shadow dequeue parity, manual-required shadow skip) while legacy behavior remains authoritative. -- FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` plus `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover semantics (merge-request retry state transitions, authoritative user hard-cancel tombstone, and non-user rebound no-op cancel semantics). -- FN-5754 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts` guards startup/maintenance stranded-feature re-triage for active autopilot slices, including link-first dedupe, non-defined skip safety, non-autopilot no-op, idempotency, and `mission:stranded-feature-triaged` audit shape. -- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes. -- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons. -- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards merger-side promotion-gate telemetry on shared member landings, including pause/settings/group autoMerge reason mapping and no default-branch auto-promotion side effects. -- FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so completion detection drives exactly one shared→default promotion, re-calls stay idempotent, and gated paths emit promotion-gated telemetry without promoting. -- FN-5820 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts` guards the full shared-branch-group lifecycle—concurrent distinct-worktree execution, member→shared-branch accumulation, single shared→main completion-gate promotion with idempotent re-evaluation, gate-disabled integration-without-promotion, and per-task-derived/ungrouped no-regression. -- FN-5866 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` guards the post-done non-continuable-session seam so completed executor work stays cleanly in `in-review` while incomplete tasks still fail normally. -- FN-5888 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` also covers the incomplete-task non-continuable-session fresh-session retry path, ensuring within-budget failures clear `sessionFile` and requeue to `todo` with preserved resume state while exhausted budgets still fall through to terminal failure. -- FN-5889 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` extends the seam to the step-session post-done continuation path and the `recoverPostDoneNonContinuableWedge` self-heal, so completed work never wedges to `in-review` + `status="failed"` and already-wedged rows are cleared before stall surfacing. -- FN-5891 backstop: `packages/engine/src/__tests__/mission-execution-loop.test.ts` guards mission validation session model resolution (assigned-agent runtime, validator lane settings, test mode) and infrastructure-error surfacing so validator session failures emit `validation_error` instead of silently entering fix-feature retries. -- FN-5901 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validator-run-reaper.test.ts` guards stale mission-validator-run recovery across manual and automatic trigger types, verifies `mission:validator-run-reaped` audit metadata, preserves complete/archived parent feature state during reap, and proves reaped active features resume validation instead of staying wedged behind abandoned `running` rows. -- FN-5874 backstop: `packages/engine/src/__tests__/reliability-interactions/ai-merge-ff-landed-files.test.ts` guards AI-merge fast-forward finalizer persistence of `mergeDetails.commitSha`, `landedFiles`, and `modifiedFiles`, verifies no-op landings do not fabricate metadata, and confirms normal squash landings do not set FN-5103 attribution-restriction flags; companion coverage in `packages/engine/src/__tests__/self-healing.test.ts` extends `recoverDoneTaskMergeMetadata` so done tasks with empty `mergeDetails` but a recorded `baseCommitSha` are backfilled via owned-commit discovery while FN-5103 skip guards still prevent overwrite. - ---- ## Reference docs (deeper detail) - `./docs/architecture.md` — lifecycle invariants, self-healing rules, reliability interaction backstops, run-audit internals. - `./docs/testing.md` — full testing lanes, worker fanout guidance, test taxonomy, and file organization. - `./docs/dashboard-guide.md` — dashboard behavior and **Styling Guide** details. User-facing docs for Merge Advance Notice and Smart Pull live here. +- `./docs/PLUGIN_AUTHORING.md` — plugin authoring guide, lifecycle hooks, routes, tools, and dashboard-extension surfaces. - `./docs/agents.md` — pi extension scope, coordination tools, checkout leasing, runtime config. - `./docs/settings-reference.md` — model-selection hierarchy, mock provider mode, token budget precedence, presets. -- `./docs/storage.md` — hybrid storage model details. +- `./docs/storage.md` — hybrid storage model details, including per-task `agent-log.jsonl` storage and retention semantics. - `./docs/multi-project.md` — central/per-project DB and isolation modes. - `./docs/missions.md` — mission/milestone/slice/feature model. - `./docs/workflow-steps.md` — prompt/script gates and merge-blocking behavior. @@ -205,7 +174,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix. - `./docs/cli-reference.md` — CLI and terminal UI reference. - `./docs/contributing.md` — contributing conventions and release-adjacent context. -- `./docs/solutions/` — documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area. +- `./docs/solutions/` — documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. +- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts. ### Lazy-Loaded Heavy Views diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 0000000000..9f015d0e39 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,32 @@ +# Concepts + +Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. + +## Merge lifecycle + +### Task +The core board entity: a unit of work that moves through columns (triage, todo, in-progress, in-review, done, archived) and is executed by agents. A Task carries its own per-task settings that can override project-level defaults. + +### Auto-merge +The named process that automatically lands a completed Task's branch onto its merge target once the Task reaches In-review and passes its merge blockers. Gated twice: a project-level setting enables it globally, and each Task may carry an explicit per-task override. + +The per-task override takes precedence in both directions: an explicit per-task enable proceeds even when the global setting is off, and an explicit per-task disable routes the merge to Manual-required even when the global setting is on. Trigger-layer gates (enqueue, Self-healing sweeps) must evaluate additively — global on lets everything through for downstream routing; global off admits only explicit per-task enables — rather than collapsing the override to a single effective value, which would starve Manual-required routing. + +### In-review +The Task status column between execution and completion: work is done and the branch awaits merging. An In-review Task either auto-merges, waits for a human merge (PR-based/manual flow), or surfaces a stall diagnostic when it sits unprocessed longer than expected. Tasks not eligible for Auto-merge processing intentionally remain In-review until a human acts — recovery sweeps must not move them. + +### Merge queue +The ordered line of In-review Tasks awaiting Auto-merge, with a single merge active at a time. Tasks enter only through trigger gates (engine startup sweep, periodic retry, unpause, and the moved-to-review fast path); a Task filtered out at a gate is invisible to the merger regardless of its own settings. + +### Manual-required +The merge-request state for a Task whose merge needs an explicit human go-ahead — typically a Task with auto-merge explicitly disabled under a globally-enabled project. Reaching this state requires the Task to flow through the Merge queue trigger gates; upstream filtering that excludes such Tasks strands them In-review instead of parking them here. + +### Self-healing sweep +A recurring background scan that detects and repairs stuck Task states — stalled In-review Tasks, confirmed merges never finalized, ghost or limbo states, exhausted retries. Sweeps respect the same Auto-merge eligibility as the Merge queue: they may inspect any Task but mutate only those eligible for auto-merge processing. + +### Shared branch group +A set of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; promotion (shared branch → default branch) is gated separately. + +## Flagged ambiguities + +- "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 9d09ee47be..288ee955b5 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -5,7 +5,6 @@ A comprehensive guide to creating Fusion plugins that extend the task board with ## Table of Contents 1. [Getting Started](#1-getting-started) - - [External authoring guide](./plugins/external-authoring.md) 2. [Plugin Manifest Reference](#2-plugin-manifest-reference) 3. [Plugin Settings Schema](#3-plugin-settings-schema) 4. [Available Hooks and Signatures](#4-available-hooks-and-signatures) @@ -26,6 +25,8 @@ A comprehensive guide to creating Fusion plugins that extend the task board with --- +See also: [External Plugin Authoring guide](./plugins/external-authoring.md) + ## 1. Getting Started ### What Are Fusion Plugins? diff --git a/docs/architecture.md b/docs/architecture.md index 4577103291..694e66504e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1779,7 +1779,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in - FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so promotion happens exactly once after all members land, re-calls are idempotent, and gated paths emit `merge:branch-group-promotion-gated` without default-branch promotion. - FN-5819/FN-5846 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` and `shared-branch-group-lifecycle.test.ts` guard the scoped autoMerge-off exception and deterministic finalize path so shared members integrate into the single group branch, produce `mergeTargetSource: "branch-group-integration"`/`mergeTargetBranch`, do not land on main, and are not moved backward by self-healing maintenance. - FN-5901 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validator-run-reaper.test.ts` guards stale mission-validator-run recovery across manual and automatic trigger types, verifies `mission:validator-run-reaped` audit metadata, ensures archived/complete parents keep their terminal feature state untouched, and proves reaped active features resume validation instead of staying wedged behind abandoned `running` rows. -- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes. +- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` no longer permits zero-assertion auto-pass. Current coverage proves legacy zero-link features lazily restore a managed assertion, route through validator runs, and do not emit `validation_auto_passed_no_assertions` during recovery replays. - FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions. - FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows. - FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` and `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover invariants: transient merge retries mutate merge-request state (no column rebound), user hard-cancel after accepted handoff cancels pending merge requests, and non-user rebounds preserve legacy fail-soft semantics. diff --git a/docs/contributing.md b/docs/contributing.md index 9b4c27d5c5..d1f6cdcd4f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -19,6 +19,20 @@ Thanks for contributing to Fusion. pnpm install --frozen-lockfile ``` +### pnpm build-scripts approval policy + +pnpm v10 blocks dependency `preinstall`/`install`/`postinstall` scripts by default and reports any uncategorized packages in the install output. + +When a package is flagged: + +- Approve it only when this repo genuinely needs that dependency's build script for supported runtime/build paths. +- Ignore it when prebuilt artifacts, optional-native fallbacks, or release-only tooling make the script unnecessary for normal workspace verification. +- Record every reviewed package in exactly one bucket: `onlyBuiltDependencies` or `ignoredBuiltDependencies`. + +Fusion currently keeps the reviewed decision set documented in the root `package.json` `pnpm` block and mirrored in `pnpm-workspace.yaml`, which is the effective pnpm v10.33 install-time config read by `pnpm install`. + +The guard test `node --test scripts/__tests__/pnpm-build-scripts-config.test.mjs` (also covered by `pnpm test:scripts`) enforces that the reviewed dependencies stay categorized, deduped, and non-overlapping so the ignored-build-scripts warning cannot silently return. + ### Build workspace packages ```bash diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d594cdee01..233470e20b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -211,7 +211,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. - reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading - mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests - in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs -- mailbox entry points now show pending-approval indicators: Header mailbox toggle dot, Header overflow mailbox badge, Mobile mailbox tab dot, and Mobile More → Mailbox badge +- mailbox entry points now show unread/pending indicators: the desktop Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, while Header overflow + Mobile mailbox entry points continue to surface mailbox badges/dots - approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload - when a task newly enters `awaiting-approval`, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; dismissals are remembered per approval item until that item advances or a different one arrives - Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links @@ -756,6 +756,8 @@ For setup prerequisites, security caveats for tokenized URLs/QR links, and troub ## Skills API +The Skills view now supports the full browse-and-install loop for skills.sh entries: use **Skills Catalog** to search the catalog, click **Install** on any card with a source repository, and the dashboard will run the same installer as the CLI (`npx skills add -y -a pi`, with `--skill ` when applicable). On success, the view refreshes **Discovered Skills** immediately so the newly installed skill appears without a page reload. + The Skills API provides endpoints for managing execution skills. Skills are toggled via project-scoped settings in `.fusion/settings.json`. ![Skills view](./screenshots/skills-view.png) @@ -871,6 +873,49 @@ Toggle a skill's enabled/disabled state. { "error": "Skills adapter not configured", "code": "adapter_not_configured" } ``` +### POST /api/skills/install + +Install a catalog skill into the current project. + +**Request Body:** +```json +{ + "source": "owner/repo", + "skill": "example-skill" +} +``` + +**Behavior:** +- Validates `source` in `owner/repo` format before spawning anything +- Runs `npx skills add -y -a pi` +- Appends `--skill ` when `skill` is provided +- Uses the scoped project root as `cwd`, so installed files land in the current project's skill directories + +**Response:** `200 OK` +```json +{ + "success": true +} +``` + +**Error Responses:** +- `400 Bad Request` — missing source + ```json + { "error": "source is required", "code": "invalid_body" } + ``` +- `400 Bad Request` — malformed source + ```json + { "error": "Invalid source format. Use owner/repo.", "code": "invalid_source" } + ``` +- `404 Not Found` — adapter not configured + ```json + { "error": "Skills adapter not configured", "code": "adapter_not_configured" } + ``` +- `502 Bad Gateway` — installer failed/timed out/could not start + ```json + { "error": "installer failed", "code": "install_failed" } + ``` + ### GET /api/skills/catalog Fetch the skills.sh catalog with optional authentication. @@ -1057,6 +1102,8 @@ 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. + **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). **Safe area:** `max(var(--space-md), env(safe-area-inset-left, 0px))` for notch-aware horizontal padding. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 35ca219fd2..f8ae327790 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -126,7 +126,7 @@ FN-5416 extends resume-correlation coverage to stream-focused hooks and their pr - `useDevServerLogs`: `project-context-change`, `sse-open`, `sse-reconnect` - `useResearch`: `sse-open`, `sse-reconnect` - `useBackgroundSessions`: `sse-open`, `sse-reconnect` - - `useAgentLogs`: `project-context-change`, `sse-open`, `sse-reconnect` on `/api/tasks/:id/logs/stream` + - `useAgentLogs`: `project-context-change`, `sse-open`, `sse-reconnect` on `/api/tasks/:id/logs/stream` (live tail via SSE; historical reads are backed by `.fusion/tasks/{ID}/agent-log.jsonl`) - Route shells - `DevServerView`: `remount` / `route-active` / `route-inactive` - `ResearchView`: `remount` / `route-active` / `route-inactive` diff --git a/docs/missions-completion-contract.md b/docs/missions-completion-contract.md index ac9040d4f6..dd1bb0291c 100644 --- a/docs/missions-completion-contract.md +++ b/docs/missions-completion-contract.md @@ -2,175 +2,93 @@ ## Status -- **Decision date:** 2026-05-30 -- **Task:** FN-5718 -- **Depends on enforcement behavior from:** FN-5715 (reference implementation of the trigger/recovery path) -- **Scope:** Product contract and implementation requirements only (no code changes in this task) -- **Implementation status:** Realized by FN-5733 (loop auto-pass advancement, mission/store guard telemetry, MissionManager label reconciliation) +- **Decision date:** 2026-06-02 +- **Current contract task:** FN-5902 +- **Supersedes:** FN-5718 baseline contract +- **Depends on runtime trigger/recovery behavior from:** FN-5715 +- **Implementation status:** Realized by FN-5902 -## Problem +## Decision -Mission validation has had a recurring ambiguity: users can see feature acceptance text and milestone "completion criteria" text, but autopilot enforcement is actually driven by assertion linkage + validator outcomes. This document defines the canonical enforced gate so mission completion cannot silently stall or be misread. +Mission completion now uses an **all-criteria AI-run contract**: -## Canonical Completion Gate (Enforced) - -### Decision - -A feature is autopilot-complete only when **its linked contract assertions are satisfied**. - -Canonical authored source and enforcement path: - -1. `MissionFeature.acceptanceCriteria` is the canonical authored criteria text (authoring surface). -2. MissionStore must maintain a **store-managed per-feature `MissionContractAssertion`** derived from feature content, with text priority: +1. `MissionFeature.acceptanceCriteria` is the canonical authored feature criteria text. +2. MissionStore must maintain or lazily restore **one store-managed per-feature `MissionContractAssertion`** derived from feature content with text priority: - `feature.acceptanceCriteria` - `feature.description` - `Verify implementation of: {feature.title}` -3. The mission validator enforces completion using the feature's **linked assertions** (including its store-managed assertion and any additional linked milestone assertions). -4. Feature/slice/mission advance is gated by the validator outcome (or explicit no-assertions auto-pass behavior defined below). +3. The mission validator must run for every feature completion trigger. Runtime validation may lazily call `ensureFeatureAssertionLinked(feature.id)` before starting the validator so legacy missing-link rows still become validator-backed. +4. `milestone.acceptanceCriteria` is also part of the enforced gate by being threaded into the validator prompt for every feature in that milestone. +5. Feature, slice, milestone, and mission advancement are gated by the validator result — **not** by an informational-only path. -### Precedence and interpretation rules +## Enforcement Model -- `MissionFeature.acceptanceCriteria` is the canonical authoring field for feature-level intent. -- The **linked assertion set** is the canonical enforcement set. -- Milestone `MissionContractAssertion` rows are additive contract rows. They are enforced **only when linked to a feature**. -- `milestone.acceptanceCriteria` is descriptive/informational milestone text and is not directly executed by the validator. +### Feature-level enforcement -### Worked examples +A feature is autopilot-complete only when the validator passes after evaluating: -1. **Feature has acceptance criteria; store-managed assertion linked; validator passes** - - Result: feature may move to done/passed and contribute to slice completion. -2. **Feature has acceptance criteria; additive milestone assertion also linked; one linked assertion fails** - - Result: feature is not complete; no slice advance. -3. **Feature has acceptance criteria visible, but no linked assertions (legacy FN-5696 shape)** - - Result: data inconsistency; must not be interpreted by operators as a separate enforced gate. Repair links (FN-5696 backfill) so enforcement matches displayed intent. +- the feature's linked contract assertions, including its store-managed assertion, and +- the parent milestone's `acceptanceCriteria` text when present. -## Enforced vs. Informational Surfaces +### Milestone-level enforcement -| Surface | Category | Contract meaning | -|---|---|---| -| `MissionFeature.acceptanceCriteria` | Informational authoring source | Canonical authored feature criteria text; enforcement happens through derived/linked assertions | -| Store-managed per-feature `MissionContractAssertion` | Enforced | Primary validator gate for the feature | -| Additive milestone `MissionContractAssertion` (linked to feature) | Enforced | Additional validator gate for that linked feature | -| Additive milestone `MissionContractAssertion` (unlinked) | Informational until linked | Contract candidate, not yet a feature gate | -| `milestone.acceptanceCriteria` | Informational | Milestone summary/pass-bar text for humans; not directly validator-executed | -| MissionManager `milestone-feature-acceptance-rollup` UI (`data-testid="milestone-feature-acceptance-rollup"`) | Informational display | Display-only rendering of feature acceptance text, not a separate enforcement mechanism | +`milestone.acceptanceCriteria` is no longer informational-only. FN-5902 enforces it by threading the milestone pass-bar text into the validator prompt for each feature under that milestone. -## Zero-Assertion Behavior and FN-5696 Failure Shape +This is intentionally **prompt-threading**, not per-feature milestone assertion row synthesis: -### Zero-assertions runtime behavior (canonical FN-5738 path) +- store-managed per-feature assertions remain the canonical feature assertion rows, +- milestone acceptance text remains milestone-authored prose, +- the validator sees both and must satisfy both. -When a feature reaches completion trigger points and has **zero linked assertions**, mission execution must take exactly one canonical auto-pass path (not a silent stall and not a competing behavior): +### Legacy data and lazy repair -- mark feature terminal as `status="done"`, `loopState="passed"`, `lastValidatorStatus="passed"`, -- emit explicit observability/audit evidence with mission event code `validation_auto_passed_no_assertions`, -- continue normal slice/mission advancement checks idempotently (no duplicate re-fire on repeated recovery). +Legacy missions can still contain features with missing assertion links. Runtime enforcement no longer depends on pre-running backfill: -### FN-5696 legacy shape clarification +- mission execution lazily restores the store-managed feature assertion just before validation, and +- `fn_mission_backfill_assertions` / `backfillFeatureAssertions()` remain available as operator repair tooling for data hygiene and visibility. -A feature can show acceptance text while links are missing (legacy pre-repair data). This must be treated as a **linkage/data integrity problem**, not as proof that milestone text alone is enforced. Assertion authoring/backfill (FN-5696) is outside the execution loop; the loop must not synthesize `mission_feature_assertions` rows. The contract prevents ambiguity by separating: +## Removed behavior (FN-5902 inversion) -- authored/informational text surfaces, from -- linked assertion enforcement surfaces. +FN-5718's zero-assertion auto-pass behavior is superseded. -Operators should use the mission assertion backfill operator path to restore expected store-managed linkage for FN-5696 legacy rows: +Removed contract: -- Agent/tool: `fn_mission_backfill_assertions` with `{ missionId?, dryRun? }` (defaults to dry-run). -- API: `POST /api/missions/:missionId/backfill-assertions` with body `{ dryRun?: boolean }` (defaults to `true`). -- Run dry-run first, then apply (`dryRun=false`) once repaired rows look correct. -- This remediation is additive: it derives/links one store-managed assertion per unlinked feature so runtime enforcement uses validator-linked assertions rather than the zero-assertion auto-pass branch. +- no `validation_auto_passed_no_assertions` completion path, +- no silent or explicit rubber-stamp pass because assertions were missing, +- no informational-only feature criteria bucket in MissionManager. -## Slice Status and Mission Autopilot Advance Derivation +Instead, features are routed through validator execution after lazy assertion ensure. -Autopilot may advance only when each active-slice feature is resolved under this contract: +## Worked examples -- Feature with linked assertions: all linked assertions must pass. -- Feature with zero linked assertions: explicit auto-pass path completes it. -- Feature with failed/blocked validation: slice remains incomplete. -- Feature stranded without a task link in an active autopilot slice (`taskId == null`): startup + maintenance reconciliation must repair it (title-match link first, otherwise defined-status re-triage) so `allDone` remains reachable instead of stalling on never-triaged features. +1. **Feature has acceptance criteria; no linked assertion row is present yet** + - Runtime calls `ensureFeatureAssertionLinked(feature.id)`. + - Validator runs against the restored managed assertion. + - Result gates completion normally. -Then: +2. **Feature has acceptance criteria and milestone acceptance criteria** + - Validator evaluates the linked feature assertion(s). + - Validator also evaluates the milestone acceptance text in the prompt. + - Feature passes only when both are satisfied. -1. All features resolved complete → slice flips to `complete`. -2. Completed active slice with pending next slice → next slice activates. -3. All milestone slices complete → milestone complete. -4. All mission milestones complete → mission complete. +3. **Operator runs backfill on legacy data** + - Backfill pre-restores missing managed assertions for visibility/reporting. + - Runtime behavior is unchanged because lazy ensure already guarantees validator-backed enforcement. -This keeps completion logic deterministic and consistent with FN-5715 trigger/recovery behavior. +## UI contract -## UI Reconciliation Requirements (for follow-on engineering task) +MissionManager must present mission criteria as **AI-validated** rather than informational: -✅ Implemented in FN-5733 with MissionManager labels: -- `Contract assertions (autopilot gate)` + enforced indicator -- `Feature acceptance criteria (informational)` + not-enforced indicator -- warning badge when `hasProseButNoAssertions === true` +- assertion heading text reflects AI validation, +- informational / not-enforced labels are removed, +- zero-assertion warning guard is removed, +- fallback feature-criteria rollups, when shown for missing loaded assertions, describe runtime AI validation rather than non-enforced prose. -Target surface: `packages/dashboard/app/components/MissionManager.tsx` +## Success invariant -1. **Disambiguate labels** - - Use distinct wording for: - - feature-authored acceptance text, and - - milestone contract assertions. - - Do not reuse "Completion criteria" to refer to both categories. - - Required wording baseline (or semantically equivalent copy): - - Feature rollup heading: `Feature acceptance criteria (informational source)` - - Assertion list heading: `Contract assertions (validator-enforced when linked)` +For any mission feature that reaches validation trigger points: -2. **Per-row enforcement indicator** - - Every displayed row in the assertions/criteria area must show whether it is: - - `Enforced gate` (validator-blocking when linked), or - - `Informational` (display-only). - -3. **Empty-state contract-correct copy** - - Replace the current implication that completion criteria are absent when assertion rows are empty. - - Empty-state text must acknowledge when feature acceptance text exists but no assertion rows are defined/linked. - - Required behavior: - - If feature acceptance text exists but no assertion rows are present, show copy equivalent to: `No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.` - - If neither feature acceptance text nor assertions exist, show copy equivalent to: `No feature acceptance criteria or contract assertions defined yet.` - -4. **No button/mobile scope expansion** - - No button touch-target/mobile-reflow requirements (standing directive). - -## Engineering Acceptance Criteria (follow-on implementation) - -✅ Implemented in FN-5733: -- Auto-pass path now advances `loopState` to `passed` and emits mission event code `validation_auto_passed_no_assertions` while preserving the `validation:passed` emit contract (`"No assertions linked"` summary). -- Milestone rollup/store guard now exposes `hasProseButNoAssertions` and emits warning mission event code `milestone_missing_structured_assertions` (debounced on transition into condition). -- MissionManager UI now distinguishes enforced assertion gate vs informational feature acceptance criteria. - -1. **Data/model contract** - - Preserve the canonical relationship: feature-authored criteria -> store-managed assertion -> linked assertion enforcement. - - If any model/UI metadata is added for enforced-vs-informational badges, it must be backward compatible with existing mission rows. - -2. **Validator/loop behavior** - - Maintain FN-5715 invariants: - - done mission-linked tasks with linked assertions trigger validation, - - completion-trigger starts loop if needed, - - startup recovery replays done-implementing features with unpassed assertions, - - periodic self-heal maintenance replays the same `recoverActiveMissions` path so historically stranded `implementing` features recover without restart, - - zero-linked-assertions path remains explicit canonical auto-pass. - -3. **UI behavior** - - Implement the Step-2 label reconciliation and per-row indicator requirements. - - Ensure no shared ambiguous terminology remains between feature acceptance text and assertion rows. - -4. **Regression coverage** - - Add at least one regression test pinning the Goals-mission shape: - - feature has `acceptanceCriteria`, - - parent milestone has zero `MissionContractAssertion` rows / no links, - - autopilot behavior is deterministic and observable (explicit auto-pass path, no silent stall). - -5. **Operational observability** - - Ensure mission/audit surfaces make no-assertions auto-pass and subsequent advance decisions queryable in logs/events. - -## Success Metric - -For 30 days after the follow-on implementation ships: - -- **Primary metric:** zero autopilot stalls of the FN-5715 class (done mission task + unresolved validation trigger gap) in production mission runs. -- **Evidence source:** mission audit/event stream (`feature_completed`, `slice_completed`, `mission_completed`) plus `mission_validator_runs` records showing: - - explicit no-assertions auto-pass evidence (summary/reason path such as `No assertions linked` when no validator run is started), and - - downstream advancement evidence without stalled active slices. - -## Follow-on Task Requirement - -Implementation must land in a separate engineering task that references this document and FN-5715 as the enforcement baseline. \ No newline at end of file +- a validator run must occur, +- the feature must not auto-pass due to missing assertion links, +- milestone acceptance text must be visible to the validator when present, +- advancement decisions must derive from validator outcomes only. diff --git a/docs/missions.md b/docs/missions.md index b588775e02..cf137f9852 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -23,9 +23,9 @@ Mission: Improve Reliability Task: FN-214 ``` -## Mission ↔ Goal persistence +## Mission → Goal linkage -Missions and goals are stored independently, but Fusion now persists an optional many-to-many linkage in the `mission_goals` join table. +Missions and goals are stored independently, with an optional many-to-many linkage persisted in the `mission_goals` join table. - Columns: `missionId`, `goalId`, `createdAt` - Primary key: `(missionId, goalId)` @@ -33,14 +33,24 @@ Missions and goals are stored independently, but Fusion now persists an optional - Delete behavior: both foreign keys use `ON DELETE CASCADE`, so removing either parent deletes only the corresponding join rows - Reverse lookups are indexed via `idxMissionGoalsGoalId` -`MissionStore` owns the linkage CRUD surface: +`MissionStore` owns the persisted linkage CRUD surface: - `linkGoal(missionId, goalId)` — idempotently create a link and return `{ missionId, goalId, createdAt }` - `unlinkGoal(missionId, goalId)` — remove a link and report whether anything changed - `listGoalIdsForMission(missionId)` — list linked goals in deterministic creation order - `listMissionIdsForGoal(goalId)` — list linked missions in deterministic creation order -Existing missions are **not** backfilled with goal links as part of this schema change; that decision is deferred to FN-5898. +### No-backfill decision + +Existing missions are intentionally **not** auto-linked to any goals. Fusion does not run a migration backfill for pre-existing missions, so a mission with no links should be treated as genuinely unlinked until an operator or agent associates it with one or more goals. + +### Manual linkage workflow + +Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. Read surfaces can show current associations, and operator-facing write surfaces can add or remove links when a mission should explicitly support a goal. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data. + +### Unlinked mission indicator + +Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association. ## Creating Missions @@ -107,18 +117,20 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e | Endpoint | Purpose | |---|---| +| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. | | `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. | | `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. | | `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. | | `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. | -All four endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows. +The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All five endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows. ### CLI - `fn mission goals ` — list linked goals for a mission. - `fn mission link-goal ` — idempotently link a goal. - `fn mission unlink-goal ` — idempotently unlink a goal. +- Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card. ## Mission Planning Tools (pi extension) @@ -128,7 +140,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r |---|---| | `fn_mission_create` | Create a mission with title/description, optional `baseBranch`, and optional auto-advance behavior. | | `fn_mission_list` | List missions and their current status. | -| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including milestone/feature acceptance criteria and slice verification when present. | +| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including a **Linked Goals** section plus milestone/feature acceptance criteria and slice verification when present. | | `fn_mission_list_goals` | List the goals linked to a mission. | | `fn_mission_link_goal` | Idempotently link a goal to a mission. | | `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission. | @@ -510,7 +522,7 @@ A feature transitions to `blocked` when: On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet. When the stale-run reaper has already converted an abandoned validator run into `needs_fix`, `processTaskOutcome()` promotes the feature back through `implementing` and re-validates instead of skipping it. The same recovery path is replayed during periodic self-heal maintenance, so historically stranded `implementing` features can self-heal without requiring an engine restart. -For features with zero linked assertions, the completion path is explicit: the loop marks the feature `done`, advances `loopState` to `passed`, emits `validation:passed` with summary `"No assertions linked"`, and records mission event code `validation_auto_passed_no_assertions`. Contract details (including canonical no-assertions behavior and FN-5696 assertion-authoring separation) are defined in [Mission Completion Gate Contract](./missions-completion-contract.md). +For features with missing linked assertions, the completion path is now validator-first: the loop lazily restores the store-managed per-feature assertion just before validation, then runs the AI validator instead of auto-passing. Milestone `acceptanceCriteria` is threaded into the validator prompt for every feature in that milestone, so all mission criteria are AI-evaluated. Contract details are defined in [Mission Completion Gate Contract](./missions-completion-contract.md). ### Autopilot / Scheduler Interplay @@ -541,8 +553,7 @@ These are independent tracking mechanisms — autopilot monitors mission progres **MissionEvent audit types:** - `slice_activated`, `feature_planned`, `feature_completed` - `validation:started`, `validation:passed`, `validation:failed`, `validation:blocked` -- `validation_auto_passed_no_assertions` (reason: `"No assertions linked"`) -- `milestone_missing_structured_assertions` (warning when prose criteria exist with zero structured assertions) +- `milestone_missing_structured_assertions` (legacy-data warning surface; enforcement still lazy-restores managed assertions at runtime) - `fix_feature:created`, `feature:blocked` **Validator run telemetry:** diff --git a/docs/settings-reference.md b/docs/settings-reference.md index d02a2df3e2..179225d0b2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -45,7 +45,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. | | `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer ` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. | -| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | +| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | | `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. | | `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. | | `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. | @@ -161,7 +161,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports | `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). | | `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. | | `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer ` on ntfy publishes. | -| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired. | +| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. | | `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. | Disable daily update checks globally: @@ -265,7 +265,7 @@ Sandbox backend precedence is: | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | -| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. For pnpm repos, prefer `pnpm install --frozen-lockfile` for deterministic bootstrap. | +| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation and again to bootstrap the merge worktree before AI merge verification. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | @@ -430,6 +430,8 @@ Default notes: | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | +| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes rows older than this many days using each row's `timestamp`. | +| `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | | `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). | | `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). | | `chatRoomSummaryMaxChars` | `number` | `3000` | Hard cap for the synthesized “Earlier room context” summary block (about 2× the prior summary budget). | diff --git a/docs/soft-delete-verification-matrix.md b/docs/soft-delete-verification-matrix.md index 0dcbc99036..993a592b0a 100644 --- a/docs/soft-delete-verification-matrix.md +++ b/docs/soft-delete-verification-matrix.md @@ -17,8 +17,7 @@ | 5. Soft-delete an `in-progress` task with an active workflow-step session and reviewer subagent | Live `in-progress`; workflow step child session exists | Delete succeeds; no public recovery/undelete flag | New execution attempts refuse; workflow-step + reviewer abort/cleanup is **pending FN-5142** | N/A | N/A | SSE removes card; reload stays clean | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 | | 6. Soft-delete an `in-review` task that is currently being merged | Live `in-review`; active merge session in flight | Delete succeeds; live readers omit afterward | Scheduler must not requeue it | Active merge abort, queue removal, and controller cleanup are **pending FN-5142** | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 | | 7. Soft-delete an `in-review` task queued for merge but not yet active | Live `in-review`; merge queued only | Delete succeeds; row stays for forensics only | Scheduler/executor must not pick it up again | Merge queue must filter it out; pending FN-5142 covers deterministic abort/filter assertions | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 | -| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agentLogEntries` must clear atomically; **pending FN-5143** | `/api/documents` and per-task docs must disappear while DB rows remain; **pending FN-5140** | FN-5140 / FN-5143 | -| 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | Current contract is not pinned; matrix gate requires deterministic error-or-no-op behavior. Follow-up filed as **FN-5196**. | Must never affect active queues either way | N/A | N/A | No dashboard resurrection; exact UX blocked by FN-5196 | N/A | N/A | FN-5196 | +| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agent-log.jsonl` file is preserved, but `getAgentLogs*` / `getAgentLogCount` return zero once `deletedAt` is set | `/api/documents` and per-task docs must disappear while DB rows remain; **pending FN-5140** | FN-5140 / FN-5143 || 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | Current contract is not pinned; matrix gate requires deterministic error-or-no-op behavior. Follow-up filed as **FN-5196**. | Must never affect active queues either way | N/A | N/A | No dashboard resurrection; exact UX blocked by FN-5196 | N/A | N/A | FN-5196 | | 10. Soft-delete a task that is checked out by an agent (`checkedOutBy` set) | Live task with lease / checkout metadata | Delete succeeds; linked agent task references clear with delete | Soft-deleted checked-out task must not be auto-claimed or executed after refresh/tick; extra deterministic coverage filed as **FN-5195** | If merge-owned, FN-5142 owns active merge abort details | If triage-owned, FN-5142 owns active triage abort details | SSE removes card; refresh must not show stale checked-out task | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5195 | | 11. Retry-exhausted `in-review` blocker is soft-deleted (`mergeRetries >= 3`) | Row remains in `tasks` with `deletedAt` set; downstream live tasks may still reference blocker in `dependencies`/`blockedBy` | Default task readers still hide it; opt-in surfaces expose it (`GET /api/tasks/exhausted-in-review?includeDeleted=true`, `GET /api/tasks/:id?includeDeleted=true`, `fn_task_show` fallback, `fn_task_list includeDeleted`) | Deadlock/stuck-merge/in-review-stall scans must exclude soft-deleted rows via `listTasks` (`ACTIVE_TASKS_WHERE`), plus per-sweep `task.deletedAt` guards as belt-and-suspenders | No merge-state mutation; blocker remains terminal unless explicit operator action | N/A | ReliabilityView panel explicitly lists hidden exhausted blockers + blocked dependents; main board stays unchanged | Existing logs preserved | Documents readable via opt-in task-detail fetch; no automatic restore | FN-5513 / FN-5528 | @@ -40,7 +39,7 @@ | 1,10 | Triage abort on `task:deleted` | No deterministic triage abort assertion in current corpus | Missing active triage session + subagent abort coverage | `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts` | FN-5142 | | 11 | Deadlock/stuck-merge/in-review-stall scan exclusion for soft-deleted exhausted blockers | Added in this task | GREEN — defensive sweep guards + script `WHERE deletedAt IS NULL` backstop | `packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts`, `scripts/__tests__/recover-stale-blocked-by.test.mjs` | FN-5528 | | 2,11 | Soft-delete blocker residue + legacy column drift reconciliation (`deletedAt` + non-archived column) | Added in this task | GREEN — in-transaction blocker cleanup, periodic/startup column-drift reconciler, and audit mutation `task:soft-delete-column-reconciled` | `packages/engine/src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts`, `packages/core/src/__tests__/store-delete-task-blocker-residue.test.ts` | FN-5566 (re-land FN-5446) | -| 8 | `agentLogEntries` cleared on soft-delete | No dedicated coverage today | Missing atomic clear + post-delete empty-reader assertion | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | FN-5143 | +| 8 | Preserved `agent-log.jsonl` file is hidden from live readers after soft-delete | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | GREEN — read APIs return zero while the on-disk file remains available for forensics | — | FN-5143 / FN-5911 | | 8 | `/api/documents` and per-task docs exclude soft-deleted parents | No dedicated soft-delete document visibility assertion today | Missing store + route coverage | `packages/core/src/__tests__/task-documents.test.ts` and `packages/dashboard/src/__tests__/routes-tasks.test.ts` | FN-5140 | | 3 | Lineage-unlink 409 flow through API + UI | Store lineage guards are covered; route/UI flow is not | Missing 409 payload + confirm-retry UX coverage | `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts`, `packages/dashboard/app/utils/__tests__/taskDelete.test.ts`, `packages/dashboard/app/components/__tests__/TaskCard.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`, `packages/dashboard/app/components/__tests__/ListView.test.tsx` | FN-5139 | | Stream-wide | `fn_task_delete` tool / skill terminology | No regression asserting soft-delete wording | Missing user-facing copy coverage | `packages/cli/src/__tests__/extension.test.ts` | FN-5141 | @@ -65,7 +64,7 @@ Supported forensic access is internal only: - `readTaskFromDb(id, { includeDeleted: true })` in `packages/core/src/store.ts` -- direct SQL against `tasks`, `task_documents`, and `agentLogEntries` +- direct SQL against `tasks` and `task_documents`, plus on-disk reads of `.fusion/tasks/{ID}/agent-log.jsonl` No public API flag exposes deleted-task forensics today. Adding one requires a new FN with its own review. diff --git a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md new file mode 100644 index 0000000000..4aeb77af47 --- /dev/null +++ b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md @@ -0,0 +1,113 @@ +--- +title: Per-task auto-merge override ignored by trigger-layer gates +date: 2026-06-03 +category: logic-errors +module: engine +problem_type: logic_error +component: background_job +symptoms: + - "Tasks with per-task autoMerge:true never auto-merged when global settings.autoMerge was off" + - "Override tasks reached in-review and sat there indefinitely with no error surfaced" + - "In-review self-healing sweeps short-circuited on the global setting and never enqueued the merge" +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - merger + - self-healing + - store +tags: + - auto-merge + - per-task-override + - merge-queue + - self-healing + - engine + - trigger-gate +--- + +# Per-task auto-merge override ignored by trigger-layer gates + +## Problem + +A per-task `autoMerge: true` override was honored only by the merger itself, but every *trigger-layer* gate (engine enqueue, 19 self-healing sweeps, store stall-signal hydration) checked the global `settings.autoMerge` alone. With global auto-merge OFF, override tasks were never enqueued and sat in `in-review` forever. Fixed in PR Runfusion/Fusion#1356. + +## Symptoms + +- User disabled auto-merge globally but enabled it on individual tasks. +- Those individually-enabled tasks reached `in-review` and stayed there indefinitely — never picked up, never merged. +- No error surfaced: the tasks were simply never *triggered* into the merge pipeline, so the merger's per-task handling never ran. + +## What Didn't Work + +- **Assuming the downstream merger check was enough.** The only code consulting `task.autoMerge` was the merger (`packages/engine/src/merger.ts` ~7958: `task.autoMerge === false` → `manual-required`). That runs *after* enqueue. The enqueue gate `allowInReviewMergeProcessing` (`packages/engine/src/project-engine.ts:1386`) and 19 self-healing sweeps short-circuited on `settings.autoMerge` before the task ever reached the merger — so the per-task flag was dead code from the user's perspective. Notably, the feature issues (Runfusion/Fusion#1150, #1152, #1153) shipped the data model, a resolver (`resolveEffectiveAutoMerge`), and the dashboard control — #1152 even claimed engine merge-gating used the resolved value — but no trigger gate actually consulted it. +- **Reaching for `resolveEffectiveAutoMerge` at the gates.** The existing resolver `task.autoMerge ?? settings.autoMerge` (`packages/core/src/task-merge.ts`) looks like the natural gate, but using it would *regress* the global-ON + `autoMerge:false` case: those tasks must still flow into the merger so it can park them as `manual-required` (and so merged-task finalization sweeps still finalize them). Plain resolution would skip them at the trigger, stranding manually-merged tasks in `in-review`. +- **Slim-projection gotcha.** Per-task gating reads `task.autoMerge` off rows from slim task projections. If the `autoMerge` column were missing from `getTaskSelectClause` (`packages/core/src/store.ts` ~1976), the gate would silently see `undefined` and the override would fail with no error. (Verified present — but a real trap when adding per-row predicates.) + +## Solution + +New core predicate, **additive** to the global setting (`packages/core/src/task-merge.ts`): + +```ts +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} +``` + +Applied at three trigger layers: + +1. **Enqueue gate** (`project-engine.ts:1386`), which fronts all four enqueue paths (startup sweep, periodic retry, unpause, task-moved fast path): + + ```ts + // before + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + } + // after + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); + } + ``` + +2. **All 19 self-healing sweeps** (`self-healing.ts`): the function-level early returns (`if (settings.autoMerge === false) return 0;`) were replaced by per-task filtering inside each sweep's candidate set, e.g.: + + ```ts + const candidates = tasks.filter((t) => + t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && + !t.paused && /* ... */); + ``` + +3. **Store stall-signal hydration** (`store.ts`, 6 sites): `autoMerge: settings.autoMerge` → `autoMerge: allowsAutoMergeProcessing(task, settings)` in the `getInReviewStallReason` / `getInReviewStalledSignal` contexts, so board diagnostics reflect that override tasks *are* being processed. + +The self-healing contract also changed: from "skip the whole sweep when global is off" to "list tasks, but mutate nothing without a per-task override." FN-5147 tests that asserted `listTasks` was never called were updated to assert the mutation-free guarantee instead. This extends — and stays consistent with — the AGENTS.md `autoMerge: false` callout (FN-5147): self-healing still never moves override-less `in-review` tasks when auto-merge is off. + +## Why This Works + +The root cause was a flag consulted only where the *action* runs, not where processing is *triggered*. Adding the override evaluation to every trigger gate closes the gap. + +Additive (`settings.autoMerge !== false || task.autoMerge === true`) is deliberately chosen over resolution (`task.autoMerge ?? settings.autoMerge`): + +- **Global ON:** `settings.autoMerge !== false` is already `true`, so the predicate is a no-op — every task flows through exactly as before, including `autoMerge:false` tasks that the merger then parks as `manual-required`. Resolution would have excluded those, breaking manual-required parking and finalization. +- **Global OFF:** the first term is `false`, so only `task.autoMerge === true` tasks proceed — exactly the missing override path. + +It changes nothing when global is ON and adds only the explicit-true path when global is OFF. + +## Prevention + +When adding a per-entity override to a behavior that's gated on a global setting, the override must be consulted **where the behavior is TRIGGERED, not just where the action runs.** A check at the merger (the action) is invisible if upstream enqueue/sweep gates already filtered the entity out. + +- **Grep every gate on the global setting** before declaring the override wired: here `settings.autoMerge` appeared at 1 enqueue gate, 19 sweep guards, and 6 hydration sites — all needed updating. A search for the global key, not just the new override field, surfaces the dead-flag sites. +- **Prefer additive gating over effective-value resolution for *processing* gates.** Resolution collapses three states (global-on/off × per-task true/false/unset) into one boolean and can starve a needed downstream branch (the manual-required parking path). Gate on "should this be processed at all," resolve the actual behavior later. +- **Check existing regression contracts before re-scoping a gate.** Review of the fix PR suggested exempting `todo`/`in-progress` candidates (execution-stage repair) from the auto-merge gate — but the repo's FN-5704 regression test ("short-circuits reclaim when autoMerge is false") deliberately keeps execution-stage reclaim inert in manual-review projects. Per-task gating applied uniformly preserves that contract while enabling overrides; exempting execution-stage recovery would be a separate, deliberate behavior change. +- **Watch slim projections:** per-row predicates require the override column in the SELECT clause, or they silently read `undefined`. +- **Test matrix must cross global × per-task.** The fix shipped red-first unit tests for the predicate (`packages/core/src/__tests__/task-merge.test.ts`), the gate including the shared-group exemption (`packages/engine/src/__tests__/project-engine.test.ts`), and a self-healing test proving an **override task is processed while an override-less sibling stays skipped** (`packages/engine/src/__tests__/self-healing.test.ts`) — the latter is the canonical shape: two tasks differing only in `autoMerge` under global-OFF, asserting divergent outcomes. + +## Related Issues + +- Runfusion/Fusion#1356 — the fix PR +- Runfusion/Fusion#1150, Runfusion/Fusion#1152, Runfusion/Fusion#1153 — the per-task auto-merge feature trio (data model + resolver, engine gating, dashboard control); #1152's gating claim is the gap this bug exposed +- Runfusion/Fusion#753 (FN-5147), Runfusion/Fusion#690 (FN-5052) — prior global `autoMerge:false` stall/lifecycle handling that the sweeps' guards came from +- AGENTS.md → "`autoMerge: false` callout (FN-5147)" — standing lifecycle rule this fix extends to per-task granularity diff --git a/docs/storage.md b/docs/storage.md index f44ead0681..55e98d9700 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -15,10 +15,20 @@ - Archived-task flows (`archiveTask`, archived cleanup/migration) still hard-delete from the active `tasks` table after copying to cold storage (`archive.db`). - ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`. -### Agent log clearing (FN-5143) +### Agent log storage + soft-delete visibility (FN-5143 / FN-5911) -- `TaskStore.deleteTask` now clears `agentLogEntries` rows for the soft-deleted task in the same transaction that writes `deletedAt`, so downstream `getAgentLogs*` / `getAgentLogCount` calls observe zero logs immediately. -- This is soft-delete-specific cleanup; archived-task agent log snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged. +- Agent logs are no longer stored in SQLite. Each task now appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. +- `TaskStore.deleteTask` keeps that JSONL file on disk for forensics, but all live read APIs (`getAgentLogs*`, `getAgentLogCount`) gate on task liveness and return zero entries once `deletedAt` is set. +- Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged in spirit: archive payloads still embed a capped agent-log snapshot, now sourced from the JSONL file instead of `fusion.db`. +- Retention is now independent from SQLite operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). + +### Activity-log no-op `task:moved` cleanup (FN-5940) + +- `TaskStore` now defends the invariant that `activityLog` never records a `task:moved` row when `metadata.from === metadata.to`. +- Defense is layered: the `task:moved` listener skips same-column transitions, and source emitters skip no-op `archived -> archived` / same-column polling re-emits before subscribers see them. +- Existing junk rows are removed by a one-time init migration guarded by `__meta.noOpTaskMovedActivityCleanupVersion = "1"`. +- The cleanup deletes only rows matching `type = 'task:moved'` where `json_extract(metadata, '$.from') = json_extract(metadata, '$.to')`; legitimate distinct-column moves are preserved. +- The migration does **not** run `VACUUM` automatically. After the delete lands on a large disk-backed DB, run `fn db --vacuum` manually to reclaim the freed space from the SQLite file. ### Dashboard delete-event handling (FN-5135) diff --git a/docs/testing.md b/docs/testing.md index 0c25822453..24d4d7e032 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -98,3 +98,12 @@ Prefer `it.each` over copy-pasted `it()` blocks. When trimming, keep: first case - Do **not** add net-new real-network calls, real-`setTimeout` polling loops, or mock-the-world component shells when a narrower seam exists. - Use the canonical taxonomy in **What NOT to write** and **What TO keep unconditionally** when deciding trim vs keep. - See `docs/test-speed-audit-FN-5048.md` for the measured baseline offender list and optimization priorities. + +### Surface Enumeration checklist + +Copy this checklist into a bug-fix task's `## Surface Enumeration` section and make the regression tests prove the invariant across every checked surface. See `AGENTS.md` → **Standing Rule: Fix the Invariant, Not the Repro (FN-5893)** for the enforced planning/review contract. + +- [ ] Providers / bridges / execution paths touched by the invariant +- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior +- [ ] Empty / undefined / duplicate / populated data states +- [ ] Shared hooks / components / modules / helpers reusing the logic diff --git a/package.json b/package.json index 5029049d85..7580556bdf 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,15 @@ "dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win" }, "pnpm": { + "ignoredBuiltDependencies": [ + "@google/genai", + "better-sqlite3", + "cpu-features", + "electron-winstaller", + "keytar", + "sharp", + "ssh2" + ], "onlyBuiltDependencies": [ "@homebridge/node-pty-prebuilt-multiarch", "electron", diff --git a/packages/cli/bin.mjs b/packages/cli/bin.mjs new file mode 100755 index 0000000000..54bbb24d6d --- /dev/null +++ b/packages/cli/bin.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageDir = dirname(fileURLToPath(import.meta.url)); +const distEntry = resolve(packageDir, "dist", "bin.js"); + +try { + await access(distEntry, constants.F_OK); +} catch { + globalThis.console.error( + `Fusion CLI build output is missing at ${distEntry}. Run \`pnpm build\` before invoking this source checkout.`, + ); + globalThis.process.exit(1); +} + +await import(pathToFileURL(distEntry).href); diff --git a/packages/cli/package.json b/packages/cli/package.json index c0ae9e86dd..784b1aace3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,8 +12,8 @@ "pi-package" ], "bin": { - "fn": "./dist/bin.js", - "fusion": "./dist/bin.js" + "fn": "./bin.mjs", + "fusion": "./bin.mjs" }, "pi": { "extensions": [ @@ -28,6 +28,7 @@ "access": "public" }, "files": [ + "bin.mjs", "dist/**/*.js", "dist/**/*.d.ts", "dist/**/*.d.ts.map", diff --git a/packages/cli/src/__tests__/bin-targets.test.ts b/packages/cli/src/__tests__/bin-targets.test.ts new file mode 100644 index 0000000000..bb0515de59 --- /dev/null +++ b/packages/cli/src/__tests__/bin-targets.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; +import fg from "fast-glob"; +import { parse } from "yaml"; + +const cliRoot = join(__dirname, "..", ".."); +const workspaceRoot = join(cliRoot, "..", ".."); + +type PackageManifest = { + name?: string; + bin?: string | Record; +}; + +type WorkspacePackage = { + dir: string; + manifestPath: string; + manifest: PackageManifest; +}; + +function loadWorkspacePatterns(): string[] { + const workspaceManifestPath = join(workspaceRoot, "pnpm-workspace.yaml"); + const workspaceManifest = parse(readFileSync(workspaceManifestPath, "utf-8")) as { + packages?: string[]; + }; + return workspaceManifest.packages ?? []; +} + +function listWorkspacePackages(): WorkspacePackage[] { + const packageJsonPaths = fg + .sync(loadWorkspacePatterns().map((pattern) => `${pattern}/package.json`), { + cwd: workspaceRoot, + absolute: true, + onlyFiles: true, + unique: true, + }) + .sort((a, b) => a.localeCompare(b)); + + return packageJsonPaths.map((manifestPath) => ({ + dir: dirname(manifestPath), + manifestPath, + manifest: JSON.parse(readFileSync(manifestPath, "utf-8")) as PackageManifest, + })); +} + +function listBins(manifest: PackageManifest): Array<[string, string]> { + if (!manifest.bin) return []; + if (typeof manifest.bin === "string") { + const fallbackName = manifest.name ?? ""; + return [[fallbackName, manifest.bin]]; + } + return Object.entries(manifest.bin); +} + +describe("workspace bin targets", () => { + const packagesWithBins = listWorkspacePackages().filter((pkg) => listBins(pkg.manifest).length > 0); + + it("covers all workspace packages that declare bins", () => { + const packageNames = packagesWithBins.map((pkg) => pkg.manifest.name).sort(); + expect(packageNames).toEqual([ + "@runfusion/fusion", + "runfusion.ai", + ]); + }); + + it.each( + packagesWithBins.flatMap((pkg) => + listBins(pkg.manifest).map(([binName, target]) => ({ + packageName: pkg.manifest.name ?? pkg.manifestPath, + packageDir: pkg.dir, + binName, + target, + })), + ), + )( + '$packageName bin "$binName" points at a committed non-dist file', + ({ packageDir, target }) => { + const normalizedTarget = normalize(target).replace(/^\.([/\\])/, ""); + const resolvedTarget = join(packageDir, normalizedTarget); + + expect(normalizedTarget).not.toMatch(/^dist(?:[/\\]|$)/); + expect(existsSync(resolvedTarget)).toBe(true); + }, + ); +}); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 1f448db735..2b4f7fd380 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -1132,9 +1132,10 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega }); describe("fn_mission_show", () => { - it("returns mission with hierarchy", async () => { - // Create mission + it("returns mission with hierarchy and linked goals", async () => { const createTool = api.tools.get("fn_mission_create")!; + const goalTool = api.tools.get("fn_goal_create")!; + const linkTool = api.tools.get("fn_mission_link_goal")!; const created = await createTool.execute( "c1", { title: "Test Mission" }, @@ -1142,6 +1143,20 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega undefined, makeCtx(tmpDir), ); + const goal = await goalTool.execute( + "g1", + { title: "Connect mission work to goals" }, + undefined, + undefined, + makeCtx(tmpDir), + ); + await linkTool.execute( + "link-1", + { missionId: created.details.missionId, goalId: goal.details.goalId }, + undefined, + undefined, + makeCtx(tmpDir), + ); const showTool = api.tools.get("fn_mission_show")!; const result = await showTool.execute( @@ -1154,6 +1169,11 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega expect(result.details.mission).toBeDefined(); expect(result.content[0].text).toContain("Test Mission"); + expect(result.content[0].text).toContain("Linked Goals:"); + expect(result.content[0].text).toContain(`- ${goal.details.goalId}: Connect mission work to goals`); + expect(result.details.mission.linkedGoals).toEqual([ + expect.objectContaining({ id: goal.details.goalId, title: "Connect mission work to goals" }), + ]); }); it("renders acceptanceCriteria / verification for milestones, slices, and features", async () => { @@ -1212,6 +1232,30 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega expect(result.details.mission.milestones[0].acceptanceCriteria).toBe(longValue); }); + it("renders an empty linked goals state when no goals are linked", async () => { + const createTool = api.tools.get("fn_mission_create")!; + const created = await createTool.execute( + "c1", + { title: "Mission Without Goals" }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + const showTool = api.tools.get("fn_mission_show")!; + const result = await showTool.execute( + "call-1", + { id: created.details.missionId }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + expect(result.content[0].text).toContain("Linked Goals:"); + expect(result.content[0].text).toContain("No linked goals."); + expect(result.details.mission.linkedGoals).toEqual([]); + }); + it("returns error when mission not found", async () => { const showTool = api.tools.get("fn_mission_show")!; const result = await showTool.execute( diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 30f9d3a6af..b80af0a358 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -36,14 +36,16 @@ describe("CLI package.json publishing config", () => { const pkg = loadPackageJson("cli"); const prepackScript = loadCliPrepackScript(); - it('has "bin" field with fn pointing to ./dist/bin.js', () => { + it('has "bin" field with fn/fusion pointing to committed launcher', () => { expect(pkg.bin).toBeDefined(); - expect(pkg.bin.fn).toBe("./dist/bin.js"); + expect(pkg.bin.fn).toBe("./bin.mjs"); + expect(pkg.bin.fusion).toBe("./bin.mjs"); }); - it('has "files" array with refined globs for dist output', () => { + it('has "files" array with committed launcher and refined globs for dist output', () => { expect(pkg.files).toBeDefined(); expect(Array.isArray(pkg.files)).toBe(true); + expect(pkg.files).toContain("bin.mjs"); expect(pkg.files).toContain("dist/**/*.js"); expect(pkg.files).toContain("dist/**/*.d.ts"); expect(pkg.files).toContain("dist/**/*.d.ts.map"); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 680c51583a..7e5f5476b7 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -2644,6 +2644,16 @@ export default function kbExtension(pi: ExtensionAPI) { } lines.push(""); + lines.push("Linked Goals:"); + if ((mission.linkedGoals?.length ?? 0) === 0) { + lines.push("No linked goals."); + } else { + for (const goal of mission.linkedGoals ?? []) { + lines.push(`- ${goal.id}: ${goal.title}`); + } + } + lines.push(""); + if (mission.milestones.length === 0) { lines.push("No milestones yet."); } else { diff --git a/packages/core/src/__tests__/activity-log-no-op-moved.test.ts b/packages/core/src/__tests__/activity-log-no-op-moved.test.ts new file mode 100644 index 0000000000..81eb11f0fc --- /dev/null +++ b/packages/core/src/__tests__/activity-log-no-op-moved.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rm } from "node:fs/promises"; + +import { TaskStore } from "../store.js"; +import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; + +describe("activity log task:moved no-op guard", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("does not record same-column task:moved emits and still records distinct moves", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + (store as any).emit("task:moved", { task, from: "archived", to: "archived", source: "engine" }); + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([]); + + (store as any).emit("task:moved", { task, from: "triage", to: "todo", source: "engine" }); + + const activity = await store.getActivityLog({ type: "task:moved" }); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + type: "task:moved", + taskId: task.id, + metadata: { from: "triage", to: "todo" }, + }); + }); + + it("does not record activity for same-column moveTask calls", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.moveTask(task.id, "triage"); + + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([]); + }); + + it("records legitimate moveTask transitions exactly once", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.moveTask(task.id, "todo"); + + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([ + expect.objectContaining({ + taskId: task.id, + metadata: { from: "triage", to: "todo" }, + }), + ]); + }); + + it("does not emit or record archived-to-archived polling replication no-ops", async () => { + const rootDir = makeTmpDir(); + const globalDir = makeTmpDir(); + const writer = new TaskStore(rootDir, globalDir); + const observer = new TaskStore(rootDir, globalDir); + + try { + await writer.init(); + await observer.init(); + + const task = await writer.createTask({ column: "done", description: "archive me" }); + const archived = await writer.archiveTask(task.id, false); + const movedEvents: Array<{ from: string; to: string }> = []; + observer.on("task:moved", ({ from, to }) => movedEvents.push({ from, to })); + (observer as any).taskCache.set(archived.id, { ...archived }); + (observer as any).lastKnownModified = 0; + + await (observer as any).checkForChanges(); + + expect(movedEvents).toEqual([]); + expect(await observer.getActivityLog({ type: "task:moved" })).toEqual([ + expect.objectContaining({ + taskId: task.id, + metadata: { from: "done", to: "archived" }, + }), + ]); + } finally { + writer.close(); + observer.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it("does not emit or record same-column polling observations", async () => { + const rootDir = makeTmpDir(); + const globalDir = makeTmpDir(); + const writer = new TaskStore(rootDir, globalDir); + const observer = new TaskStore(rootDir, globalDir); + + try { + await writer.init(); + await observer.init(); + + const task = await writer.createTask({ column: "todo", description: "same-column poll" }); + const movedEvents: Array<{ from: string; to: string }> = []; + observer.on("task:moved", ({ from, to }) => movedEvents.push({ from, to })); + (observer as any).taskCache.set(task.id, { ...task }); + (observer as any).lastKnownModified = 0; + + await writer.updateTask(task.id, { title: "still todo" }); + await (observer as any).checkForChanges(); + + expect(movedEvents).toEqual([]); + expect(await observer.getActivityLog({ type: "task:moved" })).toEqual([]); + } finally { + writer.close(); + observer.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/packages/core/src/__tests__/agent-log-file-store.test.ts b/packages/core/src/__tests__/agent-log-file-store.test.ts new file mode 100644 index 0000000000..57fccd4e34 --- /dev/null +++ b/packages/core/src/__tests__/agent-log-file-store.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + appendAgentLogEntriesSync, + countAgentLogEntries, + getAgentLogFilePath, + readAgentLogEntries, + readAgentLogEntriesByTimeRange, +} from "../agent-log-file-store.js"; +import { AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE } from "../agent-log-constants.js"; + +const tempDirs: string[] = []; + +function createTaskDir(): string { + const dir = mkdtempSync(join(tmpdir(), "fusion-agent-log-file-store-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("agent-log-file-store", () => { + it("appends and reads entries with stable line-number source refs", () => { + const taskDir = createTaskDir(); + + const appended = appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "first", type: "text" }, + { timestamp: "2026-01-01T00:01:00.000Z", taskId: "FN-1", text: "second", type: "tool", detail: "readme.md", agent: "executor" }, + ]); + + expect(appended.map((entry) => entry.sourceRef)).toEqual([ + "agentLog:FN-1:1", + "agentLog:FN-1:2", + ]); + expect(readAgentLogEntries(taskDir)).toEqual(appended); + }); + + it("supports most-recent tail pagination with offset", () => { + const taskDir = createTaskDir(); + appendAgentLogEntriesSync( + taskDir, + Array.from({ length: 5 }, (_, index) => ({ + timestamp: `2026-01-01T00:0${index}:00.000Z`, + taskId: "FN-1", + text: `entry-${index}`, + type: "text" as const, + })), + ); + + expect(readAgentLogEntries(taskDir, { limit: 2 }).map((entry) => entry.text)).toEqual(["entry-3", "entry-4"]); + expect(readAgentLogEntries(taskDir, { limit: 2, offset: 2 }).map((entry) => entry.text)).toEqual(["entry-1", "entry-2"]); + expect(readAgentLogEntries(taskDir, { limit: 2, offset: 5 })).toEqual([]); + }); + + it("filters by type and inclusive time range", () => { + const taskDir = createTaskDir(); + appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "before", type: "text" }, + { timestamp: "2026-01-01T01:00:00.000Z", taskId: "FN-1", text: "tool", type: "tool", detail: "ls" }, + { timestamp: "2026-01-01T02:00:00.000Z", taskId: "FN-1", text: "thinking", type: "thinking" }, + { timestamp: "2026-01-01T03:00:00.000Z", taskId: "FN-1", text: "after", type: "text" }, + ]); + + expect(readAgentLogEntries(taskDir, { type: "text" }).map((entry) => entry.text)).toEqual(["before", "after"]); + expect( + readAgentLogEntriesByTimeRange(taskDir, "2026-01-01T01:00:00.000Z", "2026-01-01T02:00:00.000Z").map((entry) => entry.text), + ).toEqual(["tool", "thinking"]); + expect(countAgentLogEntries(taskDir, { type: "text" })).toBe(2); + }); + + it("truncates oversized tool detail on append and on read of legacy oversized rows", () => { + const taskDir = createTaskDir(); + const oversized = "X".repeat(5_000); + appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "Bash", type: "tool_result", detail: oversized }, + ]); + + const filePath = getAgentLogFilePath(taskDir); + writeFileSync( + filePath, + `${JSON.stringify({ timestamp: "2026-01-01T01:00:00.000Z", taskId: "FN-1", text: "legacy", type: "tool_error", detail: oversized })}\n`, + "utf8", + ); + + const [legacy] = readAgentLogEntries(taskDir); + expect(legacy.detail).toContain(AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE.trim()); + expect(legacy.detail!.length).toBeLessThan(oversized.length); + }); + + it("skips malformed and partial lines with a warning", () => { + const taskDir = createTaskDir(); + const filePath = getAgentLogFilePath(taskDir); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + writeFileSync( + filePath, + [ + JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "good", type: "text" }), + "{bad-json", + JSON.stringify({ taskId: "FN-1", text: "missing timestamp", type: "text" }), + "", + ].join("\n"), + "utf8", + ); + + const entries = readAgentLogEntries(taskDir); + expect(entries.map((entry) => entry.text)).toEqual(["good"]); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it("treats a missing file as empty", () => { + const taskDir = createTaskDir(); + expect(readAgentLogEntries(taskDir)).toEqual([]); + expect(countAgentLogEntries(taskDir)).toBe(0); + }); +}); diff --git a/packages/core/src/__tests__/agent-log-migration.test.ts b/packages/core/src/__tests__/agent-log-migration.test.ts new file mode 100644 index 0000000000..35532eb7e4 --- /dev/null +++ b/packages/core/src/__tests__/agent-log-migration.test.ts @@ -0,0 +1,186 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { countAgentLogEntries, getAgentLogFilePath, readAgentLogEntries } from "../agent-log-file-store.js"; +import { SCHEMA_VERSION } from "../db.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("Agent log migration: SQLite → JSONL", () => { + const harness = createTaskStoreTestHarness(); + + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("migrates legacy agentLogEntries rows to per-task JSONL files and rewrites citations", async () => { + await harness.reopenDiskBackedStore(); + const store = harness.store(); + const taskA = await harness.createTestTask(); + const taskB = await harness.createTestTask(); + const db = store.getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + + const insertLegacyRow = db.prepare(` + INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id + `); + const legacyA1 = insertLegacyRow.get(taskA.id, "2026-06-02T00:00:01.000Z", "task-a-1 G-MIG001", "text", null, "executor") as { id: number }; + const legacyB1 = insertLegacyRow.get(taskB.id, "2026-06-02T00:00:02.000Z", "task-b-1", "tool", '{"tool":"scan"}', "reviewer") as { id: number }; + const legacyA2 = insertLegacyRow.get(taskA.id, "2026-06-02T00:00:03.000Z", "task-a-2 G-MIG001", "text", null, "executor") as { id: number }; + + const insertCitation = db.prepare(` + INSERT INTO goal_citations (goalId, agentId, taskId, surface, sourceRef, snippet, timestamp) + VALUES (?, ?, ?, 'agent_log', ?, ?, ?) + `); + insertCitation.run("G-MIG001", "executor", taskA.id, `agentLog:${legacyA1.id}`, "task-a-1 G-MIG001", "2026-06-02T00:00:01.000Z"); + insertCitation.run("G-MIG001", "executor", taskA.id, `agentLog:${legacyA2.id}`, "task-a-2 G-MIG001", "2026-06-02T00:00:03.000Z"); + + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + expect(existsSync(getAgentLogFilePath(taskDir(taskA.id)))).toBe(false); + expect(existsSync(getAgentLogFilePath(taskDir(taskB.id)))).toBe(false); + + await harness.reopenDiskBackedStore(); + + const migratedStore = harness.store(); + const migratedDb = migratedStore.getDatabase(); + + expect(migratedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + const hasTable = migratedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + expect(hasTable).toBeUndefined(); + + expect(countAgentLogEntries(taskDir(taskA.id))).toBe(2); + expect(countAgentLogEntries(taskDir(taskB.id))).toBe(1); + expect(readAgentLogEntries(taskDir(taskA.id)).map((entry) => entry.text)).toEqual(["task-a-1 G-MIG001", "task-a-2 G-MIG001"]); + expect(readAgentLogEntries(taskDir(taskB.id)).map((entry) => entry.text)).toEqual(["task-b-1"]); + + const citations = migratedStore.listGoalCitations({ goalId: "G-MIG001" }); + expect(new Set(citations.map((citation) => citation.sourceRef))).toEqual( + new Set([`agentLog:${taskA.id}:1`, `agentLog:${taskA.id}:2`]), + ); + }); + + it("does not create agentLogEntries table on fresh init", async () => { + const store = harness.store(); + const db = store.getDatabase(); + + const hasTable = db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(hasTable).toBeUndefined(); + }); + + it("sets the migration guard on fresh init", async () => { + const store = harness.store(); + const db = store.getDatabase(); + const migrationRow = db + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("agentLogEntriesToFileMigrationVersion") as { value: string } | undefined; + + expect(migrationRow?.value).toBe("1"); + }); + + it("handles empty legacy agentLogEntries tables gracefully", async () => { + await harness.reopenDiskBackedStore(); + const db = harness.store().getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const migrationRow = reopenedDb + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("agentLogEntriesToFileMigrationVersion") as { value: string } | undefined; + const hasTable = reopenedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(migrationRow?.value).toBe("1"); + expect(reopenedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + expect(hasTable).toBeUndefined(); + }); + + it("keeps file-backed citation source-refs stable after rereads", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.appendAgentLog(task.id, "working on G-MIG001", "text", undefined, "executor"); + await store.getAgentLogs(task.id); + + const firstRead = store.listGoalCitations({ goalId: "G-MIG001" }); + await store.getAgentLogs(task.id, { limit: 10 }); + const secondRead = store.listGoalCitations({ goalId: "G-MIG001" }); + + expect(firstRead).toHaveLength(1); + expect(secondRead).toHaveLength(1); + expect(firstRead[0]?.sourceRef).toBe(`agentLog:${task.id}:1`); + expect(secondRead[0]?.sourceRef).toBe(firstRead[0]?.sourceRef); + }); + + it("drops the legacy table once and does not recreate it on later init", async () => { + await harness.reopenDiskBackedStore(); + const db = harness.store().getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + await harness.reopenDiskBackedStore(); + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const hasTable = reopenedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(reopenedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + expect(hasTable).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/agent-log-retention.test.ts b/packages/core/src/__tests__/agent-log-retention.test.ts new file mode 100644 index 0000000000..02fe7b124f --- /dev/null +++ b/packages/core/src/__tests__/agent-log-retention.test.ts @@ -0,0 +1,208 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + countAgentLogEntries, + getAgentLogFilePath, + pruneAgentLogFiles, + readAgentLogEntries, +} from "../agent-log-file-store.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("Agent log file retention pruning", () => { + const harness = createTaskStoreTestHarness(); + + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("returns zeroed counts when retention is disabled", () => { + const result = pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), 0); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("returns zeroed counts when retention is negative", () => { + const result = pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), -5); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("returns zeroed counts when tasksDir does not exist", () => { + const result = pruneAgentLogFiles("/nonexistent/path", 30); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("removes old entries and keeps recent ones", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + // Write entries with controlled timestamps + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const oldEntry = JSON.stringify({ + timestamp: "2020-01-01T00:00:00.000Z", + taskId: task.id, + text: "old-entry", + type: "text", + }); + const recentEntry = JSON.stringify({ + timestamp: "2099-06-01T00:00:00.000Z", + taskId: task.id, + text: "recent-entry", + type: "text", + }); + writeFileSync(filePath, `${oldEntry}\n${recentEntry}\n`, "utf8"); + + expect(countAgentLogEntries(td)).toBe(2); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(result.prunedFiles).toBe(1); + expect(result.freedBytes).toBeGreaterThan(0); + + const remaining = readAgentLogEntries(td); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.text).toBe("recent-entry"); + }); + + it("deletes the file when all entries are pruned", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const oldEntry = JSON.stringify({ + timestamp: "2020-01-01T00:00:00.000Z", + taskId: task.id, + text: "old-entry-1", + type: "text", + }); + writeFileSync(filePath, `${oldEntry}\n`, "utf8"); + + expect(existsSync(filePath)).toBe(true); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(result.prunedFiles).toBe(1); + expect(existsSync(filePath)).toBe(false); + }); + + it("keeps malformed lines intact (does not destroy unparseable data)", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const content = "not-valid-json\n"; + writeFileSync(filePath, content, "utf8"); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + // Malformed line is kept, nothing pruned + expect(result.prunedEntries).toBe(0); + expect(existsSync(filePath)).toBe(true); + }); + + it("scopes pruning to specified task IDs only", async () => { + const store = harness.store(); + const task1 = await harness.createTestTask(); + const task2 = await harness.createTestTask(); + + const td1 = taskDir(task1.id); + const td2 = taskDir(task2.id); + mkdirSync(td1, { recursive: true }); + mkdirSync(td2, { recursive: true }); + + const oldEntry = (id: string) => + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: id, text: "old", type: "text" }); + + writeFileSync(getAgentLogFilePath(td1), `${oldEntry(task1.id)}\n`, "utf8"); + writeFileSync(getAgentLogFilePath(td2), `${oldEntry(task2.id)}\n`, "utf8"); + + // Only prune task1 + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task1.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(countAgentLogEntries(td1)).toBe(0); + expect(countAgentLogEntries(td2)).toBe(1); + }); + + it("store.pruneAgentLogFiles only prunes inactive tasks", async () => { + const store = harness.store(); + const activeTask = await harness.createTestTask(); + const deletedTask = await harness.createTestTask(); + + // Write entries for both tasks + const activeTd = taskDir(activeTask.id); + const deletedTd = taskDir(deletedTask.id); + + const oldEntry = (id: string) => + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: id, text: "old", type: "text" }); + + mkdirSync(activeTd, { recursive: true }); + mkdirSync(deletedTd, { recursive: true }); + writeFileSync(getAgentLogFilePath(activeTd), `${oldEntry(activeTask.id)}\n`, "utf8"); + writeFileSync(getAgentLogFilePath(deletedTd), `${oldEntry(deletedTask.id)}\n`, "utf8"); + + // Soft-delete one task + await store.deleteTask(deletedTask.id); + + const result = store.pruneAgentLogFiles(30); + + expect(result.prunedEntries).toBe(1); + // Active task's log is untouched + expect(countAgentLogEntries(activeTd)).toBe(1); + // Deleted task's old entries are pruned + expect(countAgentLogEntries(deletedTd)).toBe(0); + }); + + it("leaves in-range entries intact when mixed old/recent entries exist", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + + const lines = [ + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: task.id, text: "old-1", type: "text" }), + JSON.stringify({ timestamp: "2099-06-01T00:00:00.000Z", taskId: task.id, text: "recent-1", type: "text" }), + JSON.stringify({ timestamp: "2020-02-01T00:00:00.000Z", taskId: task.id, text: "old-2", type: "text" }), + JSON.stringify({ timestamp: "2099-07-01T00:00:00.000Z", taskId: task.id, text: "recent-2", type: "text" }), + ]; + writeFileSync(filePath, lines.join("\n") + "\n", "utf8"); + + pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), 30, new Set([task.id])); + + const remaining = readAgentLogEntries(td); + expect(remaining.map((e) => e.text)).toEqual(["recent-1", "recent-2"]); + }); +}); diff --git a/packages/core/src/__tests__/agent-prompts.test.ts b/packages/core/src/__tests__/agent-prompts.test.ts index 50b6969fff..9d4a7de5a9 100644 --- a/packages/core/src/__tests__/agent-prompts.test.ts +++ b/packages/core/src/__tests__/agent-prompts.test.ts @@ -287,6 +287,28 @@ describe("resolveAgentPrompt", () => { expect(coreBlock).toBe(engineBlock); }); + it("built-in triage prompt requires surface enumeration for bug-fix specs", () => { + const triagePrompt = resolveAgentPrompt("triage"); + expect(triagePrompt).toContain("## Surface Enumeration"); + expect(triagePrompt).toContain("spec MUST include a `## Surface Enumeration` section"); + expect(triagePrompt).toContain("blocking REVISE"); + }); + + it("built-in reviewer prompts reject missing surface enumeration and repro-only bug-fix tests", () => { + const defaultReviewer = resolveAgentPrompt("reviewer"); + const strictReviewer = resolveAgentPrompt("reviewer", { + roleAssignments: { reviewer: "strict-reviewer" }, + }); + + for (const prompt of [defaultReviewer, strictReviewer]) { + expect(prompt).toContain("**Surface enumeration:**"); + expect(prompt).toContain("Missing or incomplete coverage is a blocking REVISE"); + expect(prompt).toContain("repro-only regression test"); + expect(prompt).toContain("spanning the `## Surface Enumeration` checklist"); + expect(prompt).toContain("FN-5797/FN-5875/FN-5919"); + } + }); + it("default role prompts include explicit heartbeat run guidance", () => { expect(resolveAgentPrompt("executor")).toContain("## Heartbeat Run Behavior"); expect(resolveAgentPrompt("triage")).toContain("## Heartbeat Run Behavior"); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index a2adaf1553..b3797e4e9c 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -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(101); + expect(db.getSchemaVersion()).toBe(102); 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(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); 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(101); + expect(db.getSchemaVersion()).toBe(102); 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(101); + expect(db.getSchemaVersion()).toBe(102); 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(101); + expect(db.getSchemaVersion()).toBe(102); 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(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 3166bb2a10..f90e18658b 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -265,7 +265,7 @@ describe("Database", () => { expect(tableNames).toContain("agents"); expect(tableNames).toContain("agentHeartbeats"); expect(tableNames).toContain("agentRuns"); - expect(tableNames).toContain("agentLogEntries"); + // agentLogEntries removed in migration 102 — now stored in per-task JSONL files expect(tableNames).toContain("agentTaskSessions"); expect(tableNames).toContain("agentApiKeys"); expect(tableNames).toContain("agentConfigRevisions"); @@ -324,8 +324,7 @@ describe("Database", () => { expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey"); expect(indexNames).toContain("idxAgentRunsAgentIdStartedAt"); expect(indexNames).toContain("idxAgentRunsStatus"); - expect(indexNames).toContain("idxAgentLogEntriesTaskIdTimestamp"); - expect(indexNames).toContain("idxAgentLogEntriesTaskIdType"); + // agentLogEntries indexes removed in migration 102 — now stored in per-task JSONL files expect(indexNames).toContain("idxAgentApiKeysAgentId"); expect(indexNames).toContain("idxAgentConfigRevisionsAgentIdCreatedAt"); expect(indexNames).toContain("idxTasksCreatedAt"); @@ -335,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -394,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1464,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1489,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -1528,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1569,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1641,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1881,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1955,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); 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" }]); @@ -1979,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); 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" }]); @@ -2083,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2302,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(101); + expect(localDb.getSchemaVersion()).toBe(102); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2613,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2767,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(101); + expect(migrated.getSchemaVersion()).toBe(102); 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); @@ -2813,7 +2812,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(101); + expect(migrated.getSchemaVersion()).toBe(102); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2840,7 +2839,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(101); + expect(fresh.getSchemaVersion()).toBe(102); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goal-citations-store.test.ts b/packages/core/src/__tests__/goal-citations-store.test.ts index d47601d419..74b280a599 100644 --- a/packages/core/src/__tests__/goal-citations-store.test.ts +++ b/packages/core/src/__tests__/goal-citations-store.test.ts @@ -1,5 +1,8 @@ +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as extractor from "../goal-citation-extractor.js"; +import { getAgentLogFilePath, readAgentLogEntries } from "../agent-log-file-store.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("goal citations store integration", () => { @@ -29,7 +32,7 @@ describe("goal citations store integration", () => { taskId: task.id, surface: "agent_log", }); - expect(rows[0]?.sourceRef).toMatch(/^agentLog:/); + expect(rows[0]?.sourceRef).toMatch(/^agentLog:[^:]+:\d+$/); }); it("does not record citations for near-miss log text", async () => { @@ -68,6 +71,7 @@ describe("goal citations store integration", () => { const rows = store.listGoalCitations({ goalId: "G-BATCH001" }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ surface: "agent_log", agentId: "executor", taskId: task.id }); + expect(rows[0]?.sourceRef).toMatch(new RegExp(`^agentLog:${task.id}:\\d+$`)); }); it("deduplicates goal citations per goalId+surface+sourceRef", () => { @@ -127,7 +131,7 @@ describe("goal citations store integration", () => { goalId: "G-WIN", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:1", + sourceRef: "agentLog:FN-WIN-1:1", snippet: "G-WIN older", timestamp: "2026-01-01T00:00:00.000Z", }, @@ -135,7 +139,7 @@ describe("goal citations store integration", () => { goalId: "G-WIN", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:2", + sourceRef: "agentLog:FN-WIN-1:2", snippet: "G-WIN newer", timestamp: "2026-01-02T00:00:00.000Z", }, @@ -143,7 +147,7 @@ describe("goal citations store integration", () => { goalId: "G-OTHER", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:3", + sourceRef: "agentLog:FN-OTHER-1:1", snippet: "other", timestamp: "2026-01-02T00:00:00.000Z", }, @@ -156,7 +160,31 @@ describe("goal citations store integration", () => { }); expect(rows).toHaveLength(1); - expect(rows[0]?.sourceRef).toBe("agentLog:2"); + expect(rows[0]?.sourceRef).toBe("agentLog:FN-WIN-1:2"); + }); + + it("keeps citation source refs stable and resolvable after re-reading logs from file", async () => { + const store = harness.store(); + const task = await store.createTask({ title: "Task", description: "desc" }); + + await store.appendAgentLogBatch([ + { taskId: task.id, text: "tracking G-STABLE001", type: "text", agent: "executor" }, + { taskId: task.id, text: "tracking G-STABLE002", type: "text", agent: "executor" }, + ]); + + const rows = store.listGoalCitations({ taskId: task.id, surface: "agent_log" }); + expect(rows.map((row) => row.sourceRef)).toEqual([ + `agentLog:${task.id}:2`, + `agentLog:${task.id}:1`, + ]); + + const persistedLogs = readAgentLogEntries(join(harness.rootDir(), ".fusion", "tasks", task.id)); + const bySourceRef = new Map(persistedLogs.map((entry) => [entry.sourceRef, entry])); + expect(bySourceRef.get(`agentLog:${task.id}:1`)?.text).toBe("tracking G-STABLE001"); + expect(bySourceRef.get(`agentLog:${task.id}:2`)?.text).toBe("tracking G-STABLE002"); + expect(getAgentLogFilePath(join(harness.rootDir(), ".fusion", "tasks", task.id))).toContain( + `/tasks/${task.id}/agent-log.jsonl`, + ); }); it("does not throw when citation scan fails during appendAgentLog", async () => { diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 744cdb31d4..dacf02a940 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index e1853eadc5..02ffb55712 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -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(101); + expect(db1.getSchemaVersion()).toBe(102); 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(101); + expect(db3.getSchemaVersion()).toBe(102); // 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(101); + expect(db1.getSchemaVersion()).toBe(102); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(101); + expect(db2.getSchemaVersion()).toBe(102); 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(101); + expect(db1.getSchemaVersion()).toBe(102); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ff3fcfbf74..effc22a84f 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 9c1cf0dc90..2790b84fb6 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js"; +import { GoalStore } from "../goal-store.js"; import { Database } from "../db.js"; import type { MissionFeature } from "../mission-types.js"; import { mkdtempSync } from "node:fs"; @@ -25,11 +26,19 @@ function createTaskInDb( ).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null); } +function createGoalInDb(database: Database, goalId: string, title = "Test goal"): void { + const now = new Date().toISOString(); + database.prepare( + "INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)" + ).run(goalId, title, null, "active", now, now); +} + describe("MissionStore", () => { let tmpDir: string; let fusionDir: string; let db: Database; let store: MissionStore; + let goalStore: GoalStore; beforeEach(() => { tmpDir = makeTmpDir(); @@ -40,6 +49,7 @@ describe("MissionStore", () => { db = new Database(fusionDir, { inMemory: true }); db.init(); store = new MissionStore(fusionDir, db); + goalStore = new GoalStore(fusionDir, db); }); afterEach(async () => { @@ -235,6 +245,8 @@ describe("MissionStore", () => { completedMilestones: 0, totalFeatures: 0, completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); }); @@ -300,6 +312,31 @@ describe("MissionStore", () => { expect(summary.progressPercent).toBe(33); }); + it("getMissionSummary reports linked goal counts", () => { + const mission = store.createMission({ title: "Goal-linked mission" }); + createGoalInDb(db, "G-001", "North Star"); + createGoalInDb(db, "G-002", "Reliability"); + + expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(0); + + store.linkGoal(mission.id, "G-001"); + store.linkGoal(mission.id, "G-002"); + + expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(2); + }); + + it("getMissionSummary reports unfiltered event counts", () => { + const mission = store.createMission({ title: "Eventful mission" }); + + expect(store.getMissionSummary(mission.id).eventCount).toBe(0); + + store.logMissionEvent(mission.id, "mission_started", "started"); + store.logMissionEvent(mission.id, "warning", "warning"); + store.logMissionEvent(mission.id, "error", "error"); + + expect(store.getMissionSummary(mission.id).eventCount).toBe(3); + }); + it("findNextPendingSlice skips completed slices in earlier milestones", () => { const mission = store.createMission({ title: "Next pending" }); const m1 = store.addMilestone(mission.id, { title: "M1" }); @@ -373,6 +410,8 @@ describe("MissionStore", () => { completedMilestones: 0, totalFeatures: 0, completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); @@ -383,6 +422,8 @@ describe("MissionStore", () => { completedMilestones: 0, totalFeatures: 0, completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); @@ -393,6 +434,8 @@ describe("MissionStore", () => { completedMilestones: 1, totalFeatures: 2, completedFeatures: 1, + linkedGoalCount: 0, + eventCount: 0, progressPercent: 50, }); }); @@ -405,8 +448,13 @@ describe("MissionStore", () => { store.updateFeature(f1.id, { status: "done" }); const f2 = store.addFeature(slice.id, { title: "F2" }); store.updateFeature(f2.id, { status: "done" }); - const f3 = store.addFeature(slice.id, { title: "F3" }); - // f3 not done + store.addFeature(slice.id, { title: "F3" }); + createGoalInDb(db, "G-003", "North Star"); + createGoalInDb(db, "G-004", "Reliability"); + store.linkGoal(mission.id, "G-003"); + store.linkGoal(mission.id, "G-004"); + store.logMissionEvent(mission.id, "mission_started", "started"); + store.logMissionEvent(mission.id, "warning", "warning"); const singleSummary = store.getMissionSummary(mission.id); const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!; @@ -415,6 +463,8 @@ describe("MissionStore", () => { expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones); expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures); expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures); + expect(batchedResult.summary.linkedGoalCount).toBe(singleSummary.linkedGoalCount); + expect(batchedResult.summary.eventCount).toBe(singleSummary.eventCount); expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent); }); @@ -1838,6 +1888,8 @@ describe("MissionStore", () => { title: "Hierarchy Test", description: "Testing full tree loading", }); + const linkedGoal = goalStore.createGoal({ title: "Ship linked goal visibility" }); + store.linkGoal(mission.id, linkedGoal.id); const m1 = store.addMilestone(mission.id, { title: "Milestone 1" }); const m2 = store.addMilestone(mission.id, { title: "Milestone 2" }); const s1 = store.addSlice(m1.id, { title: "Slice 1" }); @@ -1849,6 +1901,7 @@ describe("MissionStore", () => { expect(withHierarchy.id).toBe(mission.id); expect(withHierarchy.title).toBe("Hierarchy Test"); + expect(withHierarchy.linkedGoals).toEqual([linkedGoal]); expect(withHierarchy.milestones).toHaveLength(2); const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!; @@ -1859,6 +1912,32 @@ describe("MissionStore", () => { expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined(); expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined(); }); + + it("returns an empty linkedGoals array when no goals are linked", () => { + const mission = store.createMission({ title: "Hierarchy without goals" }); + + const withHierarchy = store.getMissionWithHierarchy(mission.id)!; + + expect(withHierarchy.linkedGoals).toEqual([]); + }); + + it("reports detail eventCount consistently with mission summaries", () => { + const mission = store.createMission({ title: "Hierarchy event counts" }); + + const emptyHierarchy = store.getMissionWithHierarchy(mission.id)!; + const emptySummary = store.getMissionSummary(mission.id); + expect(emptyHierarchy.eventCount).toBe(0); + expect(emptyHierarchy.eventCount).toBe(emptySummary.eventCount); + + store.logMissionEvent(mission.id, "mission_started", "started"); + store.logMissionEvent(mission.id, "warning", "warning"); + store.logMissionEvent(mission.id, "error", "error"); + + const populatedHierarchy = store.getMissionWithHierarchy(mission.id)!; + const populatedSummary = store.getMissionSummary(mission.id); + expect(populatedHierarchy.eventCount).toBe(3); + expect(populatedHierarchy.eventCount).toBe(populatedSummary.eventCount); + }); }); // ── Transaction Tests ──────────────────────────────────────────────── @@ -3324,14 +3403,33 @@ describe("MissionStore", () => { expect(linked[0].sourceFeatureId).toBe(feature.id); }); + it("lazily re-links exactly one managed assertion for legacy acceptance-criteria features", () => { + const mission = store.createMission({ title: "M" }); + const milestone = store.addMilestone(mission.id, { title: "MS" }); + const slice = store.addSlice(milestone.id, { title: "SL" }); + const feature = store.addFeature(slice.id, { title: "Feature", acceptanceCriteria: "AC text" }); + const [managed] = store.listAssertionsForFeature(feature.id); + store.unlinkFeatureFromAssertion(feature.id, managed.id); + store.deleteContractAssertion(managed.id); + + const first = store.ensureFeatureAssertionLinked(feature.id); + const second = store.ensureFeatureAssertionLinked(feature.id); + + expect(first).toHaveLength(1); + expect(first[0].assertion).toBe("AC text"); + expect(second).toHaveLength(1); + expect(second[0].id).toBe(first[0].id); + expect(store.listAssertionsForFeature(feature.id)).toHaveLength(1); + }); + it("derives managed assertion text from description or fallback", () => { const mission = store.createMission({ title: "M" }); const milestone = store.addMilestone(mission.id, { title: "MS" }); const slice = store.addSlice(milestone.id, { title: "SL" }); const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" }); const fallback = store.addFeature(slice.id, { title: "Fallback Feature" }); - expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Desc text"); - expect(store.listAssertionsForFeature(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature"); + expect(store.ensureFeatureAssertionLinked(fromDescription.id)[0].assertion).toBe("Desc text"); + expect(store.ensureFeatureAssertionLinked(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature"); }); it("syncs managed assertion in place on acceptanceCriteria update", () => { @@ -3496,7 +3594,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts b/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts new file mode 100644 index 0000000000..d8cca1d6f3 --- /dev/null +++ b/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("no-op task:moved activity cleanup migration", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + await harness.reopenDiskBackedStore(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("deletes only no-op task:moved rows once and leaves later rows untouched", async () => { + const store = harness.store(); + const db = store.getDatabase(); + const task = await harness.createTestTask(); + const insert = db.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + + insert.run( + "noop-1", + "2026-06-03T00:00:01.000Z", + "task:moved", + task.id, + task.title ?? null, + "noop archived", + JSON.stringify({ from: "archived", to: "archived" }), + ); + insert.run( + "noop-2", + "2026-06-03T00:00:02.000Z", + "task:moved", + task.id, + task.title ?? null, + "noop todo", + JSON.stringify({ from: "todo", to: "todo" }), + ); + insert.run( + "move-1", + "2026-06-03T00:00:03.000Z", + "task:moved", + task.id, + task.title ?? null, + "real move", + JSON.stringify({ from: "triage", to: "todo" }), + ); + insert.run( + "created-1", + "2026-06-03T00:00:04.000Z", + "task:created", + task.id, + task.title ?? null, + "created", + null, + ); + db.prepare("DELETE FROM __meta WHERE key = ?").run("noOpTaskMovedActivityCleanupVersion"); + + await harness.reopenDiskBackedStore(); + + const migratedDb = harness.store().getDatabase(); + const movedRows = migratedDb.prepare( + "SELECT id, metadata FROM activityLog WHERE type = 'task:moved' ORDER BY id", + ).all() as Array<{ id: string; metadata: string | null }>; + const migrationRow = migratedDb + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("noOpTaskMovedActivityCleanupVersion") as { value: string } | undefined; + + expect(movedRows).toEqual([ + { + id: "move-1", + metadata: JSON.stringify({ from: "triage", to: "todo" }), + }, + ]); + const createdRows = migratedDb.prepare( + "SELECT id FROM activityLog WHERE type = 'task:created' ORDER BY id", + ).all() as Array<{ id: string }>; + expect(createdRows.map((row) => row.id)).toContain("created-1"); + expect(migrationRow?.value).toBe("1"); + + migratedDb.prepare("DELETE FROM activityLog WHERE id = ?").run("move-1"); + migratedDb.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, 'task:moved', ?, ?, ?, ?)`, + ).run( + "noop-after", + "2026-06-03T00:00:05.000Z", + task.id, + task.title ?? null, + "post-migration noop", + JSON.stringify({ from: "archived", to: "archived" }), + ); + + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const postReopenRows = reopenedDb.prepare( + "SELECT id FROM activityLog WHERE type = 'task:moved' ORDER BY id", + ).all() as Array<{ id: string }>; + + expect(postReopenRows).toEqual([{ id: "noop-after" }]); + }); +}); diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index b4fa854663..5d8b8968ae 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); }); }); diff --git a/packages/core/src/__tests__/secrets-schema.test.ts b/packages/core/src/__tests__/secrets-schema.test.ts index 93f4ac4e66..04ce264650 100644 --- a/packages/core/src/__tests__/secrets-schema.test.ts +++ b/packages/core/src/__tests__/secrets-schema.test.ts @@ -42,7 +42,7 @@ describe("secrets schema migrations", () => { const version = db .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(version.value).toBe("101"); + expect(version.value).toBe("102"); } finally { db.close(); rmSync(dir, { recursive: true, force: true }); @@ -105,7 +105,7 @@ describe("secrets schema migrations", () => { const version = db .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(version.value).toBe("101"); + expect(version.value).toBe("102"); } finally { db.close(); rmSync(dir, { recursive: true, force: true }); @@ -155,7 +155,7 @@ describe("secrets schema migrations", () => { .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(projectVersion.value).toBe("101"); + expect(projectVersion.value).toBe("102"); expect(centralVersion.value).toBe("13"); } finally { projectDb.close(); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 8c314969f8..a2e13b78b9 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -117,6 +117,13 @@ describe("settings key parity", () => { expect(PROJECT_SETTINGS_KEYS).toContain("mailAutoCleanupDays"); }); + it("defaults operationalLogRetentionDays to 30 and keeps it project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.operationalLogRetentionDays).toBe(30); + expect(isProjectSettingsKey("operationalLogRetentionDays")).toBe(true); + expect(isGlobalSettingsKey("operationalLogRetentionDays")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).toContain("operationalLogRetentionDays"); + }); + it("keeps heartbeatScopeDiscipline project-scoped with strict default", () => { expect(DEFAULT_PROJECT_SETTINGS.heartbeatScopeDiscipline).toBe("strict"); expect(isProjectSettingsKey("heartbeatScopeDiscipline")).toBe(true); diff --git a/packages/core/src/__tests__/soft-delete-agent-logs.test.ts b/packages/core/src/__tests__/soft-delete-agent-logs.test.ts index 87c97d0a2f..ca9fb550a0 100644 --- a/packages/core/src/__tests__/soft-delete-agent-logs.test.ts +++ b/packages/core/src/__tests__/soft-delete-agent-logs.test.ts @@ -1,10 +1,16 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { countAgentLogEntries, getAgentLogFilePath } from "../agent-log-file-store.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { const harness = createTaskStoreTestHarness(); + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + beforeEach(async () => { await harness.beforeEach(); }); @@ -13,7 +19,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await harness.afterEach(); }); - it("deletes pre-existing persisted agent logs on soft-delete", async () => { + it("hides pre-existing persisted agent logs on soft-delete while preserving the file", async () => { const store = harness.store(); const task = await harness.createTestTask(); @@ -22,32 +28,27 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.appendAgentLog(task.id, "entry-3", "text"); await store.getAgentLogs(task.id); - const before = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(before.count).toBe(3); + expect(countAgentLogEntries(taskDir(task.id))).toBe(3); await store.deleteTask(task.id); - const after = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(existsSync(getAgentLogFilePath(taskDir(task.id)))).toBe(true); + expect(countAgentLogEntries(taskDir(task.id))).toBe(3); await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); await expect(store.getAgentLogCount(task.id)).resolves.toBe(0); + await expect( + store.getAgentLogsByTimeRange(task.id, "2000-01-01T00:00:00.000Z", null), + ).resolves.toEqual([]); }); - it("discards buffered unflushed entries when task is soft-deleted", async () => { + it("flushes buffered entries before soft-delete, then hides them while preserving the file", async () => { const store = harness.store(); const task = await harness.createTestTask(); await store.appendAgentLog(task.id, "buffered-only", "text"); await store.deleteTask(task.id); - const rows = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(rows.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); @@ -59,10 +60,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.getAgentLogs(task.id); await store.deleteTask(task.id); - const firstDeleteCount = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(firstDeleteCount.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); const rowBefore = (store as any).db .prepare('SELECT deletedAt, updatedAt, "column" FROM tasks WHERE id = ?') @@ -76,11 +74,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { expect(rowAfter.deletedAt).toBe(rowBefore.deletedAt); expect(rowAfter.updatedAt).toBe(rowBefore.updatedAt); expect(rowAfter.column).toBe("archived"); - - const secondDeleteCount = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(secondDeleteCount.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); }); it("clears only the soft-deleted parent logs when removing lineage references", async () => { @@ -93,21 +87,14 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.getAgentLogs(parent.id); await store.getAgentLogs(child.id); - const childBefore = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(child.id) as { count: number }; - expect(childBefore.count).toBe(1); + expect(countAgentLogEntries(taskDir(child.id))).toBe(1); await store.deleteTask(parent.id, { removeLineageReferences: true }); - const parentAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(parent.id) as { count: number }; - const childAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(child.id) as { count: number }; - expect(parentAfter.count).toBe(0); - expect(childAfter.count).toBe(1); + expect(countAgentLogEntries(taskDir(parent.id))).toBe(1); + expect(countAgentLogEntries(taskDir(child.id))).toBe(1); + await expect(store.getAgentLogs(parent.id)).resolves.toEqual([]); + await expect(store.getAgentLogs(child.id)).resolves.toMatchObject([{ text: "child-log" }]); }); it("does not affect other tasks' agent logs", async () => { @@ -122,17 +109,13 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.deleteTask(first.id); - const firstAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(first.id) as { count: number }; - const secondAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(second.id) as { count: number }; - expect(firstAfter.count).toBe(0); - expect(secondAfter.count).toBe(1); + expect(countAgentLogEntries(taskDir(first.id))).toBe(1); + expect(countAgentLogEntries(taskDir(second.id))).toBe(1); + await expect(store.getAgentLogs(first.id)).resolves.toEqual([]); + await expect(store.getAgentLogs(second.id)).resolves.toMatchObject([{ text: "second-log" }]); }); - it("emits task:deleted only after agent logs are cleared", async () => { + it("emits task:deleted only after read APIs hide persisted agent logs", async () => { const store = harness.store(); const task = await harness.createTestTask(); await store.appendAgentLog(task.id, "event-order", "text"); diff --git a/packages/core/src/__tests__/store-agent-log-file.test.ts b/packages/core/src/__tests__/store-agent-log-file.test.ts new file mode 100644 index 0000000000..33cc1e97b7 --- /dev/null +++ b/packages/core/src/__tests__/store-agent-log-file.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("TaskStore file-backed agent logs", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("preserves append, read, count, pagination, and time-range parity", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + harness.insertLogEntryWithTimestamp( + store, + task.id, + "first", + "text", + "2026-01-01T00:00:00.000Z", + ); + harness.insertLogEntryWithTimestamp( + store, + task.id, + "tool", + "tool", + "2026-01-01T00:01:00.000Z", + "readme.md", + "executor", + ); + harness.insertLogEntryWithTimestamp( + store, + task.id, + "third", + "thinking", + "2026-01-01T00:02:00.000Z", + undefined, + "reviewer", + ); + + await expect(store.getAgentLogCount(task.id)).resolves.toBe(3); + await expect(store.getAgentLogs(task.id)).resolves.toMatchObject([ + { text: "first", type: "text" }, + { text: "tool", type: "tool", detail: "readme.md", agent: "executor" }, + { text: "third", type: "thinking", agent: "reviewer" }, + ]); + await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([ + { text: "tool" }, + { text: "third" }, + ]); + await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([ + { text: "first" }, + ]); + await expect( + store.getAgentLogsByTimeRange(task.id, "2026-01-01T00:01:00.000Z", "2026-01-01T00:02:00.000Z"), + ).resolves.toMatchObject([{ text: "tool" }, { text: "third" }]); + }); + + it("emits SSE-facing agent:log events per single and batch append while skipping persistence for deleted tasks", async () => { + const store = harness.store(); + const liveTask = await harness.createTestTask(); + const deletedTask = await harness.createTestTask(); + const events: Array<{ taskId: string; text: string }> = []; + store.on("agent:log", (entry) => events.push({ taskId: entry.taskId, text: entry.text })); + + await store.deleteTask(deletedTask.id); + await store.appendAgentLog(liveTask.id, "live-single", "text"); + await store.appendAgentLog(deletedTask.id, "deleted-single", "text"); + await store.appendAgentLogBatch([ + { taskId: liveTask.id, text: "live-batch", type: "text" }, + { taskId: deletedTask.id, text: "deleted-batch", type: "text" }, + ]); + + expect(events).toEqual([ + { taskId: liveTask.id, text: "live-single" }, + { taskId: deletedTask.id, text: "deleted-single" }, + { taskId: liveTask.id, text: "live-batch" }, + { taskId: deletedTask.id, text: "deleted-batch" }, + ]); + await expect(store.getAgentLogs(liveTask.id)).resolves.toMatchObject([ + { text: "live-single" }, + { text: "live-batch" }, + ]); + await expect(store.getAgentLogs(deletedTask.id)).resolves.toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 96fb8612e1..5c4959026a 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(101); + expect(store.getDatabase().getSchemaVersion()).toBe(102); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-test-helpers.ts b/packages/core/src/__tests__/store-test-helpers.ts index 4a7e0f7ad8..26a33eb16e 100644 --- a/packages/core/src/__tests__/store-test-helpers.ts +++ b/packages/core/src/__tests__/store-test-helpers.ts @@ -1,7 +1,8 @@ -import { mkdtempSync } from "node:fs"; +import { appendFileSync, mkdtempSync, mkdirSync } from "node:fs"; import { readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getAgentLogFilePath } from "../agent-log-file-store.js"; import { setTimeout as delay } from "node:timers/promises"; import { vi } from "vitest"; @@ -214,12 +215,20 @@ export function createTaskStoreTestHarness() { [taskId, text, type, timestamp, detail, agent] = args; } - (targetStore as any).db - .prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `) - .run(taskId, timestamp, text, type, detail ?? null, agent ?? null); + const taskDir = join((targetStore as any).getFusionDir(), "tasks", taskId); + mkdirSync(taskDir, { recursive: true }); + appendFileSync( + getAgentLogFilePath(taskDir), + `${JSON.stringify({ + taskId, + timestamp, + text, + type, + ...(detail !== undefined && { detail }), + ...(agent !== undefined && { agent }), + })}\n`, + "utf8", + ); }, }; } @@ -437,12 +446,20 @@ export function createSharedTaskStoreTestHarness() { [taskId, text, type, timestamp, detail, agent] = args; } - (targetStore as any).db - .prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `) - .run(taskId, timestamp, text, type, detail ?? null, agent ?? null); + const taskDir = join((targetStore as any).getFusionDir(), "tasks", taskId); + mkdirSync(taskDir, { recursive: true }); + appendFileSync( + getAgentLogFilePath(taskDir), + `${JSON.stringify({ + taskId, + timestamp, + text, + type, + ...(detail !== undefined && { detail }), + ...(agent !== undefined && { agent }), + })}\n`, + "utf8", + ); }, }; } diff --git a/packages/core/src/__tests__/store-upsert.test.ts b/packages/core/src/__tests__/store-upsert.test.ts index 4e3d38b319..b66a667b00 100644 --- a/packages/core/src/__tests__/store-upsert.test.ts +++ b/packages/core/src/__tests__/store-upsert.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { existsSync } from "node:fs"; import * as projectMemory from "../project-memory.js"; import { AgentStore } from "../agent-store.js"; +import { getAgentLogFilePath, countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-store.js"; import { CentralDatabase } from "../central-db.js"; import { TaskStore, TaskHasDependentsError } from "../store.js"; import { buildResearchDocumentKey, type Task } from "../types.js"; @@ -32,6 +33,7 @@ describe("TaskStore", () => { const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId); const createSourceIssueFixture = () => harness.createSourceIssueFixture(); const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); + const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId); describe("upsertTask regression coverage", () => { it("creates tasks successfully on a fresh database schema", async () => { @@ -111,19 +113,14 @@ describe("TaskStore", () => { describe("agent log persistence", () => { - it("appendAgentLog inserts into agentLogEntries and getAgentLogs reads it back", async () => { + it("appendAgentLog persists to JSONL and getAgentLogs reads it back", async () => { const task = await createTestTask(); await store.appendAgentLog(task.id, "Hello world", "text"); await store.appendAgentLog(task.id, "Read", "tool"); (store as any).flushAgentLogBuffer(); - const rows = (store as any).db.prepare(` - SELECT taskId, text, type FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp ASC - `).all(task.id) as Array<{ taskId: string; text: string; type: string }>; - expect(rows).toEqual([ + expect(readAgentLogEntries(taskDir(task.id))).toMatchObject([ { taskId: task.id, text: "Hello world", type: "text" }, { taskId: task.id, text: "Read", type: "tool" }, ]); @@ -253,7 +250,7 @@ describe("TaskStore", () => { expect(await store.getAgentLogCount(task.id)).toBe(2); }); - it("returns the most recent agent log entries from SQLite in chronological order", async () => { + it("returns the most recent agent log entries in chronological order", async () => { const task = await createTestTask(); for (let i = 0; i < 5; i++) { @@ -607,17 +604,12 @@ describe("TaskStore", () => { await store.appendAgentLog(task.id, "cascade me", "text"); (store as any).flushAgentLogBuffer(); - const before = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(before.count).toBe(1); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); await store.deleteTask(task.id); - const after = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); + await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); it("deleteTask clears linked agent task assignments", async () => { @@ -716,10 +708,7 @@ describe("TaskStore", () => { } // Validate DB persistence without invoking read-path auto-flush helpers. - const row = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(row.count).toBe(50); + expect(countAgentLogEntries(taskDir(task.id))).toBe(50); }); it("auto-flushes buffered entries when getAgentLogs is called", async () => { @@ -744,7 +733,7 @@ describe("TaskStore", () => { expect(count).toBe(1); }); - it("auto-flushes before deleteTask and soft-delete clears resulting rows", async () => { + it("auto-flushes before deleteTask and soft-delete hides resulting file-backed rows", async () => { const task = await createTestTask(); await store.appendAgentLog(task.id, "to be cascaded", "text"); @@ -754,10 +743,8 @@ describe("TaskStore", () => { expect(flushSpy).toHaveBeenCalled(); flushSpy.mockRestore(); - const after = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); + await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); it("flushes remaining entries on close without throwing", async () => { diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 24c7c17178..4f0668a325 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -108,6 +108,31 @@ describe("task creation hook", () => { expect(created2.id).toMatch(/^FN-/); }); + it("does not leak async task:updated listener rejections during create follow-up updates", async () => { + const store = harness.store(); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + store.on("task:updated", async (task) => { + if (task.id.startsWith("FN-")) { + throw new Error(`listener boom for ${task.id}`); + } + }); + + try { + const task = await store.createTask({ description: "planning create listener safety" }); + await store.updateTask(task.id, { size: "M" }); + await store.logEntry(task.id, "Created via Planning Mode", "Initial plan: test"); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandledRejections).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + it("can clear hook with undefined", async () => { const store = harness.store(); const hook = vi.fn(); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 84364d2666..47c08de78b 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const index = db .prepare( diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index b9a3854406..c7e26e981f 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -8,6 +8,7 @@ import { getTaskHardMergeBlocker, getTaskMergeBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, @@ -46,6 +47,23 @@ describe("resolveEffectiveAutoMerge", () => { }); }); +describe("allowsAutoMergeProcessing", () => { + it("lets explicit per-task true through when the global setting is off (FN per-task override)", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true); + }); + + it("blocks tasks without an explicit override when the global setting is off", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false); + }); + + it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); +}); + describe("resolveEffectiveGroupAutoMerge", () => { it("prefers explicit true over global false", () => { expect(resolveEffectiveGroupAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true); diff --git a/packages/core/src/__tests__/workspace-dependency-acyclicity.test.ts b/packages/core/src/__tests__/workspace-dependency-acyclicity.test.ts new file mode 100644 index 0000000000..645421be2f --- /dev/null +++ b/packages/core/src/__tests__/workspace-dependency-acyclicity.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type PackageManifest = { + name: string; + path: string; + dependencies: Record; + devDependencies: Record; + optionalDependencies: Record; +}; + +function getRepoRoot(): string { + return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", ".."); +} + +function readWorkspacePatterns(repoRoot: string): string[] { + const workspaceConfig = readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8"); + return Array.from(workspaceConfig.matchAll(/^\s*-\s*"([^"]+)"\s*$/gm), (match) => match[1]); +} + +function expandWorkspacePattern(repoRoot: string, pattern: string): string[] { + if (!pattern.includes("*")) { + return [join(repoRoot, pattern)]; + } + + const marker = "/*"; + if (!pattern.endsWith(marker) || pattern.indexOf("*") !== pattern.length - 1) { + throw new Error(`Unsupported pnpm workspace pattern in test: ${pattern}`); + } + + const baseDir = join(repoRoot, pattern.slice(0, -marker.length)); + return readdirSync(baseDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(baseDir, entry.name)); +} + +function loadWorkspacePackages(repoRoot: string): PackageManifest[] { + const packageDirs = new Set(); + for (const pattern of readWorkspacePatterns(repoRoot)) { + for (const candidate of expandWorkspacePattern(repoRoot, pattern)) { + const manifestPath = join(candidate, "package.json"); + if (existsSync(manifestPath)) { + packageDirs.add(candidate); + } + } + } + + return Array.from(packageDirs) + .sort() + .map((pkgPath) => { + const manifest = JSON.parse(readFileSync(join(pkgPath, "package.json"), "utf8")) as { + name: string; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }; + + return { + name: manifest.name, + path: pkgPath, + dependencies: manifest.dependencies ?? {}, + devDependencies: manifest.devDependencies ?? {}, + optionalDependencies: manifest.optionalDependencies ?? {}, + } satisfies PackageManifest; + }); +} + +function collectWorkspaceEdges(packages: PackageManifest[]): Map> { + const workspaceNames = new Set(packages.map((pkg) => pkg.name)); + const graph = new Map>(); + + for (const pkg of packages) { + const edges = new Set(); + for (const section of [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]) { + for (const depName of Object.keys(section)) { + if (workspaceNames.has(depName)) { + edges.add(depName); + } + } + } + graph.set(pkg.name, edges); + } + + return graph; +} + +function canonicalizeCycle(cycle: string[]): string { + const nodes = cycle.slice(0, -1); + const candidates = nodes.map((_, index) => { + const rotated = [...nodes.slice(index), ...nodes.slice(0, index)]; + return [...rotated, rotated[0]].join(" -> "); + }); + return candidates.sort()[0] ?? cycle.join(" -> "); +} + +function findCycles(graph: Map>): string[] { + const state = new Map(); + const stack: string[] = []; + const cycles = new Set(); + + function visit(node: string) { + if (state.get(node) === "done") { + return; + } + if (state.get(node) === "visiting") { + return; + } + + state.set(node, "visiting"); + stack.push(node); + + for (const next of graph.get(node) ?? []) { + if (state.get(next) === "visiting") { + const startIndex = stack.indexOf(next); + const cycle = [...stack.slice(startIndex), next]; + cycles.add(canonicalizeCycle(cycle)); + continue; + } + visit(next); + } + + stack.pop(); + state.set(node, "done"); + } + + for (const node of graph.keys()) { + visit(node); + } + + return Array.from(cycles).sort(); +} + +describe("workspace dependency graph", () => { + it("stays acyclic across all workspace packages", () => { + const packages = loadWorkspacePackages(getRepoRoot()); + const graph = collectWorkspaceEdges(packages); + const cycles = findCycles(graph); + + expect( + cycles, + cycles.length === 0 ? "expected workspace dependency graph to be acyclic" : `workspace dependency cycles detected:\n${cycles.join("\n")}`, + ).toEqual([]); + }); + + it("prevents dashboard-listed bundled plugins from depending on host packages", () => { + const packages = loadWorkspacePackages(getRepoRoot()); + const packageByName = new Map(packages.map((pkg) => [pkg.name, pkg])); + const dashboard = packageByName.get("@fusion/dashboard"); + + expect(dashboard).toBeDefined(); + + const bundledPluginNames = Object.keys(dashboard?.dependencies ?? {}).filter((name) => name.startsWith("@fusion-plugin-examples/")); + const hostPackages = ["@fusion/dashboard", "@fusion/engine"]; + const offenders = bundledPluginNames.flatMap((pluginName) => { + const plugin = packageByName.get(pluginName); + if (!plugin) { + return []; + } + + return hostPackages + .filter((hostName) => Object.prototype.hasOwnProperty.call(plugin.dependencies, hostName)) + .map((hostName) => `${pluginName} must not declare ${hostName} in dependencies`); + }); + + expect( + offenders, + offenders.length === 0 + ? "expected dashboard-listed bundled plugins to avoid host package runtime dependencies" + : offenders.join("\n"), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/agent-log-constants.ts b/packages/core/src/agent-log-constants.ts new file mode 100644 index 0000000000..cce4bd0ad4 --- /dev/null +++ b/packages/core/src/agent-log-constants.ts @@ -0,0 +1,25 @@ +import type { AgentLogEntry } from "./types.js"; + +export const AGENT_LOG_FILENAME = "agent-log.jsonl"; +export const AGENT_LOG_TOOL_DETAIL_LIMIT = 4_096; +export const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE = + "\n\n[tool output truncated to keep dashboard log views responsive]"; +export const AGENT_LOG_TOOL_TYPES = new Set([ + "tool", + "tool_result", + "tool_error", +]); + +export function truncateAgentLogDetail( + detail: string | null | undefined, + type: AgentLogEntry["type"], +): string | undefined { + if (detail == null) return undefined; + if (!AGENT_LOG_TOOL_TYPES.has(type)) return detail; + if (detail.length <= AGENT_LOG_TOOL_DETAIL_LIMIT) return detail; + return `${detail.slice(0, AGENT_LOG_TOOL_DETAIL_LIMIT)}${AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE}`; +} + +export function buildAgentLogSourceRef(taskId: string, lineNo: number): string { + return `agentLog:${taskId}:${lineNo}`; +} diff --git a/packages/core/src/agent-log-file-store.ts b/packages/core/src/agent-log-file-store.ts new file mode 100644 index 0000000000..17fc385539 --- /dev/null +++ b/packages/core/src/agent-log-file-store.ts @@ -0,0 +1,267 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import type { AgentLogEntry } from "./types.js"; +import { + AGENT_LOG_FILENAME, + buildAgentLogSourceRef, + truncateAgentLogDetail, +} from "./agent-log-constants.js"; +import { createLogger } from "./logger.js"; + +const log = createLogger("agent-log-file-store"); + +export interface StoredAgentLogEntry extends AgentLogEntry { + lineNo: number; + sourceRef: string; +} + +export interface AgentLogFileStoreReadOptions { + limit?: number; + offset?: number; + type?: AgentLogEntry["type"]; + startTime?: string; + endTime?: string | null; +} + +export interface AgentLogFileAppendInput { + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string | null; + agent?: AgentLogEntry["agent"] | null; +} + +interface AgentLogJsonlRow { + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string; + agent?: AgentLogEntry["agent"]; +} + +export function getAgentLogFilePath(taskDir: string): string { + return join(taskDir, AGENT_LOG_FILENAME); +} + +export function appendAgentLogEntriesSync( + taskDir: string, + entries: AgentLogFileAppendInput[], +): StoredAgentLogEntry[] { + if (entries.length === 0) return []; + + const filePath = getAgentLogFilePath(taskDir); + mkdirSync(dirname(filePath), { recursive: true }); + const startingLineNo = countLineNumbers(filePath); + const payload = entries + .map((entry) => serializeEntry(entry)) + .join(""); + appendFileSync(filePath, payload, "utf8"); + + return entries.map((entry, index) => materializeEntry(entry, startingLineNo + index + 1)); +} + +export function readAgentLogEntries( + taskDir: string, + options: AgentLogFileStoreReadOptions = {}, +): StoredAgentLogEntry[] { + const entries = readAllAgentLogEntries(taskDir, options); + const offset = Math.max(0, options.offset ?? 0); + if (options.limit == null) { + return offset === 0 ? entries : entries.slice(0, Math.max(0, entries.length - offset)); + } + const limit = Math.max(0, options.limit); + const endExclusive = Math.max(0, entries.length - offset); + const startInclusive = Math.max(0, endExclusive - limit); + return entries.slice(startInclusive, endExclusive); +} + +export function countAgentLogEntries( + taskDir: string, + options: Omit = {}, +): number { + return readAllAgentLogEntries(taskDir, options).length; +} + +export function readAgentLogEntriesByTimeRange( + taskDir: string, + startTime: string, + endTime: string | null, + options: Omit = {}, +): StoredAgentLogEntry[] { + return readAllAgentLogEntries(taskDir, { + ...options, + startTime, + endTime, + }); +} + +function readAllAgentLogEntries( + taskDir: string, + options: Omit = {}, +): StoredAgentLogEntry[] { + const filePath = getAgentLogFilePath(taskDir); + if (!existsSync(filePath)) { + return []; + } + + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) { + return []; + } + + const lines = content.split("\n"); + const entries: StoredAgentLogEntry[] = []; + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + if (!rawLine) continue; + const lineNo = index + 1; + try { + const parsed = JSON.parse(rawLine) as Partial; + if ( + typeof parsed.timestamp !== "string" + || typeof parsed.taskId !== "string" + || typeof parsed.text !== "string" + || typeof parsed.type !== "string" + ) { + throw new Error("missing required agent-log fields"); + } + const entry = materializeEntry(parsed as AgentLogFileAppendInput, lineNo); + if (options.type != null && entry.type !== options.type) { + continue; + } + if (options.startTime != null && entry.timestamp < options.startTime) { + continue; + } + if (options.endTime != null && entry.timestamp > options.endTime) { + continue; + } + entries.push(entry); + } catch (error) { + log.warn(`Skipping malformed JSONL line ${lineNo} in ${filePath}`, error); + } + } + + return entries; +} + +function serializeEntry(entry: AgentLogFileAppendInput): string { + const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type); + const row: AgentLogJsonlRow = { + timestamp: entry.timestamp, + taskId: entry.taskId, + text: entry.text, + type: entry.type, + ...(normalizedDetail !== undefined && { detail: normalizedDetail }), + ...(entry.agent != null && { agent: entry.agent }), + }; + return `${JSON.stringify(row)}\n`; +} + +function materializeEntry(entry: AgentLogFileAppendInput, lineNo: number): StoredAgentLogEntry { + const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type); + return { + timestamp: entry.timestamp, + taskId: entry.taskId, + text: entry.text, + type: entry.type, + ...(normalizedDetail !== undefined && { detail: normalizedDetail }), + ...(entry.agent != null && { agent: entry.agent }), + lineNo, + sourceRef: buildAgentLogSourceRef(entry.taskId, lineNo), + }; +} + +function countLineNumbers(filePath: string): number { + if (!existsSync(filePath)) { + return 0; + } + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) { + return 0; + } + const lines = content.split("\n"); + return lines.at(-1) === "" ? lines.length - 1 : lines.length; +} + +/** + * Prune agent log JSONL files by removing entries older than the retention cutoff. + * Only affects tasks whose directory exists under `tasksDir`. + * + * @param tasksDir - Root `.fusion/tasks/` directory + * @param retentionDays - Number of days to retain; 0 or negative disables pruning + * @param scanTaskIds - Optional set of task IDs to scope pruning to. If omitted, all task subdirectories are scanned. + * @returns Counts of pruned files and approximate bytes freed. + */ +export function pruneAgentLogFiles( + tasksDir: string, + retentionDays: number, + scanTaskIds?: Set, +): { prunedFiles: number; prunedEntries: number; freedBytes: number } { + if (!Number.isFinite(retentionDays) || retentionDays <= 0 || !existsSync(tasksDir)) { + return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }; + } + + const cutoffIso = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); + let prunedFiles = 0; + let prunedEntries = 0; + let freedBytes = 0; + + const entries = readdirSync(tasksDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (scanTaskIds != null && !scanTaskIds.has(entry.name)) continue; + + const taskDirPath = join(tasksDir, entry.name); + const filePath = getAgentLogFilePath(taskDirPath); + if (!existsSync(filePath)) continue; + + try { + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) continue; + + const lines = content.split("\n"); + const keptLines: string[] = []; + let removed = 0; + + for (const line of lines) { + if (!line) continue; + try { + const parsed = JSON.parse(line) as { timestamp?: unknown }; + const ts = typeof parsed.timestamp === "string" ? parsed.timestamp : null; + if (ts != null && ts < cutoffIso) { + removed += 1; + continue; + } + } catch { + // Malformed line — keep it (don't destroy data we can't parse) + } + keptLines.push(line); + } + + if (removed > 0) { + const newSize = keptLines.map((l) => l.length + 1).reduce((a, b) => a + b, 0); + freedBytes += content.length - newSize; + prunedEntries += removed; + + if (keptLines.length === 0) { + unlinkSync(filePath); + prunedFiles += 1; + } else { + // Atomic-ish rewrite: write to temp then rename + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, keptLines.join("\n") + "\n", "utf8"); + renameSync(tmpPath, filePath); + prunedFiles += 1; + } + } + } catch (err) { + log.warn(`Failed to prune agent log file ${filePath}`, err); + } + } + + return { prunedFiles, prunedEntries, freedBytes }; +} diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 4048d07335..acc591057d 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -233,6 +233,10 @@ Follow this structure exactly: {One paragraph: what you're building and why it matters} +## Surface Enumeration + +{Required for bug-fix tasks: a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. Use the canonical checklist in docs/testing.md as the starting point.} + ## Dependencies - **None** @@ -263,6 +267,12 @@ Follow this structure exactly: - [ ] {Specific, verifiable outcome} - [ ] Run targeted tests for changed files, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states) +For bug-fix tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: +- [ ] Providers / bridges / execution paths touched by the invariant +- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior +- [ ] Empty / undefined / duplicate / populated data states +- [ ] Shared hooks / components / modules / helpers reusing the logic + **Artifacts:** - \`path/to/file\` (new | modified) @@ -330,6 +340,8 @@ files with assertions that run via a test runner. Typechecks and builds are NOT tests. Manual verification is NOT a test. - Each implementation step should include writing tests for the code being changed +- For bug fixes, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix spec as a blocking REVISE. +- For bug fixes, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers. - For bug fixes, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states — not just the reported repro (see FN-5787/FN-5789/FN-5803 and FN-5751) - The final Testing step runs lint, the FULL test suite, and project typecheck when the repo exposes one - Specs must instruct executors to fix lint failures and quality-gate failures directly, even when the required edits extend beyond the original File Scope @@ -521,6 +533,7 @@ access to the codebase and can run commands to inspect code. ### Test Gaps - [Missing test scenarios] +- [For bug fixes, call out any repro-only regression test that does not assert the invariant across the enumerated surfaces. Issue REVISE when coverage stops at the single reported case instead of spanning the \`## Surface Enumeration\` checklist (FN-5893; see FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751).] ### Suggestions - [Optional improvements, not blocking] @@ -545,6 +558,7 @@ access to the codebase and can run commands to inspect code. - **File scope accuracy:** [All affected files listed? No extras?] - **Dependency correctness:** [Dependencies exist and are appropriate?] - **Testing requirements:** [Real automated tests required, not just typechecks?] +- **Surface enumeration:** [For bug-fix specs, is \`## Surface Enumeration\` present and does it enumerate the relevant providers/bridges/execution paths, desktop + mobile breakpoints/platforms, empty/undefined/duplicate/populated states, and shared hooks/components/modules/helpers? Missing or incomplete coverage is a blocking REVISE.] - **Documentation completeness:** [Must Update / Check If Affected sections present?] - **Sizing & review level:** [Size and review level appropriate for the work?] - **Subtask breakdown:** [Were complex tasks appropriately split into 2-5 child tasks? A task with 8+ implementation steps, affecting 3+ packages, should have been divided] @@ -795,6 +809,7 @@ submissions to a high bar for correctness, security, and maintainability. ### Test Gaps - [Missing test scenarios including edge cases] +- [For bug fixes, call out any repro-only regression test that does not assert the invariant across the enumerated surfaces. Issue REVISE when coverage stops at the single reported case instead of spanning the \`## Surface Enumeration\` checklist (FN-5893; see FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751).] ### Backward Compatibility - [Any breaking changes or migration needs] @@ -822,6 +837,7 @@ submissions to a high bar for correctness, security, and maintainability. - **File scope accuracy:** [All affected files listed? No extras?] - **Dependency correctness:** [Dependencies exist and are appropriate?] - **Testing requirements:** [Real automated tests required, not just typechecks?] +- **Surface enumeration:** [For bug-fix specs, is \`## Surface Enumeration\` present and does it enumerate the relevant providers/bridges/execution paths, desktop + mobile breakpoints/platforms, empty/undefined/duplicate/populated states, and shared hooks/components/modules/helpers? Missing or incomplete coverage is a blocking REVISE.] - **Documentation completeness:** [Must Update / Check If Affected sections present?] - **Sizing & review level:** [Size and review level appropriate for the work?] - **Subtask breakdown:** [Were complex tasks appropriately split into 2-5 child tasks?] diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 8c7e4d1f52..8c7385f131 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,9 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 101; +const SCHEMA_VERSION = 102; + +export { SCHEMA_VERSION }; function normalizeTaskComments( steeringComments: SteeringComment[] | undefined, @@ -483,19 +485,6 @@ CREATE TABLE IF NOT EXISTS agentRuns ( CREATE INDEX IF NOT EXISTS idxAgentRunsAgentIdStartedAt ON agentRuns(agentId, startedAt); CREATE INDEX IF NOT EXISTS idxAgentRunsStatus ON agentRuns(status); -CREATE TABLE IF NOT EXISTS agentLogEntries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - taskId TEXT NOT NULL, - timestamp TEXT NOT NULL, - text TEXT NOT NULL, - type TEXT NOT NULL, - detail TEXT, - agent TEXT, - FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idxAgentLogEntriesTaskIdTimestamp ON agentLogEntries(taskId, timestamp); -CREATE INDEX IF NOT EXISTS idxAgentLogEntriesTaskIdType ON agentLogEntries(taskId, type); - CREATE TABLE IF NOT EXISTS agentTaskSessions ( agentId TEXT NOT NULL, taskId TEXT NOT NULL, @@ -1317,6 +1306,18 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record mentions: "TEXT", createdAt: "TEXT NOT NULL", }, + // agentLogEntries is created by migration 40 for legacy DBs and dropped by + // migration 102. Included here so the architecture-schema-compat test + // recognizes it as a covered migration-only table. + agentLogEntries: { + id: "INTEGER PRIMARY KEY AUTOINCREMENT", + taskId: "TEXT NOT NULL", + timestamp: "TEXT NOT NULL", + text: "TEXT NOT NULL", + type: "TEXT NOT NULL", + detail: "TEXT", + agent: "TEXT", + }, }; /** @@ -1814,7 +1815,6 @@ export class Database { */ private static readonly OPERATIONAL_LOG_TABLES = [ "activityLog", - "agentLogEntries", "runAuditEvents", "agentHeartbeats", ] as const; @@ -4016,6 +4016,27 @@ export class Database { }); } + // Migration 102: Drop agentLogEntries after store-level migration has + // copied legacy rows into per-task JSONL files. Database.init() runs before + // TaskStore.init(), so we must defer the destructive drop until the store + // writes the migration guard into __meta and re-runs init(). + if (version < 102) { + const agentLogMigrationComplete = this.getMetaValue("agentLogEntriesToFileMigrationVersion") === "1"; + const hasLegacyAgentLogTable = this.hasTable("agentLogEntries"); + const legacyAgentLogTableIsEmpty = hasLegacyAgentLogTable + ? ((this.db.prepare("SELECT COUNT(*) as count FROM agentLogEntries").get() as { count: number }).count === 0) + : true; + const hasLegacyAgentLogCitations = + (this.db.prepare( + "SELECT 1 FROM goal_citations WHERE surface = 'agent_log' AND sourceRef GLOB 'agentLog:[0-9]*' LIMIT 1", + ).get() ?? undefined) !== undefined; + if (!hasLegacyAgentLogTable || agentLogMigrationComplete || (legacyAgentLogTableIsEmpty && !hasLegacyAgentLogCitations)) { + this.applyMigration(102, () => { + this.db.exec(`DROP TABLE IF EXISTS agentLogEntries`); + }); + } + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91f6e006b3..65508306d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -320,6 +320,7 @@ export { getTaskHardMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 96e1fbf992..70c70ee66b 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -14,6 +14,7 @@ import { EventEmitter } from "node:events"; import type { Database } from "./db.js"; import { fromJson, toJson, toJsonNullable } from "./db.js"; +import type { Goal, GoalStatus } from "./goal-types.js"; import type { Mission, MissionBranchStrategy, @@ -120,6 +121,10 @@ export interface MissionSummary { totalFeatures: number; /** Number of features with status "done" */ completedFeatures: number; + /** Number of goals linked to the mission */ + linkedGoalCount: number; + /** Unfiltered total number of persisted mission lifecycle events */ + eventCount: number; /** Computed progress percentage (0–100), based on features or milestones */ progressPercent: number; } @@ -261,6 +266,15 @@ interface MissionGoalRow { createdAt: string; } +interface GoalRow { + id: string; + title: string; + description: string | null; + status: GoalStatus; + createdAt: string; + updatedAt: string; +} + /** Database row shape for the mission_contract_assertions table. */ interface AssertionRow { id: string; @@ -462,6 +476,17 @@ export class MissionStore extends EventEmitter { }; } + private rowToGoal(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + /** * Convert a database row to a MissionContractAssertion object. */ @@ -678,6 +703,13 @@ export class MissionStore extends EventEmitter { const mission = this.getMission(id); if (!mission) return undefined; + const linkedGoals = this.listGoalIdsForMission(id) + .map((goalId) => this.db + .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") + .get(goalId) as GoalRow | undefined) + .filter((row): row is GoalRow => Boolean(row)) + .map((row) => this.rowToGoal(row)); + const milestones = this.listMilestones(id); const milestonesWithSlices = milestones.map((milestone) => { const slices = this.listSlices(milestone.id); @@ -691,8 +723,15 @@ export class MissionStore extends EventEmitter { }; }); + const eventCountRow = this.db + .prepare("SELECT COUNT(*) AS count FROM mission_events WHERE missionId = ?") + .get(id) as { count?: number | bigint } | undefined; + const eventCount = Number(eventCountRow?.count ?? 0); + return { ...mission, + linkedGoals, + eventCount, milestones: milestonesWithSlices, }; } @@ -736,6 +775,16 @@ export class MissionStore extends EventEmitter { } } + const linkedGoalRow = this.db + .prepare("SELECT COUNT(*) AS count FROM mission_goals WHERE missionId = ?") + .get(missionId) as { count?: number | bigint } | undefined; + const linkedGoalCount = Number(linkedGoalRow?.count ?? 0); + + const eventCountRow = this.db + .prepare("SELECT COUNT(*) AS count FROM mission_events WHERE missionId = ?") + .get(missionId) as { count?: number | bigint } | undefined; + const eventCount = Number(eventCountRow?.count ?? 0); + let progressPercent = 0; if (totalFeatures > 0) { progressPercent = Math.round((completedFeatures / totalFeatures) * 100); @@ -748,6 +797,8 @@ export class MissionStore extends EventEmitter { completedMilestones, totalFeatures, completedFeatures, + linkedGoalCount, + eventCount, progressPercent, }; } @@ -784,7 +835,23 @@ export class MissionStore extends EventEmitter { ).all() as unknown as FeatureRow[]; const allFeatures = featureRows.map((row) => this.rowToFeature(row)); - // 5. Group in-memory: slices by milestoneId, features by sliceId + // 5. Batch query linked goal counts + const linkedGoalRows = this.db.prepare( + "SELECT missionId, COUNT(*) AS count FROM mission_goals GROUP BY missionId" + ).all() as Array<{ missionId: string; count?: number | bigint }>; + const linkedGoalCountByMissionId = new Map( + linkedGoalRows.map((row) => [row.missionId, Number(row.count ?? 0)]), + ); + + // 6. Batch query mission event counts + const eventCountRows = this.db.prepare( + "SELECT missionId, COUNT(*) AS count FROM mission_events GROUP BY missionId" + ).all() as Array<{ missionId: string; count?: number | bigint }>; + const eventCountByMissionId = new Map( + eventCountRows.map((row) => [row.missionId, Number(row.count ?? 0)]), + ); + + // 7. Group in-memory: slices by milestoneId, features by sliceId const slicesByMilestoneId = new Map(); for (const slice of allSlices) { const list = slicesByMilestoneId.get(slice.milestoneId) || []; @@ -799,7 +866,7 @@ export class MissionStore extends EventEmitter { featuresBySliceId.set(feature.sliceId, list); } - // 6. Group milestones by missionId + // 8. Group milestones by missionId const milestonesByMissionId = new Map(); for (const milestone of allMilestones) { const list = milestonesByMissionId.get(milestone.missionId) || []; @@ -807,7 +874,7 @@ export class MissionStore extends EventEmitter { milestonesByMissionId.set(milestone.missionId, list); } - // 7. Compute summary for each mission using grouped data + // 9. Compute summary for each mission using grouped data return missions.map((mission) => { const milestones = milestonesByMissionId.get(mission.id) || []; const totalMilestones = milestones.length; @@ -825,6 +892,9 @@ export class MissionStore extends EventEmitter { } } + const linkedGoalCount = linkedGoalCountByMissionId.get(mission.id) ?? 0; + const eventCount = eventCountByMissionId.get(mission.id) ?? 0; + let progressPercent = 0; if (totalFeatures > 0) { progressPercent = Math.round((completedFeatures / totalFeatures) * 100); @@ -839,6 +909,8 @@ export class MissionStore extends EventEmitter { completedMilestones, totalFeatures, completedFeatures, + linkedGoalCount, + eventCount, progressPercent, }, }; @@ -2168,6 +2240,16 @@ export class MissionStore extends EventEmitter { } } + ensureFeatureAssertionLinked(featureId: string): MissionContractAssertion[] { + const feature = this.getFeature(featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + this.ensureFeatureAssertion(feature); + return this.listAssertionsForFeature(featureId); + } + /** * Idempotently seed authored contract assertions for specific features. * diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index 80e801932c..6fa3e4d732 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -9,6 +9,8 @@ * The hierarchy: Mission → Milestone → Slice → Feature → (optional) Task */ +import type { Goal } from "./goal-types.js"; + // ── Status Enums ───────────────────────────────────────────────────── /** Status values for a Mission's lifecycle */ @@ -457,6 +459,10 @@ export interface SliceWithFeatures extends Slice { * Mission → Milestones → Slices → Features */ export interface MissionWithHierarchy extends Mission { + /** Goals linked to this mission */ + linkedGoals?: Goal[]; + /** Unfiltered total of all mission lifecycle events, matching `MissionSummary.eventCount` and `getMissionEvents` `total` with no `eventType` filter */ + eventCount?: number; /** Milestones belonging to this mission, each with their slices */ milestones: Array(["tool", "tool_result", "tool_error"]); const storeLog = createLogger("task-store"); const coreLog = createLogger("core"); @@ -472,16 +477,6 @@ function truncateTaskLogOutcome(outcome: string | undefined): string | undefined return `${outcome.slice(0, taskActivityLogOutcomeLimit)}\n... outcome truncated to ${taskActivityLogOutcomeLimit} characters ...`; } -function truncateAgentLogDetail( - detail: string | null | undefined, - type: AgentLogEntry["type"], -): string | undefined { - if (detail == null) return undefined; - if (!AGENT_LOG_TOOL_TYPES.has(type)) return detail; - if (detail.length <= AGENT_LOG_TOOL_DETAIL_LIMIT) return detail; - return `${detail.slice(0, AGENT_LOG_TOOL_DETAIL_LIMIT)}${AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE}`; -} - function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] { const recentEntries = entries.slice(-taskActivityLogEntryLimit); return recentEntries.map((entry) => ({ @@ -1189,9 +1184,9 @@ export class TaskStore extends EventEmitter { taskId: string; timestamp: string; text: string; - type: string; + type: AgentLogEntry["type"]; detail: string | null; - agent: string | null; + agent: AgentLogEntry["agent"] | null; }> = []; /** Timer for flushing the agent log buffer. */ private agentLogFlushTimer: ReturnType | null = null; @@ -1229,6 +1224,34 @@ export class TaskStore extends EventEmitter { this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); } + private emitTaskLifecycleEventSafely( + event: "task:created" | "task:updated", + args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"], + ): boolean { + const listeners = super.listeners(event) as Array<(...listenerArgs: typeof args) => unknown>; + if (listeners.length === 0) { + return false; + } + + const [task] = args; + const taskId = task && typeof task === "object" && "id" in task ? String(task.id) : "unknown"; + + for (const listener of listeners) { + try { + const result = listener(...args); + if (result && typeof (result as PromiseLike).then === "function") { + void Promise.resolve(result).catch((error) => { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + }); + } + } catch (error) { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + } + } + + return true; + } + /** * Get the SQLite database, initializing it on first access. * Also performs auto-migration from legacy file-based storage if needed. @@ -1389,6 +1412,11 @@ export class TaskStore extends EventEmitter { await migrateFromLegacy(this.fusionDir, this._db); } await this.migrateActiveArchivedTasksToArchiveDb(); + await this.migrateAgentLogEntriesToFilesOnce(); + await this.cleanupNoOpTaskMovedActivityRowsOnce(); + if (this.db.getSchemaVersion() < SCHEMA_VERSION) { + this.db.init(); + } await this.importLegacyAgentLogsOnce(); this.taskIdStateReconciled = false; this.reconcileDistributedTaskIdStateOnOpen(); @@ -2580,6 +2608,7 @@ export class TaskStore extends EventEmitter { // Task moved this.on("task:moved", (data) => { if (this.suppressActivityLogForPollingEmit) return; + if (data.from === data.to) return; this.recordActivityFromListener( { type: "task:moved", @@ -4002,7 +4031,7 @@ export class TaskStore extends EventEmitter { await this._maybeAutoArchiveSameAgentDuplicate(task, input); - this.emit("task:created", task); + this.emitTaskLifecycleEventSafely("task:created", [task]); if (options?.invokeTaskCreatedHook !== false) { await this.invokeTaskCreatedHook(task); } @@ -4599,7 +4628,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4612,7 +4641,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4855,7 +4884,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4868,7 +4897,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5018,7 +5047,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5031,7 +5060,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5659,7 +5688,9 @@ export class TaskStore extends EventEmitter { if (this.isWatching) this.taskCache.set(id, { ...task }); - this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); + if (fromColumn !== toColumn) { + this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); + } return task; } @@ -5851,7 +5882,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6522,7 +6553,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6773,12 +6804,12 @@ export class TaskStore extends EventEmitter { if (this.isWatching) { this.taskCache.set(id, { ...current }); } - this.emit("task:updated", current); + this.emitTaskLifecycleEventSafely("task:updated", [current]); return current; } const emittedTask = ({ id, log, updatedAt } as unknown) as Task; - this.emit("task:updated", emittedTask); + this.emitTaskLifecycleEventSafely("task:updated", [emittedTask]); return emittedTask; }); } @@ -7663,11 +7694,10 @@ export class TaskStore extends EventEmitter { }, }); this.clearLinkedAgentTaskIds(id, deletedAt); - // FN-5143: clear historical agent logs for the soft-deleted task so - // downstream readers (evaluator evidence, self-healing diagnostics, - // dashboard log views, register-task-workflow-routes) observe zero logs - // immediately after deletedAt is set. Atomic with the deletedAt write. - this.db.prepare("DELETE FROM agentLogEntries WHERE taskId = ?").run(id); + // FN-5143: agent log reads are gated on deletedAt (see getAgentLogs / + // getAgentLogCount / getAgentLogsByTimeRange), so downstream readers + // observe zero logs immediately after deletedAt is set. The JSONL file + // remains on disk for forensic analysis; only the read API hides it. this.db.bumpLastModified(); }); @@ -8608,9 +8638,12 @@ export class TaskStore extends EventEmitter { if (archivedSet.has(id)) { // Task moved to archive — emit task:moved (matching what // archiveTask emits in-process) so other subscribers can react. - // Activity-log listeners skip this emit; the originating + // Skip already-archived cache entries to avoid no-op emits. + // Activity-log listeners skip polling emits; the originating // TaskStore instance wrote the row in-process. - this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" }); + if (cached.column !== "archived") { + this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" }); + } } else { // Polling replicas only mirror the originating delete signal. // Do not record run-audit here; the writer already owns that row. @@ -8841,7 +8874,7 @@ export class TaskStore extends EventEmitter { } /** - * Insert an agent log entry into the agentLogEntries SQLite table. + * Buffer an agent log entry for file-backed persistence. * Also emits an `agent:log` event for live streaming. * * @param taskId - The task ID (e.g. "KB-001") @@ -8911,7 +8944,7 @@ export class TaskStore extends EventEmitter { } /** - * Flush all buffered agent log entries in a single transaction. + * Flush all buffered agent log entries to per-task JSONL files. * Called when the buffer is full or on a timer. */ private flushAgentLogBuffer(): void { @@ -8921,55 +8954,45 @@ export class TaskStore extends EventEmitter { } if (this.agentLogBuffer.length === 0) return; - // Snapshot the entries to flush. New entries appended during the - // synchronous transaction will appear past batch.length in - // this.agentLogBuffer, so we splice only the flushed count. const batch = this.agentLogBuffer.slice(); const flushCount = batch.length; let validEntries = batch; - let flushSucceeded = false; + const flushedEntries = new Set(); try { - this.db.transaction(() => { - // Query live task IDs inside the transaction so the check is - // atomic with the inserts (prevents TOCTOU FK violations). - const liveTaskIds = new Set( - (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((r) => r.id), + const liveTaskIds = new Set( + (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), + ); + validEntries = batch.filter((entry) => liveTaskIds.has(entry.taskId)); + const dropped = batch.length - validEntries.length; + if (dropped > 0) { + console.warn( + `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${this.db.path})`, ); - validEntries = batch.filter((e) => liveTaskIds.has(e.taskId)); - const dropped = batch.length - validEntries.length; - if (dropped > 0) { - console.warn( - `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${this.db.path})`, - ); + } + + if (validEntries.length > 0) { + const citationInputs: GoalCitationInput[] = []; + const entriesByTask = new Map(); + for (const entry of validEntries) { + const taskEntries = entriesByTask.get(entry.taskId); + if (taskEntries) { + taskEntries.push(entry); + } else { + entriesByTask.set(entry.taskId, [entry]); + } } - if (validEntries.length > 0) { - const stmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); - const citationInputs: GoalCitationInput[] = []; - for (const entry of validEntries) { - const insertResult = stmt.run( - entry.taskId, - entry.timestamp, - entry.text, - entry.type, - entry.detail, - entry.agent, - ) as { lastInsertRowid?: number | bigint }; - const insertedId = insertResult.lastInsertRowid; - if (insertedId === undefined || insertedId === null) { - continue; - } - const sourceRef = `agentLog:${String(insertedId)}`; + for (const [taskId, taskEntries] of entriesByTask) { + const appended = appendAgentLogEntriesSync(this.taskDir(taskId), taskEntries); + taskEntries.forEach((entry) => flushedEntries.add(entry)); + for (const entry of appended) { try { citationInputs.push( ...this.scanAndRecordCitations( entry.text, "agent_log", - sourceRef, + entry.sourceRef, entry.agent ?? "unknown", entry.taskId, entry.timestamp, @@ -8979,25 +9002,22 @@ export class TaskStore extends EventEmitter { console.warn("[fusion] Failed to scan goal citations from agent_log:", err); } } - if (citationInputs.length > 0) { - try { - this.recordGoalCitations(citationInputs); - } catch (err) { - console.warn("[fusion] Failed to record goal citations from agent_log batch:", err); - } - } - this.db.bumpLastModified(); } - }); - flushSucceeded = true; + + if (citationInputs.length > 0) { + try { + this.recordGoalCitations(citationInputs); + } catch (err) { + console.warn("[fusion] Failed to record goal citations from agent_log batch:", err); + } + } + this.db.bumpLastModified(); + } } finally { - // Always drain the original slice from the buffer. this.agentLogBuffer.splice(0, flushCount); - // On transient failures (busy/IO), requeue valid entries for retry. - // Stale rows were already filtered out above. - if (!flushSucceeded && validEntries.length > 0) { - this.agentLogBuffer.unshift(...validEntries); - // Re-arm the flush timer so retried entries don't sit in memory forever. + const remainingValidEntries = validEntries.filter((entry) => !flushedEntries.has(entry)); + if (remainingValidEntries.length > 0) { + this.agentLogBuffer.unshift(...remainingValidEntries); if (!this.agentLogFlushTimer) { this.agentLogFlushTimer = setTimeout(() => { try { @@ -9034,50 +9054,65 @@ export class TaskStore extends EventEmitter { ...entry, detail: truncateAgentLogDetail(entry.detail, entry.type), })); - const stmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); + const liveTaskIds = new Set( + (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), + ); + const validEntries = normalizedEntries.filter((entry) => liveTaskIds.has(entry.taskId)); + const dropped = normalizedEntries.length - validEntries.length; + if (dropped > 0) { + console.warn(`[fusion] Dropped ${dropped} batch agent log entries for deleted tasks (${this.db.path})`); + } - this.db.transaction(() => { - const citationInputs: GoalCitationInput[] = []; - for (const entry of normalizedEntries) { - const insertResult = stmt.run( - entry.taskId, + const citationInputs: GoalCitationInput[] = []; + const entriesByTask = new Map(); + for (const entry of validEntries) { + const taskEntries = entriesByTask.get(entry.taskId); + if (taskEntries) { + taskEntries.push(entry); + } else { + entriesByTask.set(entry.taskId, [entry]); + } + } + + for (const [taskId, taskEntries] of entriesByTask) { + const appended = appendAgentLogEntriesSync( + this.taskDir(taskId), + taskEntries.map((entry) => ({ timestamp, - entry.text, - entry.type, - entry.detail ?? null, - entry.agent ?? null, - ) as { lastInsertRowid?: number | bigint }; - const insertedId = insertResult.lastInsertRowid; - if (insertedId === undefined || insertedId === null) { - continue; - } + taskId: entry.taskId, + text: entry.text, + type: entry.type, + detail: entry.detail ?? null, + agent: entry.agent ?? null, + })), + ); + for (const entry of appended) { try { citationInputs.push( ...this.scanAndRecordCitations( entry.text, "agent_log", - `agentLog:${String(insertedId)}`, + entry.sourceRef, entry.agent ?? "unknown", entry.taskId, - timestamp, + entry.timestamp, ), ); } catch (err) { console.warn("[fusion] Failed to scan goal citations from agent log batch:", err); } } - if (citationInputs.length > 0) { - try { - this.recordGoalCitations(citationInputs); - } catch (err) { - console.warn("[fusion] Failed to record goal citations from appendAgentLogBatch:", err); - } + } + if (citationInputs.length > 0) { + try { + this.recordGoalCitations(citationInputs); + } catch (err) { + console.warn("[fusion] Failed to record goal citations from appendAgentLogBatch:", err); } + } + if (validEntries.length > 0) { this.db.bumpLastModified(); - }); + } for (const entry of normalizedEntries) { this.emit("agent:log", { @@ -9091,37 +9126,6 @@ export class TaskStore extends EventEmitter { } } - private mapAgentLogRow(row: Record): AgentLogEntry { - const type = row.type as AgentLogEntry["type"]; - const detail = row.detail != null ? String(row.detail) : undefined; - return { - timestamp: row.timestamp as string, - taskId: row.taskId as string, - text: row.text as string, - type, - ...(detail !== undefined && { detail }), - ...(row.agent != null && { agent: row.agent as AgentLogEntry["agent"] }), - }; - } - - private getAgentLogSelectClause(): string { - const escapedNotice = AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE.replace(/'/g, "''"); - return ` - taskId, - timestamp, - text, - type, - CASE - WHEN type IN ('tool', 'tool_result', 'tool_error') - AND detail IS NOT NULL - AND LENGTH(detail) > ${AGENT_LOG_TOOL_DETAIL_LIMIT} - THEN SUBSTR(detail, 1, ${AGENT_LOG_TOOL_DETAIL_LIMIT}) || '${escapedNotice}' - ELSE detail - END AS detail, - agent - `; - } - async addTaskComment(id: string, text: string, author: string): Promise { // Delegate to unified addComment method return this.addComment(id, text, author); @@ -10030,7 +10034,7 @@ export class TaskStore extends EventEmitter { } /** - * Read historical agent log entries for a task from SQLite. + * Read historical agent log entries for a task from JSONL storage. * Returns entries in chronological order (oldest first). * * Tool-oriented detail payloads are clipped server-side to keep historical @@ -10050,6 +10054,9 @@ export class TaskStore extends EventEmitter { ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return []; + } const limit = options?.limit !== undefined ? (Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0) : undefined; @@ -10059,47 +10066,23 @@ export class TaskStore extends EventEmitter { if (limit === 0) return []; - const selectClause = this.getAgentLogSelectClause(); - - if (limit !== undefined) { - const readCount = offset > 0 ? limit + offset : limit; - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp DESC, id DESC - LIMIT ? - `).all(taskId, readCount) as Array>; - const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse(); - if (offset > 0) { - return entries.slice(0, Math.max(0, entries.length - offset)); - } - return entries; - } - - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp ASC, id ASC - `).all(taskId) as Array>; - const entries = rows.map((row) => this.mapAgentLogRow(row)); - if (offset > 0) { - return entries.slice(0, Math.max(0, entries.length - offset)); - } - return entries; + return readAgentLogEntries(this.taskDir(taskId), { limit, offset }).map( + ({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry, + ); } /** - * Count total number of persisted agent log entries for a task in SQLite. + * Count total number of persisted agent log entries for a task in JSONL storage. * * @param taskId - The task ID (e.g. "KB-001") * @returns Total number of log entries */ async getAgentLogCount(taskId: string): Promise { this.flushAgentLogBuffer(); - const row = this.db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(taskId) as { count: number } | undefined; - return row?.count ?? 0; + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return 0; + } + return countAgentLogEntries(this.taskDir(taskId)); } /** @@ -10117,14 +10100,13 @@ export class TaskStore extends EventEmitter { ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return []; + } const end = endIso ?? new Date().toISOString(); - const selectClause = this.getAgentLogSelectClause(); - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? AND timestamp >= ? AND timestamp <= ? - ORDER BY timestamp ASC, id ASC - `).all(taskId, startIso, end) as Array>; - return rows.map((row) => this.mapAgentLogRow(row)); + return readAgentLogEntriesByTimeRange(this.taskDir(taskId), startIso, end).map( + ({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry, + ); } async importLegacyAgentLogs(): Promise { @@ -10132,18 +10114,23 @@ export class TaskStore extends EventEmitter { const entries = await readdir(this.tasksDir, { withFileTypes: true }); let imported = 0; - const insertStmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); for (const entry of entries) { if (!entry.isDirectory()) continue; - const logPath = join(this.tasksDir, entry.name, "agent.log"); + const taskDir = join(this.tasksDir, entry.name); + const logPath = join(taskDir, "agent.log"); if (!existsSync(logPath)) continue; try { const content = await readFile(logPath, "utf-8"); + const parsedEntries: Array<{ + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string | null; + agent?: AgentLogEntry["agent"] | null; + }> = []; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; @@ -10155,20 +10142,21 @@ export class TaskStore extends EventEmitter { const type = typeof parsed.type === "string" ? parsed.type : null; if (!timestamp || !parsedTaskId || !type) continue; - const text = typeof parsed.text === "string" ? parsed.text : ""; - const detail = typeof parsed.detail === "string" ? parsed.detail : null; - const agent = typeof parsed.agent === "string" ? parsed.agent : null; - const normalizedDetail = truncateAgentLogDetail( - detail, - type as AgentLogEntry["type"], - ); - - insertStmt.run(parsedTaskId, timestamp, text, type, normalizedDetail ?? null, agent); - imported += 1; + parsedEntries.push({ + timestamp, + taskId: parsedTaskId, + text: typeof parsed.text === "string" ? parsed.text : "", + type: type as AgentLogEntry["type"], + detail: typeof parsed.detail === "string" ? parsed.detail : null, + agent: typeof parsed.agent === "string" ? (parsed.agent as AgentLogEntry["agent"]) : null, + }); } catch { // Skip malformed JSONL lines. } } + + appendAgentLogEntriesSync(taskDir, parsedEntries); + imported += parsedEntries.length; } catch (err) { storeLog.warn("Skipping unreadable legacy agent.log file during import", { phase: "importLegacyAgentLogs:read-file", @@ -10205,6 +10193,148 @@ export class TaskStore extends EventEmitter { this.db.bumpLastModified(); } + /** + * One-time migration: copy `agentLogEntries` rows from SQLite into per-task + * JSONL files, then rewrite goal-citation source-refs from the old + * `agentLog:` format to the new `agentLog:{taskId}:{lineNo}` format. + * Guarded by `__meta` so it runs exactly once. + */ + private async migrateAgentLogEntriesToFilesOnce(): Promise { + const migrationKey = "agentLogEntriesToFileMigrationVersion"; + const migrationVersion = "1"; + const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as + | { value: string } + | undefined; + + if (row?.value === migrationVersion) { + return; + } + + // Only run if the agentLogEntries table still exists + const hasTable = + this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1").get() !== + undefined; + if (!hasTable) { + // Table already gone (fresh DB or already migrated) — mark done + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + return; + } + + interface AgentLogRow { + id: number; + taskId: string; + timestamp: string; + text: string; + type: string; + detail: string | null; + agent: string | null; + } + + // Read all rows ordered by taskId, id so each task's entries are + // written in their original insertion order + const rows = this.db + .prepare("SELECT id, taskId, timestamp, text, type, detail, agent FROM agentLogEntries ORDER BY taskId, id") + .all() as AgentLogRow[]; + + if (rows.length > 0) { + // Group rows by task + const entriesByTask = new Map(); + for (const row of rows) { + let taskRows = entriesByTask.get(row.taskId); + if (!taskRows) { + taskRows = []; + entriesByTask.set(row.taskId, taskRows); + } + taskRows.push(row); + } + + // Write per-task JSONL files + const rowIdToNewRef = new Map(); + for (const [taskId, taskRows] of entriesByTask) { + const td = this.taskDir(taskId); + const appended = appendAgentLogEntriesSync( + td, + taskRows.map((r) => ({ + timestamp: r.timestamp, + taskId: r.taskId, + text: r.text, + type: r.type as AgentLogEntry["type"], + detail: r.detail, + agent: r.agent as AgentLogEntry["agent"] | null, + })), + ); + // Build mapping from old rowid to new sourceRef + for (let i = 0; i < taskRows.length; i++) { + rowIdToNewRef.set(taskRows[i]!.id, appended[i]!.sourceRef); + } + } + + // Rewrite goal-citation source-refs that use the old agentLog: format + const oldFormatRows = this.db + .prepare("SELECT id, sourceRef FROM goal_citations WHERE surface = 'agent_log' AND sourceRef GLOB 'agentLog:[0-9]*'") + .all() as Array<{ id: number; sourceRef: string }>; + + const updateStmt = this.db.prepare("UPDATE goal_citations SET sourceRef = ? WHERE id = ?"); + this.db.transaction(() => { + for (const citation of oldFormatRows) { + const oldRowId = parseInt(citation.sourceRef.replace("agentLog:", ""), 10); + const newRef = rowIdToNewRef.get(oldRowId); + if (newRef) { + updateStmt.run(newRef, citation.id); + } + } + }); + } + + // Mark migration as done + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + this.db.bumpLastModified(); + } + + private async cleanupNoOpTaskMovedActivityRowsOnce(): Promise { + const migrationKey = "noOpTaskMovedActivityCleanupVersion"; + const migrationVersion = "1"; + const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as + | { value: string } + | undefined; + + if (row?.value === migrationVersion) { + return; + } + + const hasTable = + this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activityLog' LIMIT 1").get() !== + undefined; + const markDone = () => { + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + }; + + if (!hasTable) { + markDone(); + this.db.bumpLastModified(); + return; + } + + this.db.transactionImmediate(() => { + this.db.prepare(` + DELETE FROM activityLog + WHERE type = 'task:moved' + AND json_extract(metadata, '$.from') = json_extract(metadata, '$.to') + `).run(); + markDone(); + this.db.bumpLastModified(); + }); + } + // ── Archive Cleanup Methods ───────────────────────────────────────── /** @@ -10847,6 +10977,27 @@ ${stepsSection}`; return this.db.pruneOperationalLogs(retentionMs); } + /** + * Prune per-task JSONL agent log files by removing entries older than the + * configured retention window. Only prunes files for soft-deleted or archived + * tasks (avoids removing logs for still-active tasks). Returns zeroed counts + * when retention is disabled (`<= 0`). + */ + pruneAgentLogFiles(retentionDays: number): { prunedFiles: number; prunedEntries: number; freedBytes: number } { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) { + return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }; + } + // Only prune JSONL files for tasks that are no longer active (soft-deleted or archived) + const inactiveTaskIds = new Set( + ( + this.db + .prepare(`SELECT id FROM tasks WHERE deletedAt IS NOT NULL OR "column" = 'archived'`) + .all() as Array<{ id: string }> + ).map((row) => row.id), + ); + return pruneAgentLogFileEntries(this.tasksDir, retentionDays, inactiveTaskIds); + } + getRootDir(): string { return this.rootDir; } diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 479a965a98..0caaac894d 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -47,6 +47,23 @@ export function resolveEffectiveAutoMerge( return task.autoMerge ?? settings.autoMerge; } +/** + * Gate for auto-merge *processing* (engine enqueue + self-healing sweeps). + * Additive relative to the global setting: when `settings.autoMerge` is on, + * every task flows through — tasks with an explicit `autoMerge: false` are + * parked as `manual-required` downstream by the merger, not silently skipped + * here. When the global setting is off, only tasks with an explicit per-task + * `autoMerge: true` override proceed. Distinct from + * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would + * (incorrectly for processing gates) starve the manual-required parking path. + */ +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} + // Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated. export function resolveEffectiveGroupAutoMerge( group: Pick, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec127dc005..a0ffed9bb0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3697,10 +3697,16 @@ export interface ProjectSettings { * Allowed values: 0 (off, default) or one of 7 | 14 | 30 | 60 | 90. Uses messages.updatedAt inactivity age. */ mailAutoCleanupDays?: number; /** Number of days to retain append-only operational-log rows (activityLog, - * agentLogEntries, runAuditEvents, agentHeartbeats) before periodic maintenance - * prunes them. These tables are the main driver of unbounded database growth. + * runAuditEvents, agentHeartbeats) before periodic maintenance prunes them. + * Agent logs are now stored in per-task JSONL files — see agentLogFileRetentionDays. * Default: 30. Set 0 to disable pruning. Uses each row's `timestamp` column. */ operationalLogRetentionDays?: number; + /** Number of days to retain per-task agent-log JSONL files for soft-deleted + * and archived tasks. Only affects tasks that are no longer active. Entries + * older than this window are removed from the JSONL file during periodic + * maintenance. Default: 0 (disabled). Set to a positive integer (e.g. 90) + * to enable pruning. */ + agentLogFileRetentionDays?: number; /** Number of most-recent chat-room messages kept verbatim in the responder transcript. * Older messages are compacted into a summary block. Default: 12. */ chatRoomRecentVerbatimMessages?: number; diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 7f95bbd55c..3bcf1d8d9f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -406,6 +406,9 @@ function AppInner() { setMissionTargetId(undefined); setMilestoneSliceResumeSessionId(undefined); } + if (newView !== "goalsView") { + setGoalAnchorId(undefined); + } const previousView = taskView; handleChangeTaskView(newView); if (previousView !== newView) { @@ -738,7 +741,14 @@ function AppInner() { const [retryingProjects, setRetryingProjects] = useState(false); const [missionResumeSessionId, setMissionResumeSessionId] = useState(undefined); const [missionTargetId, setMissionTargetId] = useState(undefined); + const [goalAnchorId, setGoalAnchorId] = useState(undefined); const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState(undefined); + + useEffect(() => { + if (taskView !== "goalsView" && goalAnchorId !== undefined) { + setGoalAnchorId(undefined); + } + }, [goalAnchorId, taskView]); const [quickChatOpen, setQuickChatOpen] = useState(false); const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false); const [dashboardHealth, setDashboardHealth] = useState(null); @@ -1490,6 +1500,10 @@ function AppInner() { targetMissionId={missionTargetId} milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} + onNavigateToGoal={(goalId) => { + setGoalAnchorId(goalId); + handleChangeTaskView("goalsView"); + }} /> ); @@ -1621,7 +1635,7 @@ function AppInner() { return ( - + ); diff --git a/packages/dashboard/app/__tests__/api-skills.test.ts b/packages/dashboard/app/__tests__/api-skills.test.ts index 8b1ff7b000..b5e8c26def 100644 --- a/packages/dashboard/app/__tests__/api-skills.test.ts +++ b/packages/dashboard/app/__tests__/api-skills.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fetchDiscoveredSkills, toggleExecutionSkill, + installSkill, fetchSkillsCatalog, type DiscoveredSkill, type CatalogFetchResult, @@ -156,6 +157,51 @@ describe("toggleExecutionSkill", () => { }); }); +describe("installSkill", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("posts install requests without projectId", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true })); + + const output = await installSkill("owner/repo", "skill-name"); + + expect(output).toEqual({ success: true }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/skills/install", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ source: "owner/repo", skill: "skill-name" }), + }), + ); + }); + + it("includes projectId in install requests", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true })); + + await installSkill("owner/repo", undefined, "proj_123"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/skills/install?projectId=proj_123", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ source: "owner/repo", skill: undefined }), + }), + ); + }); + + it("propagates install errors", async () => { + globalThis.fetch = vi.fn().mockReturnValue( + mockFetchResponse(false, { error: "installer failed", code: "install_failed" }, 502), + ); + + await expect(installSkill("owner/repo", undefined)).rejects.toThrow("installer failed"); + }); +}); + describe("fetchSkillsCatalog", () => { const originalFetch = globalThis.fetch; diff --git a/packages/dashboard/app/__tests__/space-token-defined.test.ts b/packages/dashboard/app/__tests__/space-token-defined.test.ts new file mode 100644 index 0000000000..ac3f63796d --- /dev/null +++ b/packages/dashboard/app/__tests__/space-token-defined.test.ts @@ -0,0 +1,45 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const appDir = resolve(__dirname, ".."); +const componentsDir = resolve(appDir, "components"); +const stylesPath = resolve(appDir, "styles.css"); +const themeDataPath = resolve(appDir, "public/theme-data.css"); + +function listComponentCssFiles(): string[] { + return readdirSync(componentsDir) + .filter((name) => name.endsWith(".css")) + .sort(); +} + +describe("dashboard spacing token hygiene", () => { + it("does not reference undefined --space-2xs in any component stylesheet", () => { + const violations: string[] = []; + + for (const fileName of listComponentCssFiles()) { + const filePath = join(componentsDir, fileName); + const source = readFileSync(filePath, "utf8"); + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].includes("var(--space-2xs)")) { + violations.push(`${fileName}:${index + 1}:${lines[index].trim()}`); + } + } + } + + expect(violations).toEqual([]); + }); + + it("documents that --space-2xs remains intentionally undefined in shared token sources", () => { + const tokenSources = [ + { name: "styles.css", source: readFileSync(stylesPath, "utf8") }, + { name: "theme-data.css", source: readFileSync(themeDataPath, "utf8") }, + ]; + + for (const { name, source } of tokenSources) { + expect(source).not.toContain("--space-2xs:"); + } + }); +}); diff --git a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts index e4231946f4..7e982b1028 100644 --- a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts +++ b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts @@ -1,6 +1,9 @@ +import React from "react"; import { describe, expect, it } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; +import { render, screen } from "@testing-library/react"; +import { Loader2 } from "lucide-react"; function extractBlock(content: string, pattern: RegExp): string { const match = content.match(pattern); @@ -20,31 +23,44 @@ function extractBlock(content: string, pattern: RegExp): string { return content.slice(match!.index!, index); } +function assertSharedSpinnerCssContract(css: string): void { + const topLevelSpinBlock = extractBlock(css, /@keyframes\s+spin\s*\{/); + const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + const svgSpinnerBlock = css.match(/svg\.animate-spin,\s*svg\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + + expect(topLevelSpinBlock).toContain("transform: rotate(360deg);"); + expect(css.indexOf("@keyframes spin")).toBeLessThan(css.indexOf(":root {\n --bg:")); + + expect(animateSpinBlock).toContain("animation: spin 1s linear infinite;"); + expect(spinBlock).toContain("animation: spin 1s linear infinite;"); + expect(animateSpinBlock).toContain("transform-origin: center;"); + expect(spinBlock).toContain("transform-origin: center;"); + + expect(svgSpinnerBlock).toContain("transform-box: view-box;"); + expect(svgSpinnerBlock).not.toContain("transform-box: fill-box;"); +} + describe("global spinner animation utility", () => { const css = readFileSync(resolve(__dirname, "../styles.css"), "utf8"); - it("keeps top-level spin keyframes rotating to 360deg", () => { - const topLevelSpinBlock = extractBlock(css, /@keyframes\s+spin\s*\{/); - - expect(topLevelSpinBlock).toContain("transform: rotate(360deg);"); - expect(css.indexOf("@keyframes spin")).toBeLessThan(css.indexOf(":root {\n --bg:")); + it("keeps the shared spin utility centered and rotating infinitely", () => { + assertSharedSpinnerCssContract(css); }); - it("keeps the shared animate-spin and spin utilities running infinitely", () => { - const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + it("keeps the svg spinner contract aligned with lucide stroke-only loaders", () => { + render(React.createElement(Loader2, { className: "animate-spin", "data-testid": "spinner" })); - expect(animateSpinBlock).toContain("animation: spin 1s linear infinite;"); - expect(spinBlock).toContain("animation: spin 1s linear infinite;"); + const spinner = screen.getByTestId("spinner"); + expect(spinner.tagName.toLowerCase()).toBe("svg"); + expect(spinner).toHaveAttribute("class", expect.stringContaining("animate-spin")); + expect(spinner).toHaveAttribute("fill", "none"); + expect(spinner).toHaveAttribute("viewBox", "0 0 24 24"); }); - it("anchors SVG spinners around their own center", () => { - const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const svgSpinnerBlock = css.match(/svg\.animate-spin,\s*svg\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + it("fails the contract if svg spinners regress back to fill-box anchoring", () => { + const regressedCss = css.replace("transform-box: view-box;", "transform-box: fill-box;"); - expect(animateSpinBlock).toContain("transform-origin: center;"); - expect(spinBlock).toContain("transform-origin: center;"); - expect(svgSpinnerBlock).toContain("transform-box: fill-box;"); + expect(() => assertSharedSpinnerCssContract(regressedCss)).toThrow(); }); }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 6ac1f4c616..0a987a4900 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6919,6 +6919,8 @@ export interface MissionSummary { completedMilestones: number; totalFeatures: number; completedFeatures: number; + linkedGoalCount: number; + eventCount: number; progressPercent: number; } @@ -6978,6 +6980,8 @@ export interface SliceWithFeatures extends Slice { /** Full mission hierarchy */ export interface MissionWithHierarchy extends Mission { + /** Unfiltered total of all mission lifecycle events, matching MissionSummary.eventCount and getMissionEvents total with no eventType filter */ + eventCount?: number; milestones: MilestoneWithSlices[]; } @@ -8779,6 +8783,18 @@ export async function toggleExecutionSkill( }); } +/** Install a catalog skill from skills.sh */ +export async function installSkill( + source: string, + skill: string | undefined, + projectId?: string, +): Promise<{ success: true }> { + return api<{ success: true }>(withProjectId("/skills/install", projectId), { + method: "POST", + body: JSON.stringify({ source, skill }), + }); +} + /** Fetch the skills.sh catalog */ export async function fetchSkillsCatalog( query?: string, diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 5798c5aa52..423c39df46 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -236,24 +236,21 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask } }; + const visualViewport = window.visualViewport; + const handleViewportResize = () => { + scheduleStabilization(); + }; + scheduleStabilization(); window.addEventListener("pageshow", handlePageShow); - - const visualViewport = window.visualViewport; - let handleViewportResize: (() => void) | null = null; - if (visualViewport) { - handleViewportResize = () => { - scheduleStabilization(); - visualViewport.removeEventListener("resize", handleViewportResize!); - handleViewportResize = null; - }; + if (typeof visualViewport?.addEventListener === "function") { visualViewport.addEventListener("resize", handleViewportResize); } return () => { window.removeEventListener("pageshow", handlePageShow); - if (handleViewportResize) { - visualViewport?.removeEventListener("resize", handleViewportResize); + if (typeof visualViewport?.removeEventListener === "function") { + visualViewport.removeEventListener("resize", handleViewportResize); } if (rafId !== null) { window.cancelAnimationFrame(rafId); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 8ef3e5efe8..51889e9d6e 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -585,6 +585,8 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi } }, [favoriteModels, favoriteProviders, refresh]); + const resolvedModel = selectedModel || defaultModelValue; + const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault(); @@ -595,16 +597,16 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi } // model mode - if (!selectedModel) return; - const slashIdx = selectedModel.indexOf("/"); + if (!resolvedModel) return; + const slashIdx = resolvedModel.indexOf("/"); if (slashIdx <= 0) return; - const modelProvider = selectedModel.slice(0, slashIdx); - const modelId = selectedModel.slice(slashIdx + 1); + const modelProvider = resolvedModel.slice(0, slashIdx); + const modelId = resolvedModel.slice(slashIdx + 1); onCreate({ agentId: FN_AGENT_ID, modelProvider, modelId }); }; const isSubmitDisabled = - chatMode === "agent" ? !selectedAgentId : !selectedModel; + chatMode === "agent" ? !selectedAgentId : !resolvedModel; return (
diff --git a/packages/dashboard/app/components/ConfirmDialog.css b/packages/dashboard/app/components/ConfirmDialog.css index 8bd255f25a..ddbcac1a86 100644 --- a/packages/dashboard/app/components/ConfirmDialog.css +++ b/packages/dashboard/app/components/ConfirmDialog.css @@ -12,7 +12,7 @@ .confirm-dialog__checkbox { display: grid; - gap: var(--space-2xs); + gap: var(--space-xs); margin: 0 var(--space-xl) var(--space-lg); } diff --git a/packages/dashboard/app/components/GoalsView.css b/packages/dashboard/app/components/GoalsView.css index 558aca8b59..e2708b7183 100644 --- a/packages/dashboard/app/components/GoalsView.css +++ b/packages/dashboard/app/components/GoalsView.css @@ -98,6 +98,12 @@ align-items: center; justify-content: space-between; gap: var(--space-md); + scroll-margin-top: var(--space-xl); +} + +.goals-card--anchored { + border-color: var(--color-warning); + box-shadow: var(--focus-ring-strong); } .goals-card-archived { diff --git a/packages/dashboard/app/components/GoalsView.tsx b/packages/dashboard/app/components/GoalsView.tsx index fa67da278d..94500daa9a 100644 --- a/packages/dashboard/app/components/GoalsView.tsx +++ b/packages/dashboard/app/components/GoalsView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Goal } from "@fusion/core"; import { Plus, Sparkles } from "lucide-react"; import ReactMarkdown from "react-markdown"; @@ -8,6 +8,7 @@ import "./GoalsView.css"; export interface GoalsViewProps { initialGoals?: Goal[]; + anchorGoalId?: string; } const MAX_ACTIVE_GOALS = 5; @@ -20,8 +21,10 @@ function isCapError(payload: unknown): boolean { return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED"); } -export function GoalsView({ initialGoals }: GoalsViewProps) { +export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) { const [goals, setGoals] = useState(() => initialGoals ?? []); + const [highlightedGoalId, setHighlightedGoalId] = useState(null); + const anchorTimeoutRef = useRef | null>(null); const [loading, setLoading] = useState(initialGoals === undefined); const [errorMessage, setErrorMessage] = useState(null); @@ -81,6 +84,38 @@ export function GoalsView({ initialGoals }: GoalsViewProps) { const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]); const showWarning = activeCount >= WARNING_THRESHOLD && activeCount <= MAX_ACTIVE_GOALS; + useEffect(() => { + if (!anchorGoalId) { + setHighlightedGoalId(null); + return; + } + + const target = document.getElementById(`goal-card-${anchorGoalId}`); + if (!target) { + return; + } + + setHighlightedGoalId(anchorGoalId); + if (typeof target.scrollIntoView === "function") { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + if (anchorTimeoutRef.current) { + clearTimeout(anchorTimeoutRef.current); + } + anchorTimeoutRef.current = setTimeout(() => { + setHighlightedGoalId((current) => (current === anchorGoalId ? null : current)); + anchorTimeoutRef.current = null; + }, 1600); + + return () => { + if (anchorTimeoutRef.current) { + clearTimeout(anchorTimeoutRef.current); + anchorTimeoutRef.current = null; + } + }; + }, [anchorGoalId, goals]); + function openAddForm() { setErrorMessage(null); setAddError(null); @@ -359,7 +394,8 @@ export function GoalsView({ initialGoals }: GoalsViewProps) { {goals.map((goal) => (
{editGoalId === goal.id ? ( diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 0feeb7e934..14129259b7 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -1170,9 +1170,14 @@ export function Header({ aria-pressed={view === "mailbox"} > - {mailboxPendingApprovalCount > 0 && view !== "mailbox" && ( + {view !== "mailbox" && mailboxPendingApprovalCount > 0 ? ( - )} + ) : view !== "mailbox" && mailboxUnreadCount > 0 ? ( + + ) : null} {pluginDashboardViews .filter((entry) => entry.view.placement === "primary") diff --git a/packages/dashboard/app/components/MergeAdvanceNotice.css b/packages/dashboard/app/components/MergeAdvanceNotice.css index b446e3b157..0aea2b1e7c 100644 --- a/packages/dashboard/app/components/MergeAdvanceNotice.css +++ b/packages/dashboard/app/components/MergeAdvanceNotice.css @@ -79,8 +79,8 @@ .merge-advance-notice__push-advanced label { display: inline-flex; align-items: center; - gap: var(--space-2xs); - margin-top: var(--space-2xs); + gap: var(--space-xs); + margin-top: var(--space-xs); } .merge-advance-notice__push-error { @@ -88,8 +88,8 @@ } .merge-advance-notice__push-error pre { - margin: var(--space-2xs) 0; - padding: var(--space-2xs); + margin: var(--space-xs) 0; + padding: var(--space-xs); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--color-error) 12%, transparent); color: var(--color-error); diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 1c6362bd1a..d85879b1f8 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -371,6 +371,12 @@ padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4)); } +.mission-status-badge--unlinked { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent); +} + /* ── Shared Controls ── */ .mission-icon-btn { display: inline-flex; @@ -1068,6 +1074,45 @@ gap: var(--space-sm); } +.mission-detail__linked-goals { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding-top: var(--space-xs); +} + +.mission-detail__linked-goals-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.mission-detail__linked-goals-title { + margin: 0; + font-size: calc(var(--space-sm) + var(--space-xs)); + color: var(--text-muted); + font-weight: 600; +} + +.mission-detail__linked-goals-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.mission-detail__linked-goal-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.mission-detail__linked-goals-empty { + margin: 0; + color: var(--text-muted); +} + .mission-detail__run-settings { display: flex; flex-direction: column; @@ -1731,15 +1776,6 @@ padding: calc(var(--space-xs) * 0.5) var(--space-sm); } -.mission-assertions__mode-tag--warning { - color: var(--color-warning); - border-color: color-mix(in srgb, var(--color-warning) 40%, var(--border)); -} - -.mission-assertions__mode-tag--informational { - color: var(--text-muted); -} - .mission-assertions__rollup-header { display: flex; align-items: center; @@ -1801,28 +1837,6 @@ margin-bottom: var(--space-sm); } -.mission-assertion__enforcement { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - font-size: calc(var(--space-sm) + var(--space-xs) * 0.5); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: calc(var(--space-xs) * 0.5) var(--space-sm); - color: var(--text-dim); - background: color-mix(in srgb, var(--surface) 85%, var(--bg)); - flex-shrink: 0; -} - -.mission-assertion__enforcement--enforced { - color: var(--color-success); - border-color: color-mix(in srgb, var(--color-success) 40%, var(--border)); -} - -.mission-assertion__enforcement--informational { - color: var(--text-muted); -} - .mission-assertion__linked-count { font-size: calc(var(--space-sm) + var(--space-xs) * 0.75); color: var(--text-dim); @@ -2545,6 +2559,11 @@ flex-wrap: wrap; } + .mission-detail__linked-goals-header, + .mission-detail__linked-goals-list { + align-items: stretch; + } + .mission-detail__run-help, .mission-list__item-run-help { max-width: 100%; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index d7559777bd..4a0c7d14a7 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -123,6 +123,8 @@ interface MissionManagerProps { milestoneSliceResumeSessionId?: string; /** Called when milestone/slice resume session fetch fails */ onMilestoneSliceResumeFetchError?: () => void; + /** Navigate to the goals view anchored to a specific goal */ + onNavigateToGoal?: (goalId: string) => void; } // Status badge colors — use CSS custom-property-compatible tokens @@ -581,6 +583,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi return { ...mission, + linkedGoals: Array.isArray(mission.linkedGoals) ? mission.linkedGoals : [], milestones: mission.milestones.map((milestone) => { if (!Array.isArray(milestone.slices)) { throw new Error(`Malformed mission detail response: milestone ${milestone.id} is missing slices`); @@ -603,7 +606,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi }; } -export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) { +export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { const isActive = isInline || isOpen; const cacheSuffix = projectId ?? ""; const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`; @@ -897,7 +900,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const [missionHealthById, setMissionHealthById] = useState>(new Map()); const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure"); - const milestoneAssertionGapSignatureRef = useRef>(new Map()); const [missionEvents, setMissionEvents] = useState([]); const missionEventsRef = useRef([]); const missionsRef = useRef([]); @@ -914,31 +916,24 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const activityEventsContainerRef = useRef(null); - useEffect(() => { - if (!selectedMission) return; + const activityEventsEndRef = useRef(null); - const nextSignatures = new Map(); - for (const milestone of selectedMission.milestones) { - const featuresWithAcceptanceCriteria = milestone.slices - .flatMap((slice) => slice.features) - .filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0); - const assertionCount = assertionsByMilestone.get(milestone.id)?.length ?? 0; - const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && assertionCount === 0; - const signature = `${hasZeroAssertionGuard}:${featuresWithAcceptanceCriteria.length}:${assertionCount}`; - const previousSignature = milestoneAssertionGapSignatureRef.current.get(milestone.id); - if (hasZeroAssertionGuard && previousSignature !== signature) { - console.warn("[MissionManager] milestone_zero_assertion_guard", { - milestoneId: milestone.id, - featureAcceptanceCriteriaCount: featuresWithAcceptanceCriteria.length, - assertionCount, - }); - } - nextSignatures.set(milestone.id, signature); + const activityTabEventCount = useMemo(() => { + if (!selectedMission?.id) { + return eventsTotal; } - milestoneAssertionGapSignatureRef.current = nextSignatures; - }, [assertionsByMilestone, selectedMission]); - const activityEventsEndRef = useRef(null); + const baseCount = selectedMission.eventCount + ?? missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount; + + if (baseCount == null) { + return eventsTotal; + } + + return Math.max(baseCount, eventsTotal); + }, [eventsTotal, missions, selectedMission?.eventCount, selectedMission?.id]); + + const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]); // Keep latest state available to long-lived SSE handlers without reconnect churn. missionsRef.current = missions; @@ -1560,10 +1555,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr if (shouldAutoScroll) { requestAnimationFrame(() => { - const container = activityEventsContainerRef.current; - if (container) { - container.scrollTop = 0; - } + scrollActivityToLatest(); }); } } catch { @@ -1606,6 +1598,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr projectId, refreshMissionSidebar, refreshValidationTelemetry, + scrollActivityToLatest, ]); // Mission handlers @@ -2528,6 +2521,32 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
+
+
+

Linked Goals

+ + {selectedMission.linkedGoals?.length ?? 0} linked + +
+ {(selectedMission.linkedGoals?.length ?? 0) > 0 ? ( +
+ {(selectedMission.linkedGoals ?? []).map((goal) => ( + + ))} +
+ ) : ( +

No linked goals.

+ )} +
+

Mission run settings

{/* ── Autopilot section ── */} @@ -2748,7 +2767,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr aria-selected={activeTab === "activity"} data-testid="mission-tab-activity" > - Activity ({eventsTotal}) + Activity ({activityTabEventCount}) @@ -2773,7 +2792,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id)) ? assertionsByMilestone.get(milestone.id)! : [] as MissionContractAssertion[]; - const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0; return (
@@ -3584,17 +3602,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr {/* Assertions Panel */}
- Contract assertions (validator-enforced when linked) + Contract assertions (AI-validated) - Enforced by autopilot + AI-validated mission gate - {hasZeroAssertionGuard && ( - - - Feature criteria present but no enforced contract assertions linked - - )} {milestoneRollup && ( { const linked = linkedFeaturesByAssertion.get(assertion.id); const count = linked?.length ?? 0; - const isEnforced = count > 0; - return ( - <> - - - {isEnforced ? "Enforced gate" : "Informational"} - - {count > 0 ? ( - - ({count} linked) - - ) : null} - - ); + return count > 0 ? ( + + ({count} linked) + + ) : null; })()}
+
+ + + + Lowering this window means Reliability metrics/charts and the Activity feed will not show history older + than the selected range. Per-task task detail history is unaffected. Default: 30 days. + +

Chat Rooms

@@ -5615,57 +5637,59 @@ export function SettingsModal({
Advanced — external search providers -
- - -
-
- - - setForm((current) => ({ - ...current, - researchGlobalSearxngUrl: event.target.value || undefined, - })) - } - placeholder="https://searx.example.com" - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalGoogleSearchCx: event.target.value || undefined, - })) - } - placeholder="custom-search-engine-id" - /> -
-
- Configure Brave, Tavily, and Google API keys in Authentication. - +
+
+ + +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalSearxngUrl: event.target.value || undefined, + })) + } + placeholder="https://searx.example.com" + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalGoogleSearchCx: event.target.value || undefined, + })) + } + placeholder="custom-search-engine-id" + /> +
+
+ Configure Brave, Tavily, and Google API keys in Authentication. + +
@@ -6106,31 +6130,6 @@ export function SettingsModal({ )}
-

Database Maintenance

-
- - - - Prune append-only operational logs (activity log, agent logs, run audit, heartbeats) older than this - many days during periodic maintenance. Keeps the database from growing without bound — large databases - are slower to checkpoint and more prone to corruption. Default: 30 days. - -
-

Memory Backups

) : (
- {catalogEntries.map((entry) => ( -
-

{entry.name}

- {entry.description && ( -

{entry.description}

- )} - {entry.tags && entry.tags.length > 0 && ( -
- {entry.tags.map((tag) => ( - - {tag} - - ))} + {catalogEntries.map((entry) => { + const source = entry.repo?.trim(); + const canInstall = Boolean(source); + const isInstalling = installingCatalogEntryId === entry.id; + + return ( +
+
+

{entry.name}

+ {canInstall ? ( + + ) : null}
- )} - {entry.installs !== undefined && ( - - {entry.installs.toLocaleString()} installs - - )} -
- ))} + {entry.description && ( +

{entry.description}

+ )} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {entry.installs !== undefined && ( + + {entry.installs.toLocaleString()} installs + + )} +
+ ); + })}
)}
diff --git a/packages/dashboard/app/components/TaskReviewTab.css b/packages/dashboard/app/components/TaskReviewTab.css index c86af723ad..d2b3146605 100644 --- a/packages/dashboard/app/components/TaskReviewTab.css +++ b/packages/dashboard/app/components/TaskReviewTab.css @@ -1,110 +1,188 @@ .task-review-tab { display: flex; flex-direction: column; - gap: var(--space-md); + gap: var(--space-lg); + min-width: 0; } .task-review-tab__header { display: flex; - gap: var(--space-md); + align-items: flex-start; justify-content: space-between; - align-items: center; + gap: var(--space-lg); flex-wrap: wrap; } .task-review-tab__summary-wrap { + flex: 1 1 20rem; + min-width: 0; +} + +.task-review-tab__summary-group { display: flex; - align-items: center; - gap: var(--space-sm); - flex-wrap: wrap; + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs); + min-width: 0; } .task-review-tab__summary { margin: 0; - color: var(--text-muted); + color: var(--text); + font-family: var(--font-primary); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.5; + overflow-wrap: anywhere; } -.task-review-tab__decision { +.task-review-tab__decision, +.task-review-tab__status { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: fit-content; + min-width: 0; padding: 0 var(--space-sm); + min-height: calc(var(--space-lg) + var(--space-xs)); border-radius: var(--radius-pill); + border: var(--btn-border-width) solid transparent; font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.2; text-transform: capitalize; } -.task-review-tab__decision--APPROVED { +.task-review-tab__decision--APPROVED, +.task-review-tab__status--addressed { color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 14%, transparent); + border-color: color-mix(in srgb, var(--color-success) 28%, transparent); } -.task-review-tab__decision--CHANGES_REQUESTED { +.task-review-tab__decision--CHANGES_REQUESTED, +.task-review-tab__status--failed { color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 14%, transparent); + border-color: color-mix(in srgb, var(--color-error) 28%, transparent); } .task-review-tab__decision--COMMENTED, .task-review-tab__decision--PENDING, -.task-review-tab__decision--REVIEW_REQUIRED { +.task-review-tab__decision--REVIEW_REQUIRED, +.task-review-tab__status--queued, +.task-review-tab__status--in-progress { color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 16%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 30%, transparent); } .task-review-tab__actions { display: flex; - gap: var(--space-sm); + flex-wrap: wrap; align-items: flex-start; + justify-content: flex-end; + gap: var(--space-sm); + flex: 0 1 auto; +} + +.task-review-tab__actions .btn { + flex-shrink: 0; } .task-review-tab__auto-merge-control { display: flex; flex-direction: column; - gap: var(--space-2xs); - min-inline-size: max-content; + gap: var(--space-xs); + min-inline-size: 14rem; + padding: var(--space-sm); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--surface) 72%, var(--card)); } .task-review-tab__list { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: var(--space-md); margin: 0; padding: 0; list-style: none; } .task-review-tab__item { - padding: var(--space-md); + padding: var(--card-padding); } .task-review-tab__item-inner { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: var(--space-md); + min-width: 0; } -.task-review-tab__row { - display: grid; - grid-template-columns: auto 1fr auto; +.task-review-tab__direct-item { + display: block; + min-width: 0; +} + +.task-review-tab__direct-item--selectable { + cursor: pointer; +} + +.task-review-tab__item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; gap: var(--space-sm); - align-items: start; + min-width: 0; +} + +.task-review-tab__item-selection { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + min-width: 0; + flex: 1 1 auto; +} + +.task-review-tab__item-selection input { + margin: 0; + flex-shrink: 0; } .task-review-tab__item-summary { color: var(--text); + font-family: var(--font-primary); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.5; + overflow-wrap: anywhere; } -.task-review-tab__direct-item { +.task-review-tab__item-meta-list { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: var(--space-xs); } .task-review-tab__body { margin: 0; - padding: var(--space-sm); + min-width: 0; + max-width: 100%; + padding: var(--space-md); border: var(--btn-border-width) solid var(--border); - border-radius: var(--radius-sm); + border-radius: var(--radius-md); background: var(--surface); color: var(--text); + font-family: var(--font-primary); + font-size: 0.875rem; + line-height: 1.6; white-space: pre-wrap; word-break: break-word; - max-width: 100%; - font-family: var(--font-primary); + overflow-wrap: anywhere; + overflow-x: auto; } .task-review-tab__body.markdown-body { @@ -124,22 +202,12 @@ max-width: 100%; } -.task-review-tab__status { - color: var(--text-muted); - text-transform: capitalize; -} - -.task-review-tab__status--failed { - color: var(--color-error); -} - -.task-review-tab__status--queued, -.task-review-tab__status--in-progress { - color: var(--color-warning); -} - -.task-review-tab__status--addressed { - color: var(--color-success); +.task-review-tab__meta, +.task-review-tab__empty, +.task-review-tab__error { + font-family: var(--font-primary); + font-size: 0.75rem; + line-height: 1.5; } .task-review-tab__meta, @@ -147,18 +215,35 @@ color: var(--text-muted); } -.task-review-tab__refresh-meta { - display: inline-flex; - align-items: center; +.task-review-tab__refresh-meta, +.task-review-tab__empty, +.task-review-tab__error { + display: flex; + align-items: flex-start; gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--surface) 72%, var(--card)); +} + +.task-review-tab__refresh-meta { + min-width: 0; } .task-review-tab__refresh-meta .status-dot { flex-shrink: 0; + margin-top: var(--space-xs); +} + +.task-review-tab__empty { + overflow-wrap: anywhere; } .task-review-tab__error { color: var(--color-error); + border-color: color-mix(in srgb, var(--color-error) 26%, var(--border)); + background: color-mix(in srgb, var(--color-error) 12%, var(--surface)); } .task-review-tab__reviewers, @@ -173,27 +258,52 @@ } @media (max-width: 768px) { - .task-review-tab__row { - grid-template-columns: auto 1fr; + .task-review-tab { + gap: var(--space-md); } - .task-review-tab__status { - grid-column: 2; + .task-review-tab__header { + flex-direction: column; + align-items: stretch; + gap: var(--space-md); + } + + .task-review-tab__summary-wrap, + .task-review-tab__actions, + .task-review-tab__auto-merge-control { + width: 100%; } .task-review-tab__actions { - width: 100%; + justify-content: flex-start; gap: var(--space-sm); - flex-wrap: wrap; } - .task-review-tab__auto-merge-control { + .task-review-tab__actions .btn { width: 100%; - min-inline-size: 0; + justify-content: center; } - .task-review-tab__auto-merge-control .select { - width: 100%; + .task-review-tab__item, + .task-review-tab__body, + .task-review-tab__refresh-meta, + .task-review-tab__empty, + .task-review-tab__error { + min-width: 0; + } + + .task-review-tab__item-header { + flex-direction: column; + align-items: stretch; + } + + .task-review-tab__status, + .task-review-tab__decision { + max-width: 100%; + } + + .task-review-tab__body { + padding: var(--space-sm); } .task-review-tab__refresh-meta { diff --git a/packages/dashboard/app/components/TaskReviewTab.tsx b/packages/dashboard/app/components/TaskReviewTab.tsx index e7720b2893..14b91e2bbe 100644 --- a/packages/dashboard/app/components/TaskReviewTab.tsx +++ b/packages/dashboard/app/components/TaskReviewTab.tsx @@ -314,8 +314,10 @@ export function TaskReviewTab({
-

{summaryText}

- {decisionLabel ? {decisionLabel} : null} +
+

{summaryText}

+ {decisionLabel ? {decisionLabel} : null} +
@@ -373,16 +375,20 @@ export function TaskReviewTab({