FN-7519: add planner-overseer intervention timeline model and UI
Introduces a persisted planner-overseer intervention timeline surfaced in the task-detail Planner Oversight cluster, recording stage, reason, action taken, outcome, attempt count/limit, and source links for each intervention. - Add core `PlannerInterventionEntry` type plus `recordPlannerIntervention`/`getPlannerInterventionTimeline` helpers that persist entries via the run-audit store under the `overseer:intervention` mutation - Add `PlannerInterventionTimeline` dashboard component rendering the timeline (stage/reason/action/outcome/attempts/links) with associated styles - Wire the new API route/legacy handler and TaskDetailModal integration to expose and render the timeline - Add unit tests for the core helpers and the new UI component - Add changeset for the new minor feature and update architecture/dashboard-guide docs Files changed: $(cat /tmp/diffstat_7519.txt) Fusion-Task-Id: FN-7519 Fusion-Task-Lineage: 3c4fcda3-9eb2-46d3-b142-b0c7d6334cd0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7519-planner-intervention-timeline.md
Normal file
7
.changeset/fn-7519-planner-intervention-timeline.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a task-detail planner-overseer intervention timeline (stage, reason, action, outcome, attempts, links).
|
||||
category: feature
|
||||
dev: New core `PlannerInterventionEntry` model + `recordPlannerIntervention`/`getPlannerInterventionTimeline` helpers persisting via the run-audit store under the `overseer:intervention` mutation, plus a `PlannerInterventionTimeline` component rendered in the task-detail Planner Oversight cluster. Emission call-sites land in FN-7520.
|
||||
@@ -1097,6 +1097,7 @@ Mesh configuration and post-provision managed-node operations are registered sep
|
||||
### Run Audit API
|
||||
The run-audit system records every mutation performed by the engine across four domains:
|
||||
- **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued).
|
||||
- **Database / `overseer:intervention`** (FN-7519) — the planner-overseer intervention timeline's single canonical mutation type. `target` is the task ID; metadata carries the six intervention field groups (`stage`, `reason`, `action`, `outcome`, optional `attemptCount`/`attemptLimit`, optional `sourceLinks`). Written only via `recordPlannerIntervention` and read via `getPlannerInterventionTimeline`/`parseInterventionEntry` (`packages/core/src/planner-intervention.ts`) so no parallel audit store or timeline-mapping exists; surfaced read-only in the task-detail Intervention Timeline (`GET /tasks/:id/overseer/interventions`). This task ships the shape and record/read helpers only — FN-7520 wires the actual emission call-sites at overseer decision points.
|
||||
- **Git** — worktree:create, worktree:remove, `worktree:remove-fallback` (metadata `{ fallback: "filesystem-non-empty", error }` when native git removal falls back to filesystem removal + admin prune), commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry).
|
||||
- **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360).
|
||||
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.
|
||||
|
||||
@@ -187,6 +187,8 @@ Features:
|
||||
- Task cards also show a read-only **active-overseer-state indicator** (`.card-overseer-state-badge`, `data-testid="card-overseer-state-badge"`) — "Executor", "Reviewer", "Merger", "Pull request", or "Workflow gate" — while the task is in a monitorable stage (in-progress/in-review, or paused on a workflow input/approval gate) and the effective oversight level is not "off" (and is known, per the same resolution gate as the oversight badge above). The indicator is suppressed (no empty shell) when the task is user-paused, agent-paused off a workflow gate, `done`, or `archived`.
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 adds interactive task-detail planner-overseer controls (quick level change, manual nudge, stop oversight, explain current action) alongside the FN-7516 read-only card badges above. These controls live ONLY in TaskDetailModal, not TaskCard. -->
|
||||
- The task detail modal's inline meta-controls cluster (next to Priority/Execution mode) adds four planner-overseer controls: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes.
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline immediately below the FN-7517 controls above. -->
|
||||
- Below the quick oversight controls, the task detail modal renders an **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) listing every recorded planner-overseer intervention for the task, newest-first: watched stage, reason, action taken, outcome (with a `.status-dot` indicator using semantic outcome tokens), an attempt count/limit badge (only when both are present), and source links (agent log / review comment / failed check / merge error / PR state / generic URL). It renders a calm "No planner interventions yet" empty state rather than an empty shell when there are none, and is hidden entirely (no leftover container) when oversight is off or unresolved for the task. Entries are read via `GET /tasks/:id/overseer/interventions`, which assembles them from the existing run-audit store under the `overseer:intervention` mutation type (`recordPlannerIntervention`/`getPlannerInterventionTimeline` in `@fusion/core`). This is a pure read surface — FN-7520 wires the actual intervention-producing call-sites.
|
||||
- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
|
||||
|
||||
208
packages/core/src/__tests__/planner-intervention.test.ts
Normal file
208
packages/core/src/__tests__/planner-intervention.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
recordPlannerIntervention,
|
||||
parseInterventionEntry,
|
||||
getPlannerInterventionTimeline,
|
||||
type PlannerInterventionStore,
|
||||
} from "../planner-intervention.js";
|
||||
import { OVERSEER_INTERVENTION_MUTATION } from "../types.js";
|
||||
import type { RunAuditEvent, RunAuditEventFilter, RunAuditEventInput } from "../types.js";
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 unit tests for the planner-intervention record/read/parse helpers.
|
||||
Uses a minimal in-memory fake implementing `PlannerInterventionStore` rather
|
||||
than a real TaskStore/SQLite instance — these helpers only depend on the
|
||||
`recordRunAuditEvent`/`getRunAuditEvents` seam, so a narrow fake keeps this
|
||||
suite fast per the project's "Do Not Add Slow Tests" standing rule.
|
||||
*/
|
||||
class FakeRunAuditStore implements PlannerInterventionStore {
|
||||
events: RunAuditEvent[] = [];
|
||||
private counter = 0;
|
||||
|
||||
recordRunAuditEvent(input: RunAuditEventInput): RunAuditEvent {
|
||||
const event: RunAuditEvent = {
|
||||
id: `evt-${++this.counter}`,
|
||||
timestamp: input.timestamp ?? new Date(Date.now() + this.counter).toISOString(),
|
||||
taskId: input.taskId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
domain: input.domain,
|
||||
mutationType: input.mutationType,
|
||||
target: input.target,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
getRunAuditEvents(options: RunAuditEventFilter = {}): RunAuditEvent[] {
|
||||
return this.events
|
||||
.filter((event) => (options.taskId ? event.taskId === options.taskId : true))
|
||||
.filter((event) => (options.mutationType ? event.mutationType === options.mutationType : true))
|
||||
.slice()
|
||||
.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0))
|
||||
.slice(0, options.limit ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
describe("recordPlannerIntervention", () => {
|
||||
it("writes an overseer:intervention run-audit event with all six field groups in metadata", () => {
|
||||
const store = new FakeRunAuditStore();
|
||||
|
||||
recordPlannerIntervention(store, {
|
||||
taskId: "FN-1",
|
||||
stage: "executor",
|
||||
reason: "Executor stalled without progress",
|
||||
action: "retry",
|
||||
outcome: "pending",
|
||||
attemptCount: 1,
|
||||
attemptLimit: 3,
|
||||
sourceLinks: [{ kind: "agent-log", label: "Agent log", target: "run-1" }],
|
||||
});
|
||||
|
||||
expect(store.events).toHaveLength(1);
|
||||
const event = store.events[0];
|
||||
expect(event.mutationType).toBe(OVERSEER_INTERVENTION_MUTATION);
|
||||
expect(event.taskId).toBe("FN-1");
|
||||
expect(event.domain).toBe("database");
|
||||
expect(event.metadata).toMatchObject({
|
||||
stage: "executor",
|
||||
reason: "Executor stalled without progress",
|
||||
action: "retry",
|
||||
outcome: "pending",
|
||||
attemptCount: 1,
|
||||
attemptLimit: 3,
|
||||
sourceLinks: [{ kind: "agent-log", label: "Agent log", target: "run-1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw and omits optional fields when absent", () => {
|
||||
const store = new FakeRunAuditStore();
|
||||
|
||||
expect(() =>
|
||||
recordPlannerIntervention(store, {
|
||||
taskId: "FN-2",
|
||||
stage: "reviewer",
|
||||
reason: "Reviewer awaiting confirmation",
|
||||
action: "observe",
|
||||
outcome: "awaiting-confirmation",
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
const event = store.events[0];
|
||||
expect(event.metadata).not.toHaveProperty("attemptCount");
|
||||
expect(event.metadata).not.toHaveProperty("attemptLimit");
|
||||
expect(event.metadata).not.toHaveProperty("sourceLinks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlannerInterventionTimeline", () => {
|
||||
it("returns entries newest-first and filters out non-intervention events", () => {
|
||||
const store = new FakeRunAuditStore();
|
||||
|
||||
recordPlannerIntervention(store, {
|
||||
taskId: "FN-3",
|
||||
stage: "merger",
|
||||
reason: "First intervention",
|
||||
action: "escalate",
|
||||
outcome: "failed",
|
||||
timestamp: "2026-07-04T10:00:00.000Z",
|
||||
});
|
||||
store.recordRunAuditEvent({
|
||||
taskId: "FN-3",
|
||||
agentId: "system",
|
||||
runId: "unrelated-run",
|
||||
domain: "database",
|
||||
mutationType: "task:handoff",
|
||||
target: "FN-3",
|
||||
timestamp: "2026-07-04T10:30:00.000Z",
|
||||
});
|
||||
recordPlannerIntervention(store, {
|
||||
taskId: "FN-3",
|
||||
stage: "pull-request",
|
||||
reason: "Second intervention",
|
||||
action: "request-confirmation",
|
||||
outcome: "awaiting-confirmation",
|
||||
timestamp: "2026-07-04T11:00:00.000Z",
|
||||
});
|
||||
|
||||
const timeline = getPlannerInterventionTimeline(store, "FN-3");
|
||||
|
||||
expect(timeline).toHaveLength(2);
|
||||
expect(timeline[0].reason).toBe("Second intervention");
|
||||
expect(timeline[1].reason).toBe("First intervention");
|
||||
});
|
||||
|
||||
it("returns [] when there are no interventions for the task", () => {
|
||||
const store = new FakeRunAuditStore();
|
||||
expect(getPlannerInterventionTimeline(store, "FN-4")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseInterventionEntry", () => {
|
||||
it("returns null for unrelated audit events", () => {
|
||||
const event: RunAuditEvent = {
|
||||
id: "evt-x",
|
||||
timestamp: "2026-07-04T10:00:00.000Z",
|
||||
taskId: "FN-5",
|
||||
agentId: "system",
|
||||
runId: "run-x",
|
||||
domain: "database",
|
||||
mutationType: "task:handoff",
|
||||
target: "FN-5",
|
||||
};
|
||||
expect(parseInterventionEntry(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates missing attemptCount/attemptLimit, missing/empty sourceLinks, and unknown enum values without throwing", () => {
|
||||
const event: RunAuditEvent = {
|
||||
id: "evt-y",
|
||||
timestamp: "2026-07-04T10:00:00.000Z",
|
||||
taskId: "FN-6",
|
||||
agentId: "overseer",
|
||||
runId: "run-y",
|
||||
domain: "database",
|
||||
mutationType: OVERSEER_INTERVENTION_MUTATION,
|
||||
target: "FN-6",
|
||||
metadata: {
|
||||
stage: "some-future-stage",
|
||||
reason: "",
|
||||
action: "some-future-action",
|
||||
outcome: "some-future-outcome",
|
||||
sourceLinks: [],
|
||||
},
|
||||
};
|
||||
|
||||
let parsed: ReturnType<typeof parseInterventionEntry> = null;
|
||||
expect(() => {
|
||||
parsed = parseInterventionEntry(event);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.stage).toBe("workflow-gate");
|
||||
expect(parsed!.action).toBe("observe");
|
||||
expect(parsed!.outcome).toBe("pending");
|
||||
expect(parsed!.reason).toBe("Unknown reason");
|
||||
expect(parsed!.attemptCount).toBeUndefined();
|
||||
expect(parsed!.attemptLimit).toBeUndefined();
|
||||
expect(parsed!.sourceLinks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles a missing metadata object on an intervention event without throwing", () => {
|
||||
const event: RunAuditEvent = {
|
||||
id: "evt-z",
|
||||
timestamp: "2026-07-04T10:00:00.000Z",
|
||||
taskId: "FN-7",
|
||||
agentId: "overseer",
|
||||
runId: "run-z",
|
||||
domain: "database",
|
||||
mutationType: OVERSEER_INTERVENTION_MUTATION,
|
||||
target: "FN-7",
|
||||
};
|
||||
expect(() => parseInterventionEntry(event)).not.toThrow();
|
||||
const parsed = parseInterventionEntry(event);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.stage).toBe("workflow-gate");
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
190
packages/core/src/planner-intervention.ts
Normal file
190
packages/core/src/planner-intervention.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type {
|
||||
PlannerInterventionAction,
|
||||
PlannerInterventionEntry,
|
||||
PlannerInterventionOutcome,
|
||||
PlannerInterventionSourceLink,
|
||||
PlannerOversightStage,
|
||||
RunAuditEvent,
|
||||
RunAuditEventFilter,
|
||||
RunAuditEventInput,
|
||||
} from "./types.js";
|
||||
import { OVERSEER_INTERVENTION_MUTATION } from "./types.js";
|
||||
|
||||
/**
|
||||
* FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
* FN-7519 record/read helpers for the planner-intervention timeline. These
|
||||
* build ON TOP OF the existing run-audit store (`recordRunAuditEvent` /
|
||||
* `getRunAuditEvents`) rather than introducing a parallel audit store.
|
||||
* `recordPlannerIntervention` is the SINGLE canonical writer: FN-7520 (which
|
||||
* wires emission call-sites at overseer decision points) and FN-7511/FN-7512
|
||||
* (which produce the actual interventions) must call this helper instead of
|
||||
* emitting `overseer:intervention` run-audit events directly.
|
||||
*/
|
||||
|
||||
/** Minimal store seam this module depends on (satisfied by `TaskStore`). */
|
||||
export interface PlannerInterventionStore {
|
||||
recordRunAuditEvent(input: RunAuditEventInput): RunAuditEvent;
|
||||
getRunAuditEvents(options?: RunAuditEventFilter): RunAuditEvent[];
|
||||
}
|
||||
|
||||
/** Input for recording a planner-intervention timeline entry. */
|
||||
export interface RecordPlannerInterventionInput {
|
||||
taskId: string;
|
||||
stage: PlannerOversightStage;
|
||||
reason: string;
|
||||
action: PlannerInterventionAction;
|
||||
outcome: PlannerInterventionOutcome;
|
||||
attemptCount?: number;
|
||||
attemptLimit?: number;
|
||||
sourceLinks?: PlannerInterventionSourceLink[];
|
||||
/** Heartbeat run ID that produced this intervention. Defaults to a synthetic per-call ID when omitted. */
|
||||
runId?: string;
|
||||
/** Agent ID that produced this intervention. Defaults to "overseer" when omitted. */
|
||||
agentId?: string;
|
||||
/** ISO-8601 timestamp override. Defaults to now. */
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const KNOWN_STAGES: readonly PlannerOversightStage[] = ["executor", "reviewer", "merger", "pull-request", "workflow-gate"];
|
||||
const KNOWN_ACTIONS: readonly PlannerInterventionAction[] = [
|
||||
"observe",
|
||||
"inject-guidance",
|
||||
"retry",
|
||||
"request-fix",
|
||||
"escalate",
|
||||
"request-confirmation",
|
||||
];
|
||||
const KNOWN_OUTCOMES: readonly PlannerInterventionOutcome[] = [
|
||||
"succeeded",
|
||||
"failed",
|
||||
"pending",
|
||||
"awaiting-confirmation",
|
||||
"skipped",
|
||||
];
|
||||
const KNOWN_SOURCE_LINK_KINDS: readonly PlannerInterventionSourceLink["kind"][] = [
|
||||
"agent-log",
|
||||
"review-comment",
|
||||
"failed-check",
|
||||
"merge-error",
|
||||
"pr-state",
|
||||
"url",
|
||||
];
|
||||
|
||||
/** Records one planner-intervention timeline entry as a run-audit event under `overseer:intervention`. Non-throwing on optional-field absence. */
|
||||
export function recordPlannerIntervention(
|
||||
store: PlannerInterventionStore,
|
||||
input: RecordPlannerInterventionInput,
|
||||
): RunAuditEvent {
|
||||
const metadata: Record<string, unknown> = {
|
||||
stage: input.stage,
|
||||
reason: input.reason,
|
||||
action: input.action,
|
||||
outcome: input.outcome,
|
||||
};
|
||||
if (typeof input.attemptCount === "number") metadata.attemptCount = input.attemptCount;
|
||||
if (typeof input.attemptLimit === "number") metadata.attemptLimit = input.attemptLimit;
|
||||
if (input.sourceLinks && input.sourceLinks.length > 0) metadata.sourceLinks = input.sourceLinks;
|
||||
|
||||
return store.recordRunAuditEvent({
|
||||
timestamp: input.timestamp,
|
||||
taskId: input.taskId,
|
||||
agentId: input.agentId ?? "overseer",
|
||||
runId: input.runId ?? `planner-intervention-${input.taskId}-${Date.now()}`,
|
||||
domain: "database",
|
||||
mutationType: OVERSEER_INTERVENTION_MUTATION,
|
||||
target: input.taskId,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
function toSafeStage(value: unknown): PlannerOversightStage {
|
||||
return typeof value === "string" && (KNOWN_STAGES as readonly string[]).includes(value)
|
||||
? (value as PlannerOversightStage)
|
||||
: "workflow-gate";
|
||||
}
|
||||
|
||||
function toSafeAction(value: unknown): PlannerInterventionAction {
|
||||
return typeof value === "string" && (KNOWN_ACTIONS as readonly string[]).includes(value)
|
||||
? (value as PlannerInterventionAction)
|
||||
: "observe";
|
||||
}
|
||||
|
||||
function toSafeOutcome(value: unknown): PlannerInterventionOutcome {
|
||||
return typeof value === "string" && (KNOWN_OUTCOMES as readonly string[]).includes(value)
|
||||
? (value as PlannerInterventionOutcome)
|
||||
: "pending";
|
||||
}
|
||||
|
||||
function toSafeSourceLinks(value: unknown): PlannerInterventionSourceLink[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const links: PlannerInterventionSourceLink[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const kind =
|
||||
typeof raw.kind === "string" && (KNOWN_SOURCE_LINK_KINDS as readonly string[]).includes(raw.kind)
|
||||
? (raw.kind as PlannerInterventionSourceLink["kind"])
|
||||
: "url";
|
||||
const label = typeof raw.label === "string" && raw.label.length > 0 ? raw.label : kind;
|
||||
links.push({
|
||||
kind,
|
||||
label,
|
||||
target: typeof raw.target === "string" ? raw.target : undefined,
|
||||
url: typeof raw.url === "string" ? raw.url : undefined,
|
||||
});
|
||||
}
|
||||
return links.length > 0 ? links : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerantly maps a run-audit event's metadata back to a `PlannerInterventionEntry`.
|
||||
* Returns `null` for non-intervention events. Never throws \u2014 unknown/legacy/
|
||||
* missing fields fall back to safe defaults so a malformed or future-version
|
||||
* metadata payload cannot break the timeline.
|
||||
*/
|
||||
export function parseInterventionEntry(event: RunAuditEvent): PlannerInterventionEntry | null {
|
||||
if (event.mutationType !== OVERSEER_INTERVENTION_MUTATION) return null;
|
||||
|
||||
const metadata = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const attemptCount = typeof metadata.attemptCount === "number" ? metadata.attemptCount : undefined;
|
||||
const attemptLimit = typeof metadata.attemptLimit === "number" ? metadata.attemptLimit : undefined;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
taskId: event.taskId ?? event.target,
|
||||
timestamp: event.timestamp,
|
||||
stage: toSafeStage(metadata.stage),
|
||||
reason: typeof metadata.reason === "string" && metadata.reason.length > 0 ? metadata.reason : "Unknown reason",
|
||||
action: toSafeAction(metadata.action),
|
||||
outcome: toSafeOutcome(metadata.outcome),
|
||||
attemptCount,
|
||||
attemptLimit,
|
||||
sourceLinks: toSafeSourceLinks(metadata.sourceLinks),
|
||||
runId: event.runId,
|
||||
agentId: event.agentId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the planner-intervention timeline for a task, newest-first. Returns `[]` when there are none. */
|
||||
export function getPlannerInterventionTimeline(
|
||||
store: PlannerInterventionStore,
|
||||
taskId: string,
|
||||
opts?: { limit?: number },
|
||||
): PlannerInterventionEntry[] {
|
||||
const events = store.getRunAuditEvents({
|
||||
taskId,
|
||||
mutationType: OVERSEER_INTERVENTION_MUTATION,
|
||||
limit: opts?.limit,
|
||||
});
|
||||
|
||||
const entries: PlannerInterventionEntry[] = [];
|
||||
for (const event of events) {
|
||||
const entry = parseInterventionEntry(event);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
// getRunAuditEvents already orders `timestamp DESC, rowid DESC` (newest-first);
|
||||
// re-sort defensively so this helper's contract holds even if the store's
|
||||
// ordering changes upstream.
|
||||
entries.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0));
|
||||
return entries;
|
||||
}
|
||||
@@ -6380,6 +6380,7 @@ export type RunAuditMutationType =
|
||||
| "mergeQueue:lease-expired"
|
||||
| "task:handoff"
|
||||
| "task:handoff-invariant-violation"
|
||||
| "overseer:intervention"
|
||||
| (string & {});
|
||||
|
||||
/** Input for recording a run-audit event. */
|
||||
@@ -6444,6 +6445,72 @@ export interface RunAuditEventFilter {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ── Planner Intervention Timeline Types ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
* FN-7519 introduces a structured intervention-timeline entry so operators can
|
||||
* see, per task, exactly why and how the planner overseer stepped in. Each
|
||||
* entry records six field groups: the watched STAGE (executor / reviewer /
|
||||
* merger / pull-request / workflow-gate), the REASON for intervention, the
|
||||
* ACTION taken, the OUTCOME, the bounded-recovery ATTEMPT count/limit, and
|
||||
* SOURCE LINKS to supporting evidence (agent logs, review comments, failed
|
||||
* checks, merge errors, or PR state). Entries persist as run-audit events
|
||||
* under the canonical `overseer:intervention` mutation type (see
|
||||
* `OVERSEER_INTERVENTION_MUTATION` and `packages/core/src/planner-intervention.ts`)
|
||||
* so no parallel audit store is introduced. This task owns the entry SHAPE
|
||||
* and its record/read helpers only — FN-7511/FN-7512 produce interventions
|
||||
* and FN-7520 wires the emission call-sites at overseer decision points.
|
||||
*/
|
||||
export type PlannerOversightStage = "executor" | "reviewer" | "merger" | "pull-request" | "workflow-gate";
|
||||
|
||||
export type PlannerInterventionAction =
|
||||
| "observe"
|
||||
| "inject-guidance"
|
||||
| "retry"
|
||||
| "request-fix"
|
||||
| "escalate"
|
||||
| "request-confirmation";
|
||||
|
||||
export type PlannerInterventionOutcome = "succeeded" | "failed" | "pending" | "awaiting-confirmation" | "skipped";
|
||||
|
||||
/** A single piece of evidence backing an intervention entry (agent log, review comment, failed check, merge error, or PR state; `url` is a generic fallback). */
|
||||
export interface PlannerInterventionSourceLink {
|
||||
kind: "agent-log" | "review-comment" | "failed-check" | "merge-error" | "pr-state" | "url";
|
||||
/** Human-readable label for the link (e.g. "Agent log", "Review comment #3"). */
|
||||
label: string;
|
||||
/** Opaque identifier for the target evidence (run ID, comment ID, check name, etc). Optional — the UI degrades gracefully when absent. */
|
||||
target?: string;
|
||||
/** Direct URL to the evidence, when available. Optional. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** A single planner-overseer intervention timeline entry (see FNXC note above for the six field groups). */
|
||||
export interface PlannerInterventionEntry {
|
||||
id: string;
|
||||
taskId: string;
|
||||
/** ISO-8601 timestamp when the intervention occurred. */
|
||||
timestamp: string;
|
||||
stage: PlannerOversightStage;
|
||||
/** Why the overseer intervened (free-text, operator-facing). */
|
||||
reason: string;
|
||||
action: PlannerInterventionAction;
|
||||
outcome: PlannerInterventionOutcome;
|
||||
/** Current attempt count for bounded recovery. Present only for recovery-style actions (e.g. retry/request-fix). */
|
||||
attemptCount?: number;
|
||||
/** Attempt limit for bounded recovery. Present only alongside `attemptCount`. */
|
||||
attemptLimit?: number;
|
||||
/** Evidence links supporting this intervention (agent logs, review comments, failed checks, merge errors, PR state). */
|
||||
sourceLinks?: PlannerInterventionSourceLink[];
|
||||
/** Heartbeat run ID that produced this intervention, if applicable. */
|
||||
runId?: string;
|
||||
/** Agent ID that produced this intervention, if applicable. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/** Canonical run-audit mutation type used to persist planner-intervention entries. Single writer: `recordPlannerIntervention` (see `packages/core/src/planner-intervention.ts`); FN-7520 reuses this helper rather than emitting `overseer:intervention` events directly. */
|
||||
export const OVERSEER_INTERVENTION_MUTATION = "overseer:intervention" as const;
|
||||
|
||||
// ── Agent Permission Types ──────────────────────────────────────────────────
|
||||
|
||||
/** Canonical permission identifiers for agent access control.
|
||||
|
||||
@@ -97,6 +97,7 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { PlannerOverseerRuntimeSnapshot } from "@fusion/core";
|
||||
import type { PlannerInterventionEntry } from "@fusion/core";
|
||||
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry, SkillFileContent } from "@fusion/dashboard";
|
||||
import type { MilestoneValidationTelemetry, MissionInterviewDraftSummary } from "../components/mission-types";
|
||||
@@ -851,6 +852,16 @@ export function explainOverseer(id: string, projectId?: string): Promise<{ snaps
|
||||
return api<{ snapshot: PlannerOverseerRuntimeSnapshot | null }>(withProjectId(`/tasks/${id}/overseer/explain`, projectId), { method: "GET" });
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 read-only client fetch for the planner-intervention timeline. Mirrors
|
||||
`explainOverseer`'s pattern; never mutates state and resolves to an empty
|
||||
array when the task has no recorded interventions.
|
||||
*/
|
||||
export function fetchPlannerInterventionTimeline(id: string, projectId?: string): Promise<{ entries: PlannerInterventionEntry[] }> {
|
||||
return api<{ entries: PlannerInterventionEntry[] }>(withProjectId(`/tasks/${id}/overseer/interventions`, projectId), { method: "GET" });
|
||||
}
|
||||
|
||||
export function archiveTask(id: string, projectId?: string, options?: ArchiveTaskOptions): Promise<Task> {
|
||||
const search = new URLSearchParams();
|
||||
if (options?.removeLineageReferences) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { PlannerInterventionEntry, PlannerInterventionSourceLink } from "@fusion/core";
|
||||
import { fetchPlannerInterventionTimeline } from "../api";
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 Intervention Timeline UI. Renders the FN-7519 planner-intervention
|
||||
entries (stage / reason / action / outcome / attempt count+limit / source
|
||||
links) inside/adjacent to the FN-7517 oversight cluster in TaskDetailModal.
|
||||
Renders a calm empty state ("No planner interventions yet") rather than an
|
||||
empty shell when there are no interventions, and safely falls back on
|
||||
unknown/legacy/future stage/action/outcome enum values instead of throwing.
|
||||
This component is a pure READ surface \u2014 FN-7520 owns wiring the actual
|
||||
`recordPlannerIntervention` call-sites at overseer decision points.
|
||||
*/
|
||||
|
||||
export interface PlannerInterventionTimelineProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
/** When true, the timeline renders nothing (no leftover shell) \u2014 used for the "oversight Off / undefined" branch so callers don't fetch or render an always-on empty container. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const STAGE_LABEL_KEYS: Record<string, string> = {
|
||||
executor: "taskDetail.oversight.interventions.stage.executor",
|
||||
reviewer: "taskDetail.oversight.interventions.stage.reviewer",
|
||||
merger: "taskDetail.oversight.interventions.stage.merger",
|
||||
"pull-request": "taskDetail.oversight.interventions.stage.pullRequest",
|
||||
"workflow-gate": "taskDetail.oversight.interventions.stage.workflowGate",
|
||||
};
|
||||
|
||||
const ACTION_LABEL_KEYS: Record<string, string> = {
|
||||
observe: "taskDetail.oversight.interventions.action.observe",
|
||||
"inject-guidance": "taskDetail.oversight.interventions.action.injectGuidance",
|
||||
retry: "taskDetail.oversight.interventions.action.retry",
|
||||
"request-fix": "taskDetail.oversight.interventions.action.requestFix",
|
||||
escalate: "taskDetail.oversight.interventions.action.escalate",
|
||||
"request-confirmation": "taskDetail.oversight.interventions.action.requestConfirmation",
|
||||
};
|
||||
|
||||
const OUTCOME_LABEL_KEYS: Record<string, string> = {
|
||||
succeeded: "taskDetail.oversight.interventions.outcome.succeeded",
|
||||
failed: "taskDetail.oversight.interventions.outcome.failed",
|
||||
pending: "taskDetail.oversight.interventions.outcome.pending",
|
||||
"awaiting-confirmation": "taskDetail.oversight.interventions.outcome.awaitingConfirmation",
|
||||
skipped: "taskDetail.oversight.interventions.outcome.skipped",
|
||||
};
|
||||
|
||||
const OUTCOME_DOT_MODIFIER: Record<string, string> = {
|
||||
succeeded: "status-dot--online",
|
||||
failed: "status-dot--error",
|
||||
pending: "status-dot--pending",
|
||||
"awaiting-confirmation": "status-dot--connecting",
|
||||
skipped: "status-dot--skipped",
|
||||
};
|
||||
|
||||
const SOURCE_LINK_LABEL_KEYS: Record<string, string> = {
|
||||
"agent-log": "taskDetail.oversight.interventions.sourceLink.agentLog",
|
||||
"review-comment": "taskDetail.oversight.interventions.sourceLink.reviewComment",
|
||||
"failed-check": "taskDetail.oversight.interventions.sourceLink.failedCheck",
|
||||
"merge-error": "taskDetail.oversight.interventions.sourceLink.mergeError",
|
||||
"pr-state": "taskDetail.oversight.interventions.sourceLink.prState",
|
||||
url: "taskDetail.oversight.interventions.sourceLink.url",
|
||||
};
|
||||
|
||||
function SourceLinkChip({ link, index }: { link: PlannerInterventionSourceLink; index: number }) {
|
||||
const { t } = useTranslation();
|
||||
const kindLabel = t(SOURCE_LINK_LABEL_KEYS[link.kind] ?? SOURCE_LINK_LABEL_KEYS.url, link.kind);
|
||||
const display = link.label && link.label.length > 0 ? link.label : kindLabel;
|
||||
const href = link.url;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="planner-intervention-source-link__kind">{kindLabel}</span>
|
||||
<span className="planner-intervention-source-link__label">{display}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<a
|
||||
key={`${link.kind}-${index}`}
|
||||
className="planner-intervention-source-link"
|
||||
data-testid="planner-intervention-source-link"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={link.target ?? href}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
key={`${link.kind}-${index}`}
|
||||
className="planner-intervention-source-link planner-intervention-source-link--inert"
|
||||
data-testid="planner-intervention-source-link"
|
||||
title={link.target}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InterventionEntryRow({ entry }: { entry: PlannerInterventionEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const stageLabel = t(STAGE_LABEL_KEYS[entry.stage] ?? STAGE_LABEL_KEYS["workflow-gate"], entry.stage);
|
||||
const actionLabel = t(ACTION_LABEL_KEYS[entry.action] ?? ACTION_LABEL_KEYS.observe, entry.action);
|
||||
const outcomeLabel = t(OUTCOME_LABEL_KEYS[entry.outcome] ?? OUTCOME_LABEL_KEYS.pending, entry.outcome);
|
||||
const dotModifier = OUTCOME_DOT_MODIFIER[entry.outcome] ?? "status-dot--pending";
|
||||
const hasAttempts = typeof entry.attemptCount === "number" && typeof entry.attemptLimit === "number";
|
||||
const timestampLabel = (() => {
|
||||
try {
|
||||
return new Date(entry.timestamp).toLocaleString();
|
||||
} catch {
|
||||
return entry.timestamp;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<li className="planner-intervention-entry" data-testid="planner-intervention-entry">
|
||||
<div className="planner-intervention-entry__header">
|
||||
<span className="planner-intervention-entry__stage" data-testid="planner-intervention-entry-stage">
|
||||
{stageLabel}
|
||||
</span>
|
||||
<span className={`status-dot ${dotModifier}`} aria-hidden="true" />
|
||||
<span className="planner-intervention-entry__outcome">{outcomeLabel}</span>
|
||||
<span className="planner-intervention-entry__timestamp">{timestampLabel}</span>
|
||||
</div>
|
||||
<p className="planner-intervention-entry__reason">{entry.reason}</p>
|
||||
<div className="planner-intervention-entry__meta">
|
||||
<span className="planner-intervention-entry__action">{actionLabel}</span>
|
||||
{hasAttempts && (
|
||||
<span className="planner-intervention-entry__attempts" data-testid="planner-intervention-entry-attempts">
|
||||
{t("taskDetail.oversight.interventions.attempts", "Attempt {{count}}/{{limit}}", {
|
||||
count: entry.attemptCount,
|
||||
limit: entry.attemptLimit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.sourceLinks && entry.sourceLinks.length > 0 && (
|
||||
<div className="planner-intervention-entry__links" data-testid="planner-intervention-entry-links">
|
||||
{entry.sourceLinks.map((link, index) => (
|
||||
<SourceLinkChip key={`${link.kind}-${link.target ?? link.url ?? index}`} link={link} index={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlannerInterventionTimeline({ taskId, projectId, hidden }: PlannerInterventionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<PlannerInterventionEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hidden) return;
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
fetchPlannerInterventionTimeline(taskId, projectId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setEntries(Array.isArray(result?.entries) ? result.entries : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
setLoadError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, projectId, hidden]);
|
||||
|
||||
// FNXC:PlannerOversight 2026-07-04-18:00: when hidden (oversight Off / oversight
|
||||
// fields undefined), render nothing at all — no leftover empty container,
|
||||
// scroll region, or dangling aria-label, per the Surface Enumeration gate.
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="task-oversight-timeline"
|
||||
data-testid="planner-intervention-timeline"
|
||||
aria-label={t("taskDetail.oversight.interventions.ariaLabel", "Planner intervention timeline")}
|
||||
>
|
||||
<h4 className="task-oversight-timeline__heading">
|
||||
{t("taskDetail.oversight.interventions.heading", "Intervention timeline")}
|
||||
</h4>
|
||||
{isLoading ? (
|
||||
<span className="task-oversight-timeline__loading">
|
||||
<Loader2 className="spin" aria-hidden="true" />
|
||||
{t("taskDetail.oversight.interventions.loading", "Loading intervention timeline\u2026")}
|
||||
</span>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="task-oversight-timeline__empty" data-testid="planner-intervention-timeline-empty">
|
||||
{loadError
|
||||
? t("taskDetail.oversight.interventions.loadError", "Unable to load intervention timeline")
|
||||
: t("taskDetail.oversight.interventions.empty", "No planner interventions yet")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="planner-intervention-list">
|
||||
{entries.map((entry) => (
|
||||
<InterventionEntryRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -551,6 +551,163 @@ renders at a uniform height.
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 Intervention Timeline styles. Attaches to the closest existing seam
|
||||
(no literal `.task-oversight-controls` class exists in merged FN-7517 code)
|
||||
and mirrors the FN-7517 explain-panel's sizing/radius/token language.
|
||||
Design tokens only; `.status-dot--skipped` extends the shared `.status-dot`
|
||||
convention with the muted semantic token (no analogous modifier existed
|
||||
for a "skipped" outcome upstream).
|
||||
*/
|
||||
.task-oversight-timeline {
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-alt, var(--surface));
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.task-oversight-timeline__heading {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-oversight-timeline__loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-oversight-timeline__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-oversight-timeline .status-dot--skipped {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.planner-intervention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.planner-intervention-entry {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__stage {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planner-intervention-entry__outcome {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__timestamp {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__reason {
|
||||
margin: var(--space-xs) 0 0;
|
||||
}
|
||||
|
||||
.planner-intervention-entry__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__attempts {
|
||||
padding: 0 var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, var(--radius-md));
|
||||
}
|
||||
|
||||
.planner-intervention-entry__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.planner-intervention-source-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, var(--radius-md));
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planner-intervention-source-link--inert {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.planner-intervention-source-link__kind {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-oversight-timeline {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__header {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.planner-intervention-entry__timestamp {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.planner-intervention-source-link {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.planner-intervention-entry__links {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Error alert in task detail modal */
|
||||
.detail-error-alert {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrPanel } from "./PrPanel";
|
||||
import { PrCreateModal } from "./PrCreateModal";
|
||||
import { PlannerInterventionTimeline } from "./PlannerInterventionTimeline";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { TaskChatTab } from "./TaskChatTab";
|
||||
import { TaskPlannerChatTab } from "./TaskPlannerChatTab";
|
||||
@@ -3842,6 +3843,21 @@ export function TaskDetailContent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 Intervention Timeline: rendered adjacent to the FN-7517
|
||||
oversight cluster (no separate `.task-oversight-controls` class
|
||||
exists in the merged FN-7517 code, so this attaches to the
|
||||
closest existing seam — the same gating condition used by the
|
||||
nudge/stop/explain controls above). Hidden entirely (no
|
||||
leftover empty shell) when oversight is Off or unresolved, per
|
||||
the Surface Enumeration gate.
|
||||
*/}
|
||||
<PlannerInterventionTimeline
|
||||
taskId={task.id}
|
||||
projectId={projectId}
|
||||
hidden={!(hasTaskOversightOverride || workflowOversightResolved) || oversightIsOff}
|
||||
/>
|
||||
{provenanceDisplay && (
|
||||
<div className="detail-provenance">
|
||||
<GitBranch aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 Surface Enumeration coverage for the standalone PlannerInterventionTimeline
|
||||
component: empty state (no leftover shell), hidden (oversight Off / undefined
|
||||
oversight fields), single/many entries (newest-first ordering assumed to
|
||||
already be provided by the read path), partial entries (no attempt badge, no
|
||||
links row), every source-link kind, and unknown enum fallback labels.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { PlannerInterventionEntry } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPlannerInterventionTimeline: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchPlannerInterventionTimeline } from "../../api";
|
||||
import { PlannerInterventionTimeline } from "../PlannerInterventionTimeline";
|
||||
|
||||
const mockFetch = vi.mocked(fetchPlannerInterventionTimeline);
|
||||
|
||||
function makeEntry(overrides: Partial<PlannerInterventionEntry> = {}): PlannerInterventionEntry {
|
||||
return {
|
||||
id: "evt-1",
|
||||
taskId: "FN-100",
|
||||
timestamp: "2026-07-04T10:00:00.000Z",
|
||||
stage: "executor",
|
||||
reason: "Executor stalled without progress",
|
||||
action: "retry",
|
||||
outcome: "pending",
|
||||
attemptCount: 1,
|
||||
attemptLimit: 3,
|
||||
sourceLinks: [{ kind: "agent-log", label: "Agent log", target: "run-1" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PlannerInterventionTimeline", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("renders the calm empty state (not an empty shell) when there are no interventions", async () => {
|
||||
mockFetch.mockResolvedValue({ entries: [] });
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
expect(await screen.findByTestId("planner-intervention-timeline-empty")).toHaveTextContent("No planner interventions yet");
|
||||
expect(screen.queryByTestId("planner-intervention-entry")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing (no leftover container) when hidden (oversight Off / undefined oversight fields)", async () => {
|
||||
mockFetch.mockResolvedValue({ entries: [makeEntry()] });
|
||||
const { container } = render(<PlannerInterventionTimeline taskId="FN-100" hidden />);
|
||||
|
||||
// Give any stray effect a tick to (not) fire.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("planner-intervention-timeline")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never throws when interventions resolve to an empty array (covers the safe-empty contract)", async () => {
|
||||
mockFetch.mockResolvedValue({ entries: [] });
|
||||
expect(() => render(<PlannerInterventionTimeline taskId="FN-100" />)).not.toThrow();
|
||||
await waitFor(() => expect(screen.getByTestId("planner-intervention-timeline-empty")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders a single entry with all six field groups", async () => {
|
||||
mockFetch.mockResolvedValue({ entries: [makeEntry()] });
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
const entry = await screen.findByTestId("planner-intervention-entry");
|
||||
expect(entry).toHaveTextContent("Executor stalled without progress");
|
||||
expect(await screen.findByTestId("planner-intervention-entry-attempts")).toHaveTextContent("1/3");
|
||||
expect(await screen.findByTestId("planner-intervention-entry-links")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("planner-intervention-source-link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders many entries newest-first as provided by the read path", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
entries: [
|
||||
makeEntry({ id: "evt-2", reason: "Second (newest)", timestamp: "2026-07-04T11:00:00.000Z" }),
|
||||
makeEntry({ id: "evt-1", reason: "First (oldest)", timestamp: "2026-07-04T10:00:00.000Z" }),
|
||||
],
|
||||
});
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
const entries = await screen.findAllByTestId("planner-intervention-entry");
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toHaveTextContent("Second (newest)");
|
||||
expect(entries[1]).toHaveTextContent("First (oldest)");
|
||||
});
|
||||
|
||||
it("renders a partial entry without the attempt badge and without the links row", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
entries: [makeEntry({ attemptCount: undefined, attemptLimit: undefined, sourceLinks: undefined })],
|
||||
});
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
await screen.findByTestId("planner-intervention-entry");
|
||||
expect(screen.queryByTestId("planner-intervention-entry-attempts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planner-intervention-entry-links")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the attempt badge when only one of attemptCount/attemptLimit is present", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
entries: [makeEntry({ attemptCount: 2, attemptLimit: undefined })],
|
||||
});
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
await screen.findByTestId("planner-intervention-entry");
|
||||
expect(screen.queryByTestId("planner-intervention-entry-attempts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders each source-link kind with a graceful fallback when target/url is absent", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
entries: [
|
||||
makeEntry({
|
||||
id: "evt-links",
|
||||
sourceLinks: [
|
||||
{ kind: "agent-log", label: "Agent log", target: "run-1" },
|
||||
{ kind: "review-comment", label: "Review comment", target: "cmt-1" },
|
||||
{ kind: "failed-check", label: "Failed check", target: "ci-build" },
|
||||
{ kind: "merge-error", label: "Merge error" },
|
||||
{ kind: "pr-state", label: "PR state", url: "https://example.test/pr/1" },
|
||||
{ kind: "url", label: "Generic link" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
const links = await screen.findAllByTestId("planner-intervention-source-link");
|
||||
expect(links).toHaveLength(6);
|
||||
// The pr-state link has a url and renders as an anchor; the rest lack a url and degrade to inert spans.
|
||||
const prStateLink = links.find((el) => el.textContent?.includes("PR state"));
|
||||
expect(prStateLink?.tagName).toBe("A");
|
||||
const mergeErrorLink = links.find((el) => el.textContent?.includes("Merge error"));
|
||||
expect(mergeErrorLink?.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("renders a safe fallback label for an unknown stage/action/outcome value instead of crashing", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
entries: [
|
||||
makeEntry({
|
||||
stage: "some-future-stage" as unknown as PlannerInterventionEntry["stage"],
|
||||
action: "some-future-action" as unknown as PlannerInterventionEntry["action"],
|
||||
outcome: "some-future-outcome" as unknown as PlannerInterventionEntry["outcome"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(() => render(<PlannerInterventionTimeline taskId="FN-100" />)).not.toThrow();
|
||||
await screen.findByTestId("planner-intervention-entry");
|
||||
});
|
||||
|
||||
it("renders once per host (no duplicate data-testid) for a single mount", async () => {
|
||||
mockFetch.mockResolvedValue({ entries: [makeEntry()] });
|
||||
render(<PlannerInterventionTimeline taskId="FN-100" />);
|
||||
|
||||
await screen.findByTestId("planner-intervention-timeline");
|
||||
expect(screen.getAllByTestId("planner-intervention-timeline")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
parseExplicitDuplicateMarker,
|
||||
isWorkflowColumnsEnabled,
|
||||
TransitionRejectionError,
|
||||
getPlannerInterventionTimeline,
|
||||
type NearDuplicateCandidate,
|
||||
} from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
@@ -2473,6 +2474,29 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-04-18:00:
|
||||
FN-7519 read-only intervention-timeline endpoint. Reuses the existing
|
||||
run-audit store via `getPlannerInterventionTimeline` (built on top of
|
||||
`TaskStore.getRunAuditEvents`) rather than a parallel audit store; never
|
||||
mutates state and returns an empty array (not an error) when the task has
|
||||
no recorded interventions. This is a pure READ path — FN-7520 owns wiring
|
||||
`recordPlannerIntervention` calls at overseer decision points.
|
||||
*/
|
||||
router.get("/tasks/:id/overseer/interventions", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
await scopedStore.getTask(req.params.id);
|
||||
const entries = getPlannerInterventionTimeline(scopedStore, req.params.id);
|
||||
res.json({ entries });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tasks/:id/recover-branch-binding", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
Reference in New Issue
Block a user