diff --git a/console/README.md b/console/README.md
index 09a459ee..fe1b06b7 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,27 @@ 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.
+The retained Spec-review checks are `python3 console/reproductions/waterfall-fractional.py`,
+`python3 console/reproductions/waterfall-stage.py` and
+`python3 console/reproductions/waterfall-parity.py` from the repo root after a build.
+The fractional Run/stage boundaries also run in the Rust integration tests.
+
`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/reproductions/waterfall-fractional.py b/console/reproductions/waterfall-fractional.py
new file mode 100644
index 00000000..c0fbab23
--- /dev/null
+++ b/console/reproductions/waterfall-fractional.py
@@ -0,0 +1,28 @@
+import json, os, pathlib, re, subprocess, tempfile, urllib.request
+
+binary = pathlib.Path(__file__).resolve().parents[2] / 'target/debug/thirdshift'
+with tempfile.TemporaryDirectory(prefix='827-spec-') as temp:
+ home = pathlib.Path(temp)
+ folder = home / '.thirdshift/logs/acme/widgets/commands/issue'
+ folder.mkdir(parents=True)
+ (home / '.thirdshift/config.toml').write_text('[console]\nport = 0\n')
+ session = folder / '7-20261001T120000+0000-implement.jsonl'
+ session.write_text(json.dumps({'type':'item.completed','item':{'id':'a','type':'command_execution','command':'rg widgets','status':'completed'}})+'\n')
+ record = dict(version=1,kind='Run',repository='acme/widgets',name='run-7',
+ issue=dict(number=7,url='https://github.com/acme/widgets/issues/7',title='Widgets'),
+ hostname='fixture',process_id=0,process_start='gone',harness='codex',
+ started_at='2026-10-01T12:00:00.100+00:00',ended_at='2026-10-01T12:01:00+00:00',
+ stages=[dict(name='implement',began_at='2026-10-01T12:00:00.100+00:00',session_log=str(session))],
+ session_log=str(session),outcome='merged')
+ (folder / '7-fixture.command.json').write_text(json.dumps(record))
+ (folder / '7-fixture.log').write_text('thirdshift: 12:00:00 starting\nthirdshift: 12:00:00 implement: $ rg widgets\nthirdshift: 12:01:00 finished\n')
+ server = subprocess.Popen([str(binary),'console','--no-sign-in'],env={**os.environ,'HOME':str(home)},stdout=subprocess.PIPE,text=True)
+ try:
+ origin = server.stdout.readline().strip()
+ html = urllib.request.urlopen(origin+'/waterfall?command=widgets%237').read().decode()
+ tools = re.findall(r'
.*?)\n\s*\}', source, re.S)
+assert declaration, 'Waterfall route must be registered in the shared parity table'
+fields = declaration.group('fields')
+print('Waterfall parity fields:', fields.strip(), flush=True)
+assert re.search(r'thirdshift logs\b', fields), 'Waterfall parity declaration must include its logs CLI dependency alongside show --json'
diff --git a/console/reproductions/waterfall-stage.py b/console/reproductions/waterfall-stage.py
new file mode 100644
index 00000000..10b22abd
--- /dev/null
+++ b/console/reproductions/waterfall-stage.py
@@ -0,0 +1,29 @@
+import json, os, pathlib, re, subprocess, tempfile, urllib.request
+
+binary = pathlib.Path(__file__).resolve().parents[2] / 'target/debug/thirdshift'
+with tempfile.TemporaryDirectory(prefix='827-spec-') as temp:
+ home = pathlib.Path(temp)
+ folder = home / '.thirdshift/logs/acme/widgets/commands/issue'
+ folder.mkdir(parents=True)
+ (home / '.thirdshift/config.toml').write_text('[console]\nport = 0\n')
+ session = folder / '7-20261001T120000+0000-implement.jsonl'
+ session.write_text(json.dumps({'type':'item.completed','item':{'id':'a','type':'command_execution','command':'rg widgets','status':'completed'}})+'\n')
+ record = dict(version=1,kind='Run',repository='acme/widgets',name='run-7',
+ issue=dict(number=7,url='https://github.com/acme/widgets/issues/7',title='Widgets'),
+ hostname='fixture',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',
+ stages=[dict(name='Setup',began_at='2026-10-01T12:00:00+00:00',session_log=None),dict(name='implement',began_at='2026-10-01T12:00:10.100+00:00',session_log=str(session))],
+ session_log=str(session),outcome='merged')
+ (folder / '7-fixture.command.json').write_text(json.dumps(record))
+ (folder / '7-fixture.log').write_text('thirdshift: 12:00:00 starting\nthirdshift: 12:00:10 implement: $ rg widgets\nthirdshift: 12:01:00 finished\n')
+ server = subprocess.Popen([str(binary),'console','--no-sign-in'],env={**os.environ,'HOME':str(home)},stdout=subprocess.PIPE,text=True)
+ try:
+ origin = server.stdout.readline().strip()
+ html = urllib.request.urlopen(origin+'/waterfall?command=widgets%237').read().decode()
+ stages = html.split(' ')[1:]
+ assigned = [re.search(r'([^<]*) ',stage).group(1) for stage in stages if 'class="waterfall-tool"' in stage]
+ print('Stage containing implement tool:',assigned,flush=True)
+ assert assigned == ['implement'], 'Session tool must nest under implement; whole-second clock incorrectly puts it in previous Setup stage'
+ finally:
+ server.terminate()
+ server.wait(timeout=10)
diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs
index f4fb6fa2..eb619c1a 100644
--- a/console/tests/inbox.test.cjs
+++ b/console/tests/inbox.test.cjs
@@ -200,7 +200,7 @@ test("every keyboard action preserves list nodes and Escape closes one layer at
test("page keys, jump letters and URLs survive reload and the peek is a phone bottom sheet", async t => {
const page = await pageFor(t, { viewport: { width: 390, height: 844 } });
await page.goto(origin);
- for (const [key, name] of [['2', 'lanes'], ['3', 'stages'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) {
+ for (const [key, name] of [['2', 'lanes'], ['3', 'stages'], ['4', 'waterfall'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) {
await page.keyboard.press(key);
await page.waitForURL(`**/?page=${name}`);
await page.reload();
@@ -208,12 +208,7 @@ test("page keys, jump letters and URLs survive reload and the peek is a phone bo
assert.equal(await page.locator('#peek').isVisible(), false);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
}
- for (const key of ['4']) {
- await page.keyboard.press(key);
- assert.equal(new URL(page.url()).searchParams.get('page'), 'inbox');
- assert.equal(await page.locator(`[data-key="${key}"]`).count(), 0);
- }
- for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) {
+ for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['f', 'waterfall'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) {
await page.keyboard.press('g');
await page.keyboard.press(letter);
await page.waitForURL(`**/?page=${name}`);
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..66acc8a5 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;
@@ -26,53 +27,60 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets");
// Static resources carry no view or action and have their own fallback route.
struct PageRoute {
path: &'static str,
- cli: &'static str,
+ cli: &'static [&'static str],
}
const PAGES: &[PageRoute] = &[
+ PageRoute {
+ path: "/waterfall",
+ cli: &[
+ "thirdshift show widgets#7 --json",
+ "thirdshift logs widgets#7",
+ ],
+ },
PageRoute {
path: "/lanes",
- cli: "thirdshift status --all --json",
+ cli: &["thirdshift status --all --json"],
},
PageRoute {
path: "/stages",
- cli: "thirdshift status --all --json",
+ cli: &["thirdshift status --all --json"],
},
PageRoute {
path: "/",
- cli: "thirdshift status",
+ cli: &["thirdshift status"],
},
PageRoute {
path: "/history",
- cli: "thirdshift status --all --json",
+ cli: &["thirdshift status --all --json"],
},
PageRoute {
path: "/security",
- cli: "thirdshift status --all --json",
+ cli: &["thirdshift status --all --json"],
},
PageRoute {
path: "/peek",
- cli: "thirdshift show widgets#7 --json",
+ cli: &["thirdshift show widgets#7 --json"],
},
PageRoute {
path: "/log",
- cli: "thirdshift logs widgets#7",
+ cli: &["thirdshift logs widgets#7"],
},
PageRoute {
path: "/logs",
- cli: "thirdshift logs --json",
+ cli: &["thirdshift logs --json"],
},
PageRoute {
path: "/logs/command/{id}",
- cli: "thirdshift logs --command id --json",
+ cli: &["thirdshift logs --command id --json"],
},
PageRoute {
path: "/logs/{id}",
- cli: "thirdshift logs --file id",
+ cli: &["thirdshift logs --file id"],
},
PageRoute {
path: "/logs/{id}/stream/{offset}",
- cli: "thirdshift logs --file id --follow",
+ cli: &["thirdshift logs --file id --follow"],
},
];
@@ -110,7 +118,7 @@ pub fn serve(no_sign_in: bool) -> Result<()> {
fn router() -> Router {
let mut router = Router::new();
for page in PAGES {
- let cli = page.cli;
+ let cli = page.cli[0];
router = match page.path {
"/peek" => router.route(page.path, get(inspect)),
"/log" => router.route(page.path, get(log)),
@@ -177,6 +185,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;
}
@@ -350,22 +361,23 @@ mod tests {
);
for route in super::PAGES {
assert!(
- !route.cli.trim().is_empty(),
+ !route.cli.is_empty(),
"{} has no paired CLI command",
route.path
);
- assert!(route.cli.starts_with("thirdshift "), "{}", route.cli);
- let arguments = route
- .cli
- .split_whitespace()
- .skip(1)
- .map(str::to_owned)
- .collect::>();
- assert!(
- crate::args::parse(&arguments).is_ok(),
- "{} is not a CLI command",
- route.cli
- );
+ for cli in route.cli {
+ assert!(cli.starts_with("thirdshift "), "{cli}");
+ let arguments = cli
+ .split_whitespace()
+ .skip(1)
+ .map(str::to_owned)
+ .collect::>();
+ assert!(
+ crate::args::parse(&arguments).is_ok(),
+ "{} is not a CLI command",
+ cli
+ );
+ }
}
}
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 @@
-1 Inbox 2 Lanes 3 Stages 5 History 6 Logs 7 Security
+1 Inbox 2 Lanes 3 Stages 4 Waterfall 5 History 6 Logs 7 Security
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() %}{% for address in consoles %}{{ address }} {% endfor %} {% endif %}
+
+ {% include "console/nav.html" %}
+
+
Waterfall thirdshift show <command> --json · thirdshift logs <command>
+ {% for choice in view.choices %}{{ choice.subject }} · {{ choice.state }} {% endfor %}
+ {% 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.span.start }} · {{ run.span.text }}
+ {% if run.stages.is_empty() %}No stage times retained.
{% endif %}
+ {% for stage in run.stages %}
+ {{ stage.name }} {{ stage.span.start }} · {{ stage.span.text }}
+
+ {% for tool in stage.tools %}{{ tool.label }} {{ tool.span.start }} · {{ tool.span.text }}
+
+ {% endfor %}
+
+ {% 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..6ea52d04
--- /dev/null
+++ b/src/logs/status/waterfall.rs
@@ -0,0 +1,334 @@
+//! 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, end: Option) -> Span {
+ let seconds = start.zip(end).and_then(|(start, end)| {
+ let millis = (end - start).num_milliseconds();
+ (millis >= 0).then_some(millis as f64 / 1000.0)
+ });
+ let scale = 100.0 / (self.end - self.start).num_milliseconds().max(1) as f64;
+ Span {
+ start: start.map(|time| time.to_rfc3339()).unwrap_or_default(),
+ seconds: seconds.map(|n| n.to_string()).unwrap_or_default(),
+ text: seconds
+ .map(|n| crate::progress::duration(std::time::Duration::from_secs_f64(n)))
+ .unwrap_or_else(|| "Time unknown".into()),
+ left: start
+ .map(|time| (time - self.start).num_milliseconds() as f64 * scale)
+ .unwrap_or(0.0)
+ .clamp(0.0, 100.0),
+ width: start
+ .zip(end)
+ .filter(|_| seconds.is_some())
+ .map(|(start, end)| {
+ (end.min(self.end) - start.max(self.start))
+ .num_milliseconds()
+ .max(0) as f64
+ * scale
+ })
+ .unwrap_or(0.0)
+ .clamp(0.0, 100.0),
+ }
+ }
+}
+
+fn time(value: &str) -> Option {
+ DateTime::parse_from_rfc3339(value).ok()
+}
+
+fn subject(command: &Command) -> String {
+ command
+ .record
+ .issue
+ .as_ref()
+ .map(|issue| format!("{}#{}", command.record.repository, issue.number))
+ .unwrap_or_else(|| command.record.name.clone())
+}
+
+fn state(command: &Command) -> String {
+ command
+ .record
+ .outcome
+ .clone()
+ .unwrap_or_else(|| command.state.into())
+}
+
+fn relay_path(command: &Command, selected: &Command, path: &mut Vec) -> bool {
+ if std::ptr::eq(command, selected) {
+ return true;
+ }
+ for dispatch in &command.dispatched {
+ if relay_path(dispatch, selected, path) {
+ return true;
+ }
+ }
+ for child in &command.children {
+ let length = path.len();
+ if let Some(issue) = &child.record.issue {
+ path.push(issue.number);
+ }
+ if relay_path(child, selected, path) {
+ return true;
+ }
+ path.truncate(length);
+ }
+ false
+}
+
+fn collect(
+ command: &Command,
+ axis: &Axis,
+ now: Time,
+ depth: usize,
+ relays: &[u64],
+ log: &mut matching::Log,
+ runs: &mut Vec,
+) -> Result<()> {
+ let selector = Observation::Record(command).selector().unwrap_or_default();
+ let record = &command.record;
+ let end = record
+ .ended_at
+ .as_deref()
+ .and_then(time)
+ .or_else(|| (command.state == "live").then_some(now));
+ let intervals: Vec<_> = record
+ .stage_times((command.state == "live").then_some(now))
+ .collect();
+ let mut stages: Vec<_> = intervals
+ .iter()
+ .map(|timing| Stage {
+ name: timing.stage.name.clone(),
+ span: axis.span(timing.began, timing.ended),
+ tools: Vec::new(),
+ })
+ .collect();
+ for session in Observation::Record(command).sessions()? {
+ let calls = matching::calls(&session, command, relays, log)?;
+ for (index, call) in calls.iter().enumerate() {
+ let stage = call
+ .start
+ .and_then(|start| {
+ // A log timestamp denotes its whole second. Prefer an
+ // explicit Session reference among compatible intervals,
+ // especially when a transition retains fractional time.
+ intervals.iter().position(|timing| {
+ timing.stage.session_log.as_ref() == Some(&session)
+ && timing
+ .began
+ .is_some_and(|began| start.timestamp() >= began.timestamp())
+ && timing
+ .ended
+ .is_none_or(|end| start.timestamp() <= end.timestamp())
+ })
+ })
+ .or_else(|| {
+ call.start.and_then(|start| {
+ intervals.iter().position(|timing| {
+ timing
+ .began
+ .is_some_and(|began| start.timestamp() >= began.timestamp())
+ && timing
+ .ended
+ .is_none_or(|end| start.timestamp() < end.timestamp())
+ })
+ })
+ })
+ .or_else(|| {
+ record
+ .stages
+ .iter()
+ .position(|stage| stage.session_log.as_ref() == Some(&session))
+ });
+ let stage_index = stage.unwrap_or_else(|| {
+ if stages.len() == intervals.len() {
+ stages.push(Stage {
+ name: "Session log · stage unknown".into(),
+ span: axis.span(None, None),
+ tools: Vec::new(),
+ });
+ }
+ intervals.len()
+ });
+ let boundary = stage.and_then(|stage| intervals[stage].ended);
+ let next = calls[index + 1..].iter().find_map(|call| call.start);
+ let end = match (next, boundary) {
+ (Some(next), Some(boundary)) => Some(next.min(boundary)),
+ (next, boundary) => next.or(boundary),
+ };
+ stages[stage_index].tools.push(Tool {
+ label: call.label.clone(),
+ span: axis.span(call.start, end),
+ url: super::inspect::url("/log", &selector, Some(&session)),
+ });
+ }
+ }
+ runs.push(Run {
+ subject: subject(command),
+ kind: record.kind.clone(),
+ title: record
+ .issue
+ .as_ref()
+ .and_then(|issue| issue.title.clone())
+ .unwrap_or_default(),
+ state: state(command),
+ peek_url: super::inspect::url("/peek", &selector, None),
+ log_url: super::inspect::url("/log", &selector, None),
+ selector,
+ depth,
+ span: axis.span(time(&record.started_at), end),
+ stages,
+ });
+ for dispatch in &command.dispatched {
+ collect(dispatch, axis, now, depth + 1, relays, log, runs)?;
+ }
+ for child in &command.children {
+ let mut relays = relays.to_vec();
+ if let Some(issue) = &child.record.issue {
+ relays.push(issue.number);
+ }
+ collect(child, axis, now, depth + 1, &relays, log, runs)?;
+ }
+ Ok(())
+}
+
+pub(crate) fn load(config: &UserConfig, input: &str) -> Result {
+ let now = Local::now();
+ let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?;
+ let mut commands: Vec<_> = snapshot
+ .entries()
+ .iter()
+ .filter_map(|entry| match entry {
+ Entry::Record(command) => Some(command.as_ref()),
+ _ => None,
+ })
+ .collect();
+ commands.sort_by_key(|command| {
+ (
+ command.state != "live",
+ std::cmp::Reverse(time(&command.record.started_at)),
+ )
+ });
+ let selected = if input.is_empty() {
+ commands.first().copied()
+ } else {
+ match snapshot.resolve(input)?.observation {
+ Observation::Record(command) => Some(command),
+ _ => None,
+ }
+ };
+ let selected_id = selected.and_then(|command| Observation::Record(command).selector());
+ let choices = commands
+ .iter()
+ .map(|command| {
+ let selector = Observation::Record(command).selector().unwrap_or_default();
+ Choice {
+ subject: subject(command),
+ state: state(command),
+ selected: Some(&selector) == selected_id.as_ref(),
+ url: super::inspect::url("/waterfall", &selector, None),
+ }
+ })
+ .collect();
+ let mut view = View {
+ choices,
+ runs: Vec::new(),
+ start: String::new(),
+ end: String::new(),
+ };
+ if let Some(command) = selected {
+ let start = time(&command.record.started_at).unwrap_or(now.fixed_offset());
+ let mut relays = Vec::new();
+ let owner = commands
+ .iter()
+ .find(|root| relay_path(root, command, &mut relays))
+ .copied()
+ .unwrap_or(command);
+ let mut log = matching::Log::read(
+ &owner.command_log,
+ time(&owner.record.started_at).unwrap_or(start),
+ )?;
+ let end = command
+ .record
+ .ended_at
+ .as_deref()
+ .and_then(time)
+ .or_else(|| (command.state == "live").then_some(now.fixed_offset()))
+ .or_else(|| log.last_time())
+ .unwrap_or(start)
+ .max(start);
+ let axis = Axis { start, end };
+ view.start = start.to_rfc3339();
+ view.end = end.to_rfc3339();
+ collect(
+ command,
+ &axis,
+ now.fixed_offset(),
+ 0,
+ &relays,
+ &mut log,
+ &mut view.runs,
+ )?;
+ }
+ Ok(view)
+}
diff --git a/src/logs/status/waterfall/matching.rs b/src/logs/status/waterfall/matching.rs
new file mode 100644
index 00000000..9e3c3551
--- /dev/null
+++ b/src/logs/status/waterfall/matching.rs
@@ -0,0 +1,188 @@
+//! Replay the Harness's own summaries, consuming exact scoped log lines once.
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use chrono::{Duration, NaiveTime, TimeZone};
+use serde_json::Value;
+
+use super::{Time, time};
+use crate::harness::Harness;
+use crate::logs::owner;
+use crate::logs::reading::Command;
+
+struct Line {
+ time: Time,
+ relays: Vec,
+ message: String,
+ used: bool,
+}
+pub(super) struct Log {
+ lines: Vec,
+}
+pub(super) struct Call {
+ pub label: String,
+ pub start: Option,
+}
+
+fn read(path: &Path) -> Result {
+ match fs::read_to_string(path) {
+ Ok(text) => Ok(text),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
+ Err(error) => Err(error).with_context(|| format!("can't read {}", path.display())),
+ }
+}
+
+impl Log {
+ pub(super) fn read(path: &Path, start: Time) -> Result {
+ let mut lines = Vec::new();
+ let mut previous = None;
+ let mut date = start.date_naive();
+ for raw in read(path)?.lines() {
+ let Some(rest) = raw.strip_prefix("thirdshift: ") else {
+ continue;
+ };
+ let Some((stamp, mut message)) = rest.split_once(' ') else {
+ continue;
+ };
+ let Ok(clock) = NaiveTime::parse_from_str(stamp, "%H:%M:%S") else {
+ continue;
+ };
+ // Relayed lines can arrive slightly out of order; only a large
+ // backwards jump is a midnight, not a scheduling delay.
+ if previous.is_some_and(|previous: NaiveTime| {
+ previous.signed_duration_since(clock) > Duration::hours(12)
+ }) {
+ date += Duration::days(1);
+ } else if previous.is_none()
+ && clock.signed_duration_since(start.time()) > Duration::hours(12)
+ {
+ date -= Duration::days(1);
+ }
+ previous = Some(clock);
+ let Some(time) = start
+ .offset()
+ .from_local_datetime(&date.and_time(clock))
+ .single()
+ else {
+ continue;
+ };
+ let mut relays = Vec::new();
+ while let Some((prefix, rest)) = message.split_once(": ") {
+ let Some(number) = prefix.strip_prefix('#').and_then(|n| n.parse().ok()) else {
+ break;
+ };
+ relays.push(number);
+ message = rest;
+ }
+ lines.push(Line {
+ time,
+ relays,
+ message: message.into(),
+ used: false,
+ });
+ }
+ Ok(Self { lines })
+ }
+
+ pub(super) fn last_time(&self) -> Option {
+ self.lines.iter().map(|line| line.time).max()
+ }
+}
+
+pub(super) fn calls(
+ session: &Path,
+ command: &Command,
+ relays: &[u64],
+ log: &mut Log,
+) -> Result> {
+ let text = read(session)?;
+ let Some(harness) = Harness::named(&command.record.harness) else {
+ return Ok(Vec::new());
+ };
+ let mut stream = harness.adapter().interpretation(Path::new(""), "");
+ let kind = owner::decode(session)
+ .and_then(|decoded| decoded.session_kind)
+ .or_else(|| {
+ session
+ .file_stem()?
+ .to_str()?
+ .rsplit_once('-')
+ .map(|(_, kind)| kind)
+ })
+ .unwrap_or("");
+ let began = time(&command.record.started_at);
+ let ended = command.record.ended_at.as_deref().and_then(time);
+ let mut calls = Vec::new();
+ let mut cursor = 0;
+ for raw in text.lines() {
+ let labels = stream.condense(raw);
+ let Ok(event) = serde_json::from_str::(raw) else {
+ continue;
+ };
+ let is_tool = (event["type"] == "assistant"
+ && event["message"]["content"]
+ .as_array()
+ .is_some_and(|blocks| blocks.iter().any(|block| block["type"] == "tool_use")))
+ || (event["type"]
+ .as_str()
+ .is_some_and(|kind| kind.starts_with("item."))
+ && matches!(
+ event["item"]["type"].as_str(),
+ Some(
+ "command_execution"
+ | "file_change"
+ | "mcp_tool_call"
+ | "web_search"
+ | "collab_tool_call"
+ )
+ ));
+ if !is_tool {
+ continue;
+ }
+ for (label_index, label) in labels.into_iter().enumerate() {
+ let message = format!("{kind}: {label}");
+ // Codex does not retain its cwd. File changes retain absolute
+ // paths, while progress prints paths relative to that cwd.
+ let file = event["item"]["changes"][label_index]["path"].as_str();
+ let matched = log
+ .lines
+ .iter_mut()
+ .enumerate()
+ .skip(cursor)
+ .find(|(_, line)| {
+ !line.used
+ && line.relays == relays
+ && (line.message == message
+ || file.is_some_and(|path| {
+ let Some(summary) = line.message.strip_prefix(&format!("{kind}: "))
+ else {
+ return false;
+ };
+ let Some((action, detail)) = summary.split_once(' ') else {
+ return false;
+ };
+ if label.split_once(' ').map(|(action, _)| action) != Some(action) {
+ return false;
+ }
+ if let Some(prefix) = detail.strip_suffix('…') {
+ path.trim_start_matches('/').starts_with(prefix)
+ || path.contains(&format!("/{prefix}"))
+ } else {
+ path == detail || path.ends_with(&format!("/{detail}"))
+ }
+ }))
+ // The log truncates fractions that the record keeps.
+ && began.is_none_or(|began| line.time.timestamp() >= began.timestamp())
+ && ended.is_none_or(|ended| line.time <= ended)
+ });
+ let start = matched.map(|(index, line)| {
+ cursor = index + 1;
+ line.used = true;
+ line.time
+ });
+ calls.push(Call { label, start });
+ }
+ }
+ Ok(calls)
+}
diff --git a/tests/console.rs b/tests/console.rs
index 4e57e658..4fec42f6 100644
--- a/tests/console.rs
+++ b/tests/console.rs
@@ -137,7 +137,155 @@ fn history_keeps_every_ended_command_in_time_order_and_filters() {
.unwrap()
.contains("acme/widgets#1")
);
- assert_eq!(console.get("/?page=waterfall").status(), 404);
+ assert_eq!(console.get("/?page=waterfall").status(), 200);
+}
+
+#[test]
+fn waterfall_matches_claude_calls_to_command_times_and_keeps_unmatched_calls() {
+ let home = tempfile::tempdir().unwrap();
+ configure(home.path());
+ let mut value = record(home.path(), 7, Some("merged"), 0);
+ let dir = home
+ .path()
+ .join(".thirdshift/logs/acme/widgets/commands/issue");
+ let session = dir.join("7-20261001T235950+0000-implement.jsonl");
+ fs::write(&session, concat!(
+ "{\"type\":\"system\",\"subtype\":\"init\",\"cwd\":\"/work\"}\n",
+ "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"a\",\"name\":\"Bash\",\"input\":{\"command\":\"rg widgets\"}},{\"type\":\"tool_use\",\"id\":\"b\",\"name\":\"Read\",\"input\":{\"file_path\":\"/work/missing.rs\"}},{\"type\":\"tool_use\",\"id\":\"c\",\"name\":\"Bash\",\"input\":{\"command\":\"rg widgets\"}}]}}\n"
+ )).unwrap();
+ fs::write(dir.join("7-fixture.log"), "thirdshift: 23:59:50 starting\nthirdshift: 23:59:55 implement: $ rg widgets\nthirdshift: 00:00:05 implement: $ rg widgets\nthirdshift: 00:00:10 finished\n").unwrap();
+ value["harness"] = json!("claude");
+ // Records retain fractions; Command logs quantize calls to whole seconds.
+ value["started_at"] = json!("2026-10-01T23:59:55.100+00:00");
+ value["ended_at"] = json!("2026-10-02T00:00:10+00:00");
+ value["stages"] =
+ json!([{ "name": "implement", "began_at": value["started_at"], "session_log": session }]);
+ value["session_log"] = json!(session);
+ write_record(home.path(), 7, &value);
+ let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift"));
+ let console = Console::start(binary, home.path());
+ let html = console
+ .get("/waterfall?command=widgets%237")
+ .text()
+ .unwrap();
+ assert!(html.contains("Waterfall "), "{html}");
+ assert_eq!(
+ html.matches("class=\"waterfall-tool\"").count(),
+ 3,
+ "{html}"
+ );
+ assert!(
+ html.contains("data-start=\"2026-10-01T23:59:55+00:00\" data-seconds=\"10\""),
+ "{html}"
+ );
+ assert!(html.contains("data-start=\"2026-10-02T00:00:05+00:00\" data-seconds=\"5\""));
+ assert!(html.contains("Read missing.rs"));
+ assert!(html.contains("Time unknown"));
+ let show = Command::new(binary)
+ .args(["show", "widgets#7", "--json"])
+ .env("HOME", home.path())
+ .output()
+ .unwrap();
+ assert!(show.status.success());
+ let detail: Value = serde_json::from_slice(&show.stdout).unwrap();
+ assert!(html.contains(detail["command"]["stages"][0]["began_at"].as_str().unwrap()));
+ let logs = Command::new(binary)
+ .args(["logs", "widgets#7"])
+ .env("HOME", home.path())
+ .output()
+ .unwrap();
+ assert!(logs.status.success());
+ assert!(
+ String::from_utf8(logs.stdout)
+ .unwrap()
+ .contains("23:59:55 implement: $ rg widgets")
+ );
+}
+
+#[test]
+fn waterfall_nests_ticket_runs_and_matches_codex_items_once_in_their_scope() {
+ let home = tempfile::tempdir().unwrap();
+ configure(home.path());
+ let mut parent = record(home.path(), 7, Some("merged"), 0);
+ let dir = home
+ .path()
+ .join(".thirdshift/logs/acme/widgets/commands/issue");
+ let session = dir.join("99-20261001T120000+0000-implement.jsonl");
+ fs::write(&session, concat!(
+ "{\"type\":\"thread.started\",\"thread_id\":\"fixture\"}\n",
+ "{\"type\":\"item.started\",\"item\":{\"id\":\"a\",\"type\":\"command_execution\",\"command\":\"/bin/bash -lc 'rg widgets'\",\"status\":\"in_progress\"}}\n",
+ "{\"type\":\"item.completed\",\"item\":{\"id\":\"b\",\"type\":\"file_change\",\"changes\":[{\"path\":\"/work/src/widgets.rs\",\"kind\":\"update\"}]}}\n",
+ "{\"type\":\"item.completed\",\"item\":{\"id\":\"a\",\"type\":\"command_execution\",\"command\":\"/bin/bash -lc 'rg widgets'\",\"status\":\"completed\",\"exit_code\":0}}\n",
+ "{\"type\":\"item.started\",\"item\":{\"id\":\"c\",\"type\":\"mcp_tool_call\",\"server\":\"github\",\"tool\":\"get_issue\",\"status\":\"in_progress\"}}\n",
+ "{\"type\":\"item.completed\",\"item\":{\"id\":\"c\",\"type\":\"mcp_tool_call\",\"server\":\"github\",\"tool\":\"get_issue\",\"status\":\"completed\"}}\n"
+ )).unwrap();
+ parent["started_at"] = json!("2026-10-01T12:00:00+00:00");
+ parent["ended_at"] = json!("2026-10-01T12:01:00+00:00");
+ parent["stages"] =
+ json!([{ "name": "Tickets", "began_at": parent["started_at"], "session_log": null }]);
+ write_record(home.path(), 7, &parent);
+ let mut child = parent.clone();
+ child["kind"] = json!("Run");
+ child["name"] = json!("acme/widgets#99");
+ child["issue"]["number"] = json!(99);
+ child["parent_record"] = json!(dir.join("7-fixture.command.json"));
+ child["started_at"] = json!("2026-10-01T12:00:10+00:00");
+ child["ended_at"] = json!("2026-10-01T12:00:40+00:00");
+ child["stages"] = json!([
+ { "name": "Setup", "began_at": child["started_at"], "session_log": null },
+ { "name": "implement", "began_at": "2026-10-01T12:00:15.100+00:00", "session_log": session }
+ ]);
+ child["session_log"] = json!(session);
+ fs::write(
+ dir.join("7-fixture.issue-99.child.json"),
+ serde_json::to_vec(&child).unwrap(),
+ )
+ .unwrap();
+ fs::write(dir.join("7-fixture.log"), "thirdshift: 12:00:00 starting\nthirdshift: 12:00:11 #98: implement: $ rg widgets\nthirdshift: 12:00:15 #99: implement: $ rg widgets\nthirdshift: 12:00:20 #99: implement: Edit src/widgets.rs\nthirdshift: 12:00:30 #99: implement: github get_issue\nthirdshift: 12:01:00 finished\n").unwrap();
+ let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift"));
+ let console = Console::start(binary, home.path());
+ let html = console
+ .get("/waterfall?command=widgets%237")
+ .text()
+ .unwrap();
+ assert!(html.contains("data-depth=\"1\""), "{html}");
+ assert!(html.contains("acme/widgets#99"));
+ assert_eq!(
+ html.matches("class=\"waterfall-tool\"").count(),
+ 3,
+ "{html}"
+ );
+ let tool_stage = html
+ .split("")
+ .find(|stage| stage.contains("data-start=\"2026-10-01T12:00:15+00:00\""))
+ .unwrap();
+ assert!(tool_stage.contains("implement "), "{tool_stage}");
+ for (start, seconds) in [("15", 5), ("20", 10), ("30", 10)] {
+ assert!(
+ html.contains(&format!(
+ "data-start=\"2026-10-01T12:00:{start}+00:00\" data-seconds=\"{seconds}\""
+ )),
+ "{html}"
+ );
+ }
+ let output = Command::new(binary)
+ .args(["show", "widgets#7", "--json"])
+ .env("HOME", home.path())
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+ let show: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(show["command"]["children"][0]["issue"]["number"], 99);
+ assert_eq!(console.get("/?page=4").status(), 200);
+ let single = console
+ .get("/waterfall?command=widgets%2399")
+ .text()
+ .unwrap();
+ assert_eq!(single.matches("class=\"waterfall-tool\"").count(), 3);
+ assert!(
+ single.contains("data-start=\"2026-10-01T12:00:15+00:00\" data-seconds=\"5\""),
+ "{single}"
+ );
}
#[test]