Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions console/reproductions/waterfall-fractional.py
Original file line number Diff line number Diff line change
@@ -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'<li class="waterfall-tool" data-start="([^"]*)" data-seconds="([^"]*)"',html)
print('Rendered tool timing:',tools,flush=True)
assert len(tools)==1 and tools[0][0]=='2026-10-01T12:00:00+00:00', 'The existing matching Command-log line must time this call; fractional record start incorrectly rejects it'
finally:
server.terminate()
server.wait(timeout=10)
8 changes: 8 additions & 0 deletions console/reproductions/waterfall-parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pathlib, re

source = (pathlib.Path(__file__).resolve().parents[2] / 'src/console.rs').read_text()
declaration = re.search(r'PageRoute\s*\{\s*path:\s*"/waterfall",(?P<fields>.*?)\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'
29 changes: 29 additions & 0 deletions console/reproductions/waterfall-stage.py
Original file line number Diff line number Diff line change
@@ -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('<li class="waterfall-stage">')[1:]
assigned = [re.search(r'<h4>([^<]*)</h4>',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)
9 changes: 2 additions & 7 deletions console/tests/inbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,15 @@ 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();
assert.equal(await page.locator('main h1, main h2').first().textContent(), name[0].toUpperCase() + name.slice(1));
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}`);
Expand Down
103 changes: 103 additions & 0 deletions console/tests/waterfall.test.cjs
Original file line number Diff line number Diff line change
@@ -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 <script>unsafe()</script>' },
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 <unsafe>') + 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 <unsafe>\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, []);
});
1 change: 1 addition & 0 deletions docs/code-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading