diff --git a/web/src/components/FleetRail.tsx b/web/src/components/FleetRail.tsx index 54b2c42b..3c044412 100644 --- a/web/src/components/FleetRail.tsx +++ b/web/src/components/FleetRail.tsx @@ -15,6 +15,7 @@ import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } fr import { formatCostUsd, formatTokens } from "../lib/format"; import { costShare, fleetEconomics, type FleetEconomics } from "../lib/fleet-economics"; +import { buildTimeline, groupByDay, type TimelineDay, type TimelineEntry, type TimelineKind } from "../lib/fleet-timeline"; import { groupIntoLanes, needsYouCount, @@ -83,6 +84,15 @@ const FleetRail: Component = () => { // ABOVE a single session). Collapsed by default — it answers a question you // ask occasionally, and the lanes answer the one you ask constantly. const [showSpend, setShowSpend] = createSignal(false); + + // Which lens is showing. Lanes answer "what needs me now"; the timeline + // answers "what happened" (§4). They are different questions, and the second + // is badly served by a list that re-sorts itself by urgency. + const [lens, setLens] = createSignal<"lanes" | "timeline">("lanes"); + const timeline = createMemo(() => { + const b = board(); + return groupByDay(buildTimeline(b.tasks, b.events, (t) => taskSession(b, t))); + }); const econ = createMemo(() => { const b = board(); return fleetEconomics(b.conductor ? [b.conductor, ...b.workers] : b.workers); @@ -125,6 +135,10 @@ const FleetRail: Component = () => { +
+ + +
@@ -135,22 +149,118 @@ const FleetRail: Component = () => { )} - 0} - fallback={ -

- {board().fetchedAt === 0 - ? "Connecting to the board…" - : "Nothing dispatched yet. Ask the conductor to send or spawn work."} -

- } - > - {(lane) => } + }> + 0} + fallback={ +

+ {board().fetchedAt === 0 + ? "Connecting to the board…" + : "Nothing dispatched yet. Ask the conductor to send or spawn work."} +

+ } + > + {(lane) => } +
); }; +const LensButton: Component<{ + lens: "lanes" | "timeline"; + label: string; + active: "lanes" | "timeline"; + title: string; + onPick: (l: "lanes" | "timeline") => void; +}> = (props) => ( + +); + +/** + * Colour by outcome, reusing the lane vocabulary so a `blocked` row reads the + * same in both lenses. A `dispatched` row is intentionally quiet: it is the + * question, not the answer, and colouring every request would leave nothing + * for the outcomes to stand out against. + */ +const TIMELINE_STYLE: Record = { + dispatched: "border-border bg-bg text-fg-muted", + done: "border-success/40 bg-success/10 text-success", + blocked: "border-danger/60 bg-danger/15 text-danger", + failed: "border-danger/40 bg-danger/10 text-danger", + event: "border-border bg-bg text-fg-faint", +}; + +/** What was dispatched when, and what came back (§4's retrospection lens). */ +const TimelineView: Component<{ days: TimelineDay[] }> = (props) => ( + 0} + fallback={ +

+ Nothing has been dispatched yet, so there is no history to replay. +

+ } + > + + {(day) => ( +
+

+ {new Date(day.day).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + })} +

+
    + {(e) => } +
+
+ )} +
+
+); + +const TimelineRow: Component<{ entry: TimelineEntry }> = (props) => ( +
  • +
    + + {new Date(props.entry.at).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + + + {props.entry.kind} + + {props.entry.label} + {/* The task id is how a row is correlated by eye with fleet_tasks and + with an event whose task has aged off the board. */} + + {props.entry.taskId.slice(0, 8)} + +
    + + {(d) => ( +

    {d()}

    + )} +
    +
  • +); + /** * Per-backend spend — the metaharness view no single-vendor tool needs (§7). * diff --git a/web/src/lib/fleet-timeline.test.ts b/web/src/lib/fleet-timeline.test.ts new file mode 100644 index 00000000..3d1fed86 --- /dev/null +++ b/web/src/lib/fleet-timeline.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; + +import { buildTimeline, groupByDay } from "./fleet-timeline"; +import type { FleetEventWire, FleetTaskWire, SessionInfo } from "../protocol/types"; + +const T = 1_700_000_000_000; + +const task = (id: string, over: Partial = {}): FleetTaskWire => ({ + id, + kind: "spawn", + shape: "scout", + status: "done", + attempts: 0, + createdAt: T, + createdBy: "conductor:acct/proj", + ...over, +}); + +const event = (id: number, over: Partial = {}): FleetEventWire => ({ + id, + taskId: "t1", + type: "task_done", + digest: "finished", + createdAt: T, + ...over, +}); + +describe("buildTimeline", () => { + it("interleaves dispatches and outcomes, newest first", () => { + const out = buildTimeline( + [task("t1", { createdAt: T }), task("t2", { createdAt: T + 2_000 })], + [event(1, { taskId: "t1", createdAt: T + 1_000 })], + ); + expect(out.map((e) => e.key)).toEqual(["dispatch:t2", "event:1", "dispatch:t1"]); + }); + + it("keeps a dispatch reading as FIRST when it shares a millisecond with its outcome", () => { + // A fast task settles in the same tick it was dispatched. Newest-first + // means the dispatch must render SECOND, or the row order implies the + // result preceded the request. + const out = buildTimeline([task("t1")], [event(1, { taskId: "t1", createdAt: T })]); + expect(out.map((e) => e.kind)).toEqual(["done", "dispatched"]); + }); + + it("maps event types onto the timeline vocabulary", () => { + const out = buildTimeline( + [], + [ + event(1, { type: "task_done" }), + event(2, { type: "task_blocked" }), + event(3, { type: "task_failed" }), + ], + ); + expect(out.map((e) => e.kind).sort()).toEqual(["blocked", "done", "failed"]); + }); + + it("keeps an event type it has never heard of rather than dropping history", () => { + // A newer daemon naming something differently must not silently erase rows + // from a retrospective view. + const out = buildTimeline([], [event(1, { type: "task_reassigned" })]); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("event"); + expect(out[0]!.label).toBe("task_reassigned"); + }); + + it("keeps an event whose task has aged off the capped board", () => { + // The board is bounded; a long fleet run ages tasks out while their events + // remain, and the digest is the most useful thing left. + const out = buildTimeline([], [event(1, { taskId: "long-gone", digest: "the answer" })]); + expect(out[0]!.taskId).toBe("long-gone"); + expect(out[0]!.detail).toBe("the answer"); + }); + + it("labels a dispatch with its target so rows are distinguishable", () => { + const named = { id: "w9", name: "worker-scout-abc" } as SessionInfo; + const spawn = buildTimeline([task("t1", { workerSessionId: "w9" })], [], () => named); + expect(spawn[0]!.label).toBe("spawn scout"); + // The NAME, not the id — a raw UUID is noise in a narrow rail. + expect(spawn[0]!.detail).toBe("worker-scout-abc"); + }); + + it("degrades an unresolvable target to a short id, not a full UUID", () => { + // A finished task has no session at all: the dispatcher tears its worker + // down after the digest. The id prefix still correlates by eye. + const out = buildTimeline( + [task("t1", { workerSessionId: "7bdbd557-656a-4b0c-bab1-6a7894ab3efe" })], + [], + ); + expect(out[0]!.detail).toBe("7bdbd557"); + }); + + it("leaves the detail null when a dispatch has no target yet", () => { + // A queued spawn has neither a worker nor a target; inventing one would be + // worse than an empty cell. + expect(buildTimeline([task("t1")], [])[0]!.detail).toBeNull(); + }); + + it("orders equal rows totally and stably", () => { + const a = buildTimeline([task("aaa"), task("bbb")], []); + const b = buildTimeline([task("bbb"), task("aaa")], []); + expect(a.map((e) => e.key)).toEqual(b.map((e) => e.key)); + }); + + it("returns nothing for an empty board", () => { + expect(buildTimeline([], [])).toEqual([]); + }); +}); + +describe("groupByDay", () => { + it("splits on LOCAL midnight, not UTC", () => { + // A UTC split puts an evening dispatch on "tomorrow" for anyone east of the + // meridian — the user is reading their own day boundaries. + const late = new Date(2026, 4, 10, 23, 30).getTime(); + const next = new Date(2026, 4, 11, 0, 30).getTime(); + const days = groupByDay(buildTimeline([task("a", { createdAt: next }), task("b", { createdAt: late })], [])); + expect(days).toHaveLength(2); + expect(days[0]!.day).toBe(new Date(2026, 4, 11).getTime()); + expect(days[1]!.day).toBe(new Date(2026, 4, 10).getTime()); + }); + + it("keeps same-day entries together in order", () => { + const days = groupByDay( + buildTimeline([task("a", { createdAt: T }), task("b", { createdAt: T + 1_000 })], []), + ); + expect(days).toHaveLength(1); + expect(days[0]!.entries.map((e) => e.key)).toEqual(["dispatch:b", "dispatch:a"]); + }); + + it("returns nothing for an empty timeline", () => { + expect(groupByDay([])).toEqual([]); + }); +}); diff --git a/web/src/lib/fleet-timeline.ts b/web/src/lib/fleet-timeline.ts new file mode 100644 index 00000000..aff87dc2 --- /dev/null +++ b/web/src/lib/fleet-timeline.ts @@ -0,0 +1,148 @@ +/** + * The dispatch timeline — what was dispatched when, and what came back + * (conductor-frontends-design §4). + * + * §4 lists this as a secondary lens over the same nodes the state-grouped list + * shows: "a dispatch timeline for retrospection: what was dispatched when, what + * it returned, where it blocked". The lanes answer *what needs me now*; this + * answers *what happened*, which is a different question and badly served by a + * list that re-sorts itself by urgency. + * + * Built from the two streams the board already carries — tasks and events — + * with no new wire data. + * + * Pure functions: interleaving two streams and deciding what survives is the + * decision worth testing, and it needs no reactive root. + */ + +import type { FleetEventWire, FleetTaskWire, SessionInfo } from "../protocol/types"; + +export type TimelineKind = "dispatched" | "done" | "blocked" | "failed" | "event"; + +export interface TimelineEntry { + /** Stable across rebuilds, so a keyed list does not thrash. */ + key: string; + at: number; + taskId: string; + kind: TimelineKind; + /** Short subject — "spawn scout", "task_done". */ + label: string; + detail: string | null; +} + +/** + * Readable target for a dispatch row: the session name when it still exists, + * else a short id, else nothing. + */ +function targetLabel(task: FleetTaskWire, session: SessionInfo | null): string | null { + if (session) return session.name; + const id = task.targetSession ?? task.workerSessionId; + return id ? id.slice(0, 8) : null; +} + +/** Map a dispatch event type onto the timeline's vocabulary. */ +function kindOfEvent(type: string): TimelineKind { + switch (type) { + case "task_done": + return "done"; + case "task_blocked": + return "blocked"; + case "task_failed": + return "failed"; + default: + // An event type this client has not heard of still belongs on the + // timeline — dropping history because a newer daemon named something + // differently is the one thing a retrospective view must not do. + return "event"; + } +} + +/** + * Interleave dispatches and outcomes into one newest-first stream. + * + * Newest-first matches every other list on this rail (the board sends tasks and + * events that way, and the lanes read that way), so switching lenses does not + * invert the reading order under the user. + * + * Events are kept even when their task is no longer on the board. The board is + * capped, so a long-running fleet ages tasks out while their events remain — + * and "the digest for a task I can no longer see" is still the most useful + * thing on a retrospective timeline. Such an entry keeps its task id so it can + * still be correlated by eye. + */ +export function buildTimeline( + tasks: readonly FleetTaskWire[], + events: readonly FleetEventWire[], + sessionFor: (task: FleetTaskWire) => SessionInfo | null = () => null, +): TimelineEntry[] { + const entries: TimelineEntry[] = []; + + for (const t of tasks) { + entries.push({ + key: `dispatch:${t.id}`, + at: t.createdAt, + taskId: t.id, + kind: "dispatched", + label: `${t.kind} ${t.shape}`, + // The target is what makes a dispatch row meaningful in retrospect; + // without it every row reads "spawn scout". + // + // Prefer the session NAME. The raw id is a full UUID, which is noise in a + // narrow rail and tells the reader nothing — and a finished task has no + // session at all, since the dispatcher tears its worker down after the + // digest. So an unresolvable id degrades to a short prefix that can still + // be correlated by eye, rather than eating the row. + detail: targetLabel(t, sessionFor(t)), + }); + } + + for (const e of events) { + entries.push({ + key: `event:${e.id}`, + at: e.createdAt, + taskId: e.taskId, + kind: kindOfEvent(e.type), + label: e.type, + detail: e.digest, + }); + } + + return entries.sort((a, b) => { + if (a.at !== b.at) return b.at - a.at; + // A dispatch and its outcome routinely share a millisecond on a fast task. + // The dispatch must still read as having come FIRST, which newest-first + // means rendering it second. + const rank = (k: TimelineKind) => (k === "dispatched" ? 1 : 0); + const d = rank(a.kind) - rank(b.kind); + if (d !== 0) return d; + // Total and stable, so equal rows do not swap between renders. + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; + }); +} + +/** + * Group consecutive entries by calendar day, for date separators. + * + * Retrospection is the point of this lens, and a flat list of times with no + * day boundaries is unreadable past the first screen. + */ +export interface TimelineDay { + /** Local midnight for the day, as an epoch ms — the key and the sort anchor. */ + day: number; + entries: TimelineEntry[]; +} + +export function groupByDay(entries: readonly TimelineEntry[]): TimelineDay[] { + const days: TimelineDay[] = []; + for (const entry of entries) { + const d = new Date(entry.at); + // LOCAL midnight, not UTC: the user is reading their own day boundaries, + // and a UTC split puts an evening dispatch on "tomorrow" for anyone east + // of the meridian. + const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const last = days[days.length - 1]; + if (last && last.day === day) last.entries.push(entry); + else days.push({ day, entries: [entry] }); + } + return days; +}