From 45944d9f84615baae4b6322b53a29ea2b8ab6339 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 14 Sep 2026 10:53:15 +0800 Subject: [PATCH] feat(web): a dispatch timeline lens on the fleet rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5.4 (docs/conductor-frontends-design.md §4). The rail gains a second lens: what was dispatched when, and what came back. The lanes answer "what needs me now" by re-sorting on urgency, which is exactly wrong for retrospection — a list that reorders itself cannot be read as history. §4 asks for both, so the two now sit behind a switch rather than one pretending to do the other's job. Built from the two streams the board already carries, tasks and events, with no new wire data. Four rules, each from a case that would otherwise read wrong: **A dispatch and its outcome routinely share a millisecond** on a fast task. Newest-first then has to render the dispatch SECOND, or the row order implies the result preceded the request. **An unknown event type is kept, not dropped.** A newer daemon naming something differently must not silently erase rows from the one view whose purpose is history. **Events outlive their tasks.** The board is capped, so a long run ages tasks out while their events remain — and "the digest for a task I can no longer see" is the most useful thing left. Such a row keeps its task id so it still correlates by eye. **Days split on LOCAL midnight.** A UTC split files an evening dispatch under tomorrow for anyone east of the meridian. The target resolves to a session NAME where one exists, degrading to a short id prefix otherwise. That came from rendering the real board rather than fixtures: every dispatch row showed a full UUID, which is noise in a 288px 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. Verified against a captured board of 5 tasks and 5 events: day separators land correctly, each outcome sits directly above its dispatch, and the two tasks blocked by the old daemon-collision bug show their real task_blocked digests. 13 timeline tests; 279 web tests; typecheck, lint and build clean. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/FleetRail.tsx | 132 ++++++++++++++++++++++--- web/src/lib/fleet-timeline.test.ts | 132 +++++++++++++++++++++++++ web/src/lib/fleet-timeline.ts | 148 +++++++++++++++++++++++++++++ 3 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/fleet-timeline.test.ts create mode 100644 web/src/lib/fleet-timeline.ts 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; +}