From 193c967b4dfd0ee6c31754ef35b2d62c53793c6d Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 03:09:14 +0000 Subject: [PATCH 1/3] Add the Console Waterfall with scoped tool-call timing (#827) --- console/README.md | 15 +- console/tests/waterfall.test.cjs | 103 +++++++++ docs/code-navigation.md | 1 + src/console.rs | 8 + src/console/assets/console.css | 9 + src/console/nav.html | 2 +- src/console/waterfall.html | 41 ++++ src/console/waterfall.rs | 32 +++ src/logs/status.rs | 1 + src/logs/status/waterfall.rs | 314 ++++++++++++++++++++++++++ src/logs/status/waterfall/matching.rs | 187 +++++++++++++++ tests/console.rs | 142 +++++++++++- 12 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 console/tests/waterfall.test.cjs create mode 100644 src/console/waterfall.html create mode 100644 src/console/waterfall.rs create mode 100644 src/logs/status/waterfall.rs create mode 100644 src/logs/status/waterfall/matching.rs diff --git a/console/README.md b/console/README.md index 09a459ee..85507fee 100644 --- a/console/README.md +++ b/console/README.md @@ -31,7 +31,7 @@ them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. -The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), History (`5`), Logs (`6`) and Security (`7`). Links +The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), Waterfall (`4`), History (`5`), Logs (`6`) and Security (`7`). Links keep the page in `?page=`. History includes every ended Command, including old logs, and filters by outcome and repository. Security shows retained findings and Session logs. Older records without finding metadata still show their logs. @@ -50,12 +50,23 @@ Both pages use the observations from `status --all --json`. The Rust parity test checks their stage times and Usual ranges against the CLI; the Playwright time tests check scale in all windows, child selection, live ordering and 390 px. +Waterfall chooses one retained Command with live, died and ended chips, keeping +its exact selection in `command=`. Runs, stages and tool calls share one axis, +including a Spec run's Ticket Runs. It pairs with `show --json` and `logs`: +stage intervals come from the Command record, and Claude/Codex tool summaries +match their scoped `HH:MM:SS` Command-log lines in order, across midnight. +Each tool interval extends to the next matched call in its Session or its stage +boundary. These include waiting; they are not measured tool execution times. +Unmatched calls stay visible with “Time unknown” and no bar. Session links open +their retained evidence. The Waterfall browser test checks selection, scale, +unknown times, escaping and phone layout; Rust checks matching and CLI parity. + `j` / `k` select headings and visible rows, wrapping at either end. `Space` folds a heading or toggles a row's peek; `Enter` opens that row's full Command log. `z` folds the current section. `Escape` closes help, jumps or the peek, one layer at a time, then clears selection. `?` opens the key list. `g` opens the jump list: `i` Inbox, `a` Lanes, `t` Stages, `h` History, `o` Logs, `s` Security, or a section's displayed -letter. Text fields keep their normal keys. The peek starts closed and sits +letter; `f` opens Waterfall. Text fields keep their normal keys. The peek starts closed and sits beside the list on a wide screen or at the bottom of a phone. For an exact older Command, `show` and `logs` also accept the absolute record diff --git a/console/tests/waterfall.test.cjs b/console/tests/waterfall.test.cjs new file mode 100644 index 00000000..e7587c2b --- /dev/null +++ b/console/tests/waterfall.test.cjs @@ -0,0 +1,103 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); + +let home, server, exited, browser, origin; +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-waterfall-')); + const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + await mkdir(folder, { recursive: true }); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + let processStart = 'gone'; + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/); + processStart = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${fields[19]}`; + } + const record = { + version: 1, kind: 'Spec run', repository: 'acme/widgets', name: 'spec-7', hostname: 'fixture', + issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Widgets ' }, + process_id: 0, process_start: 'gone', harness: 'codex', started_at: '2026-10-01T12:00:00+00:00', + ended_at: '2026-10-01T12:01:00+00:00', outcome: 'merged', session_log: null, + stages: [{ name: 'Tickets', began_at: '2026-10-01T12:00:00+00:00', session_log: null }] + }; + const parent = join(folder, '7-fixture.command.json'); + await writeFile(parent, JSON.stringify(record)); + const session = join(folder, '99-20261001T120000+0000-implement.jsonl'); + const item = (id, command) => JSON.stringify({ type: 'item.completed', item: { id, type: 'command_execution', command, status: 'completed' } }) + '\n'; + await writeFile(session, item('a', 'first ') + item('b', 'unmatched') + item('c', 'last')); + await writeFile(join(folder, '7-fixture.issue-99.child.json'), JSON.stringify({ + ...record, kind: 'Run', name: 'ticket-99', issue: { ...record.issue, number: 99 }, parent_record: parent, + started_at: '2026-10-01T12:00:10+00:00', ended_at: '2026-10-01T12:00:40+00:00', session_log: session, + stages: [{ name: 'implement', began_at: '2026-10-01T12:00:10+00:00', session_log: session }] + })); + await writeFile(join(folder, '7-fixture.log'), 'thirdshift: 12:00:00 starting\nthirdshift: 12:00:15 #99: implement: $ first \nthirdshift: 12:00:30 #99: implement: $ last\nthirdshift: 12:01:00 finished\n'); + for (const number of [8, 9]) { + await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify({ + ...record, kind: 'Run', name: `run-${number}`, issue: { ...record.issue, number }, + process_id: number === 9 ? process.pid : 0, process_start: number === 9 ? processStart : 'gone', + outcome: null, ended_at: null + })); + } + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test('Waterfall chips, nested scale and unknown tools work at desktop and 390 px', async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const width of [1200, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto(`${origin}/?page=waterfall`); + const chips = page.getByRole('navigation', { name: 'Choose a Command' }); + assert.match(await chips.textContent(), /acme\/widgets#8 · died/); + if (process.platform === 'linux') assert.match(await chips.textContent(), /acme\/widgets#9 · live/); + await chips.getByRole('link', { name: 'acme/widgets#7 · merged' }).click(); + assert.equal(await page.locator('.waterfall-run').count(), 2); + const child = page.locator('.waterfall-run[data-depth="1"]'); + assert.equal(await child.locator('.waterfall-tool').count(), 3); + assert.match(await child.locator('.waterfall-tool').nth(1).textContent(), /Time unknown/); + const track = await child.locator(':scope > .timeline').boundingBox(); + const run = await child.locator(':scope > .timeline rect').boundingBox(); + const stageTrack = await child.locator('.waterfall-stage > .timeline').boundingBox(); + const toolTrack = await child.locator('.waterfall-tool .timeline').first().boundingBox(); + const first = await child.locator('.waterfall-tool .timeline rect').first().boundingBox(); + assert.ok(Math.abs(run.width - track.width / 2) <= 1.01); + assert.ok(Math.abs(first.width - track.width / 4) <= 1.01); + assert.ok(Math.abs((first.x - track.x) / track.width - 0.25) < 0.002); + assert.equal(track.x, stageTrack.x); + assert.equal(track.x, toolTrack.x); + assert.equal(track.width, toolTrack.width); + assert.equal(await page.locator('main script, main unsafe').count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await child.locator('.peek-link').click(); + await page.locator('#peek-content h2').waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /ticket-99/); + await page.keyboard.press('Escape'); + await page.keyboard.press('1'); + await page.waitForURL('**page=inbox'); + await page.keyboard.press('4'); + await page.waitForURL('**page=waterfall'); + } + assert.deepEqual(errors, []); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 63632144..23921980 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -23,6 +23,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | Console Lanes and Stages, stage times | `src/logs/status/time.rs` — time views from complete status observations; `src/console/time.rs`, `src/console/time.html` — page rendering | `tests/console.rs`, `console/tests/time.test.cjs` | +| Console Waterfall, tool-call timing | `src/logs/status/waterfall.rs` — retained Run/stage tree and scale; `src/logs/status/waterfall/matching.rs` — scoped Claude/Codex matching; `src/console/waterfall.rs` | `tests/console.rs`, `console/tests/waterfall.test.cjs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | diff --git a/src/console.rs b/src/console.rs index 2bcfc746..54a419e2 100644 --- a/src/console.rs +++ b/src/console.rs @@ -4,6 +4,7 @@ mod logs; mod peek; mod restart; mod time; +mod waterfall; use std::io::Write; use std::net::Ipv4Addr; @@ -30,6 +31,10 @@ struct PageRoute { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/waterfall", + cli: "thirdshift show widgets#7 --json", + }, PageRoute { path: "/lanes", cli: "thirdshift status --all --json", @@ -177,6 +182,9 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) if matches!(page.as_str(), "logs" | "6") { return logs::index().await; } + if matches!(page.as_str(), "waterfall" | "4") { + return waterfall::show(query).await; + } if matches!(page.as_str(), "lanes" | "2" | "stages" | "3") { return time::show(matches!(page.as_str(), "stages" | "3"), query).await; } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9ecccb29..b318b17b 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -90,6 +90,15 @@ footer { margin-top: 16px; } .timeline rect { fill: var(--panel); stroke: var(--working); } .timeline text { fill: var(--text); font: 12px var(--font-mono); } .stage-times { padding-left: 24px; } +.command-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; } +.command-chips a { border: 1px solid var(--rule); border-radius: var(--radius); padding: 6px; overflow-wrap: anywhere; } +.command-chips [aria-current] { outline: 2px solid var(--head); } +.waterfall-run { border-left: 2px solid var(--rule); padding-left: 8px; margin: 16px 0; overflow-wrap: anywhere; } +.waterfall-run[data-depth="1"] > .command { padding-left: 12px; } +.waterfall-run[data-depth="2"] > .command { padding-left: 24px; } +.waterfall-stages, .waterfall-stages ol { list-style: none; padding-left: 0; } +.waterfall-stage > h4, .waterfall-stage > p { margin-left: 12px; } +.waterfall-tool > a, .waterfall-tool > p { margin-left: 24px; } .stage-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 20px 0; } .stage-grid caption { text-align: left; font-weight: var(--head-weight); } .stage-grid th, .stage-grid td { border: 1px solid var(--rule); padding: 8px; text-align: left; vertical-align: top; overflow-wrap: anywhere; } diff --git a/src/console/nav.html b/src/console/nav.html index ab2ebd48..f70f4f36 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/waterfall.html b/src/console/waterfall.html new file mode 100644 index 00000000..38e4e83d --- /dev/null +++ b/src/console/waterfall.html @@ -0,0 +1,41 @@ + + + + + + Waterfall · {{ hostname }} · thirdshift + + + + + +

thirdshift / Console

{{ hostname }}

+ {% if !consoles.is_empty() %}{% endif %} +
+ {% include "console/nav.html" %} +
+

Waterfall

thirdshift show <command> --json · thirdshift logs <command>
+ + {% if view.runs.is_empty() %}

No Commands yet.

{% else %} +

Shared time axis: {{ view.start }} to {{ view.end }}. Tool intervals run until the next matched call in that Session or the stage boundary; they include waiting and are not measured execution times. Unknown times have no bar.

+ {% for run in view.runs %} +
+

{{ run.kind }} · {{ run.subject }} · {{ run.state }}

{{ run.title }}

+ +

{{ run.span.start }} · {{ run.span.text }}

+ {% if run.stages.is_empty() %}

No stage times retained.

{% endif %} +
    {% for stage in run.stages %} +
  1. {{ stage.name }}

    {{ stage.span.start }} · {{ stage.span.text }}

    + +
      {% for tool in stage.tools %}
    1. {{ tool.label }}

      {{ tool.span.start }} · {{ tool.span.text }}

      + +
    2. {% endfor %}
    +
  2. + {% endfor %}
+
+ {% endfor %} + {% endif %} +
+ {% include "console/chrome.html" %} + + diff --git a/src/console/waterfall.rs b/src/console/waterfall.rs new file mode 100644 index 00000000..ef9f54b4 --- /dev/null +++ b/src/console/waterfall.rs @@ -0,0 +1,32 @@ +//! One selected Command, using the same retained tree and logs as show/logs. +use anyhow::Result; +use askama::Template; +use axum::response::Response; + +use crate::config::UserConfig; +use crate::logs::status::waterfall::{self, View}; + +#[derive(Template)] +#[template(path = "console/waterfall.html")] +struct WaterfallPage { + hostname: String, + consoles: Vec, + theme: String, + view: View, +} + +pub(super) async fn show(query: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(WaterfallPage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + view: waterfall::load(&config, &super::query_value(&query, "command"))?, + } + .render()?) + }) + .await, + ) +} diff --git a/src/logs/status.rs b/src/logs/status.rs index 41a1704c..a7eed137 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -3,6 +3,7 @@ mod inbox; pub mod inspect; pub(crate) mod time; +pub(crate) mod waterfall; pub(crate) use inbox::{Archive, ArchivePage, archive}; pub(crate) use inbox::{Inbox, Row, inbox}; diff --git a/src/logs/status/waterfall.rs b/src/logs/status/waterfall.rs new file mode 100644 index 00000000..ac3a0fb4 --- /dev/null +++ b/src/logs/status/waterfall.rs @@ -0,0 +1,314 @@ +//! A Command tree on one time axis, derived from show facts and retained logs. +mod matching; + +use anyhow::Result; +use chrono::{DateTime, FixedOffset, Local}; + +use crate::config::UserConfig; +use crate::logs::reading::{Command, Entry, Observation, Snapshot}; + +type Time = DateTime; + +pub(crate) struct View { + pub choices: Vec, + pub runs: Vec, + pub start: String, + pub end: String, +} + +pub(crate) struct Choice { + pub subject: String, + pub state: String, + pub url: String, + pub selected: bool, +} + +pub(crate) struct Run { + pub subject: String, + pub kind: String, + pub title: String, + pub state: String, + pub selector: String, + pub peek_url: String, + pub log_url: String, + pub depth: usize, + pub span: Span, + pub stages: Vec, +} + +pub(crate) struct Stage { + pub name: String, + pub span: Span, + pub tools: Vec, +} + +pub(crate) struct Tool { + pub label: String, + pub span: Span, + pub url: String, +} + +pub(crate) struct Span { + pub start: String, + pub seconds: String, + pub text: String, + pub left: f64, + pub width: f64, +} + +struct Axis { + start: Time, + end: Time, +} + +impl Axis { + fn span(&self, start: Option