{{ run.subject }} · {{ run.state }}
{{ run.title }}
+ + {% if run.stages.is_empty() %}No stage times retained.
{% endif %} +- {% for stage in run.stages %}
- {{ stage.name }}: {{ stage.text }} {% endfor %}
diff --git a/console/README.md b/console/README.md index 201ef865..09a459ee 100644 --- a/console/README.md +++ b/console/README.md @@ -31,16 +31,30 @@ 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`), History (`5`), Logs (`6`) and Security (`7`). Links +The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), 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. +Lanes shows each Command and its child Runs on a shared time axis, over the last +3.5, 12 or 24 hours. The window is kept in `hours=`; bars clip to that window, +while their text retains the whole stage's time. Older Commands are on History. +Stages keeps live Runs first across repositories, then finished Runs newest first. +Tables continue when the repository changes or three columns are filled, so +each stage's Usual range belongs to that table's repository. On phones each +stage stacks its labelled cells. Historical Runs without stage metadata show +an unknown time, distinct from a stage that was not reached. Choose +a column's Run link to open its exact peek. Repeated stages retain every visit; +missing end timestamps say “time unknown” rather than inventing a duration. +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. + `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, `h` History, `o` Logs, `s` Security, or a section's displayed +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 beside the list on a wide screen or at the bottom of a phone. diff --git a/console/reproductions/time-grid.py b/console/reproductions/time-grid.py new file mode 100644 index 00000000..7ca64e0f --- /dev/null +++ b/console/reproductions/time-grid.py @@ -0,0 +1,64 @@ +import datetime as dt +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import urllib.request +from html.parser import HTMLParser + +class Tables(HTMLParser): + def __init__(self): + super().__init__(); self.tables=[]; self.table=None; self.in_cell=False; self.text='' + def handle_starttag(self, tag, attrs): + if tag=='table': self.table=[] + if tag=='tr' and self.table is not None: self.row=[] + if tag in ('th','td') and self.table is not None: self.in_cell=True; self.text='' + def handle_data(self, data): + if self.in_cell: self.text+=data + def handle_endtag(self, tag): + if tag in ('th','td') and self.in_cell: self.row.append(self.text); self.in_cell=False + if tag=='tr' and self.table is not None: self.table.append(self.row) + if tag=='table': self.tables.append(self.table); self.table=None + +binary=str(Path(sys.argv[1] if len(sys.argv)>1 else 'target/debug/thirdshift').resolve()) +case=sys.argv[2] if len(sys.argv)>2 else 'ordering' +with tempfile.TemporaryDirectory(prefix='issue-826-review-') as home: + root=Path(home)/'.thirdshift'; root.mkdir(); (root/'config.toml').write_text('[console]\nport = 0\n') + now=dt.datetime.now(dt.timezone.utc) + stamp=lambda mins:(now-dt.timedelta(minutes=mins)).isoformat() + fields=Path('/proc/self/stat').read_text().rsplit(') ',1)[1].split() + identity=Path('/proc/sys/kernel/random/boot_id').read_text().strip()+':'+fields[19] + def record(repository, number, live): + folder=root/'logs'/repository/'commands/issue'; folder.mkdir(parents=True,exist_ok=True) + value=dict(version=1,kind='Run',name='run-'+str(number),repository=repository,hostname='fixture', + issue=dict(number=number,url=f'https://github.com/{repository}/issues/{number}',title='fixture'), + process_id=os.getpid() if live else 0,process_start=identity if live else 'gone',harness='codex', + started_at=stamp(30),ended_at=None if live else stamp(10),outcome=None if live else 'merged',session_log=None, + stages=[dict(name='implement',began_at=stamp(30),session_log=None)]) + (folder/f'{number}-fixture.command.json').write_text(json.dumps(value)) + record('acme/a',1,False); record('zz/z',2,True) + old=root/'logs/acme/a/activity.log' + old.write_text('2026-10-01 12:00:00 Run #7 started: commands/issue/7-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #7 ended: merged\n') + env=dict(os.environ,HOME=home) + status=json.loads(subprocess.check_output([binary,'status','--all','--json'],env=env)) + server=subprocess.Popen([binary,'console','--no-sign-in'],env=env,stdout=subprocess.PIPE,text=True) + try: + origin=server.stdout.readline().strip() + html=urllib.request.urlopen(origin+'/stages',timeout=5).read().decode() + parser=Tables(); parser.feed(html) + if case=='ordering': + expected=[f'{c["repository"]}#{c["issue"]["number"]}' for c in status['commands'] if c['state']=='live'] + headers=[cell for table in parser.tables for cell in table[0][1:]] + assert headers[0].startswith(expected[0]),f'Live Runs should be pinned first; got columns {headers}; live={expected}' + elif case=='history': + history=next(c for c in status['commands'] if c['repository']=='acme/a' and c['issue']['number']==7) + assert history['stages']==[] and history['source']=='activity_log' + table=next(t for t in parser.tables if any('acme/a#7' in h for h in t[0])) + col=next(i for i,h in enumerate(table[0]) if 'acme/a#7' in h) + cell=table[1][col] + assert 'Not reached' not in cell,f'No stage metadata was retained for the merged historical Run; grid invents a state: {cell}' + else: raise ValueError(case) + finally: + server.terminate(); server.wait(timeout=5) diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 9d764b48..f4fb6fa2 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 [['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { + for (const [key, name] of [['2', 'lanes'], ['3', 'stages'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { await page.keyboard.press(key); await page.waitForURL(`**/?page=${name}`); await page.reload(); @@ -208,12 +208,12 @@ 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 ['2', '3', '4']) { + 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 [['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { + for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['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/time.test.cjs b/console/tests/time.test.cjs new file mode 100644 index 00000000..b41ec6a8 --- /dev/null +++ b/console/tests/time.test.cjs @@ -0,0 +1,125 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = 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; +const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-time-')); + 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 now = Date.now(); + const time = minutes => new Date(now - minutes * 60000).toISOString(); + for (let number = 1; number <= 7; number++) { + const live = number === 1; + const record = { + version: 1, kind: 'Run', name: `run-${number}`, repository: 'acme/widgets', hostname: 'fixture', + issue: { number, url: `https://github.com/acme/widgets/issues/${number}`, title: `Run ${number} ` }, + process_id: live ? process.pid : 0, process_start: live ? processStart : 'gone', harness: 'codex', + started_at: time(number === 7 ? 1500 : 180 - number * 10), + ended_at: live ? null : time(number === 7 ? 1480 : 150 - number * 10), + outcome: live ? null : 'merged', session_log: null, + stages: [ + { name: 'implement', began_at: time(number === 7 ? 1500 : 180 - number * 10), session_log: null }, + { name: 'CI watch', began_at: time(number === 7 ? 1490 : 170 - number * 10), session_log: null } + ], + ...(number === 6 ? { parent_record: join(folder, '2-fixture.command.json') } : {}) + }; + await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify(record)); + await writeFile(join(folder, `${number}-fixture.log`), `Command ${number}\n`); + } + // A finished Run in an alphabetically earlier repository must not precede + // this repository's live Run, or reorder the finished columns by repository. + const otherFolder = join(home, '.thirdshift/logs/aaa/first/commands/issue'); + await mkdir(otherFolder, { recursive: true }); + const other = JSON.parse(await readFile(join(folder, '3-fixture.command.json'), 'utf8')); + other.repository = 'aaa/first'; + other.issue.number = 8; + other.issue.url = 'https://github.com/aaa/first/issues/8'; + await writeFile(join(otherFolder, '8-fixture.command.json'), JSON.stringify(other)); + await writeFile(join(home, '.thirdshift/logs/acme/widgets/activity.log'), + '2026-10-01 12:00:00 Run #9 started: commands/issue/9-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #9 ended: merged\n'); + server = spawn(binary, ['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('Lanes scales every window, nests child Runs and fits 390 px without CSP violations', async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const hours of [3.5, 12, 24]) { + await page.goto(`${origin}/?page=lanes&hours=${hours}`); + assert.equal(await page.locator('[name="hours"]').inputValue(), String(hours)); + assert.equal(await page.locator('.lane').count(), 6); + assert.equal(await page.locator('.lane').filter({ hasText: 'acme/widgets#2' }).locator('.time-run').count(), 2); + const complete = page.locator('.time-run').filter({ hasText: 'acme/widgets#3' }); + const track = await complete.locator('.timeline').boundingBox(); + const bars = await complete.locator('.timeline rect').all(); + const first = await bars[0].boundingBox(); + const second = await bars[1].boundingBox(); + // Painted bounds include the one-pixel stroke around each scaled interval. + assert.ok(Math.abs(first.width - track.width * 10 / (hours * 60)) <= 1.01, JSON.stringify({ hours, track, first, second })); + assert.ok(Math.abs(second.width - track.width * 20 / (hours * 60)) <= 1.01); + // #3 starts 150 minutes ago; its first bar is placed on the same time axis. + assert.ok(Math.abs((first.x - track.x) / track.width - (1 - 150 / (hours * 60))) < 0.002); + assert.match(await complete.textContent(), /implement: Complete · 10 min/); + assert.match(await complete.textContent(), /CI watch: Complete · 20 min/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + await page.locator('[name="hours"]').selectOption('12'); + await page.getByRole('button', { name: 'Show', exact: true }).click(); + await page.waitForURL('**hours=12'); + assert.equal(await page.locator('.time-run script').count(), 0); + assert.deepEqual(errors, []); +}); + +test('Stages pins live Runs, matches CLI order and opens the selected child Run at 390 px', { skip: process.platform !== 'linux' }, async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(`${origin}/?page=stages`); + const status = JSON.parse(execFileSync(binary, ['status', '--all', '--json'], { env: { ...process.env, HOME: home } })); + const runs = status.commands.flatMap(command => [command, ...(command.children ?? [])]); + runs.sort((a, b) => (a.state !== 'live') - (b.state !== 'live') || Date.parse(b.ended_at ?? b.started_at) - Date.parse(a.ended_at ?? a.started_at)); + assert.deepEqual(await page.locator('thead .peek-link').allTextContents(), runs.map(run => `${run.repository}#${run.issue.number}`)); + assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::run-1`); + assert.match(await page.locator('.stage-grid').first().textContent(), /Running ·/); + assert.match(await page.locator('.stage-grid').first().textContent(), /Usual range: 10–10 min/); + const historical = page.locator('.stage-grid').filter({ has: page.locator('thead .peek-link', { hasText: 'acme/widgets#9' }) }); + const headers = await historical.locator('thead .peek-link').allTextContents(); + const historicalCell = historical.locator('tbody tr').first().locator('td').nth(headers.indexOf('acme/widgets#9')); + assert.match(await historicalCell.textContent(), /Unknown · no stage times retained/); + assert.doesNotMatch(await historicalCell.textContent(), /Not reached/); + const child = page.locator('thead .peek-link').filter({ hasText: 'acme/widgets#6' }); + await child.click(); + await page.locator('#peek-content h2').waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /run-6/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#peek').isVisible(), false); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 1f496f7e..63632144 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -22,6 +22,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` | | 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` | | 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 bbb3cb89..2bcfc746 100644 --- a/src/console.rs +++ b/src/console.rs @@ -3,6 +3,7 @@ mod conversation; mod logs; mod peek; mod restart; +mod time; use std::io::Write; use std::net::Ipv4Addr; @@ -29,6 +30,14 @@ struct PageRoute { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/lanes", + cli: "thirdshift status --all --json", + }, + PageRoute { + path: "/stages", + cli: "thirdshift status --all --json", + }, PageRoute { path: "/", cli: "thirdshift status", @@ -168,6 +177,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(), "lanes" | "2" | "stages" | "3") { + return time::show(matches!(page.as_str(), "stages" | "3"), query).await; + } let archive_page = match page.as_str() { "history" | "5" => Some(status::ArchivePage::History), "security" | "7" => Some(status::ArchivePage::Security), diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9a30b127..9ecccb29 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -83,6 +83,26 @@ footer { margin-top: 16px; } #log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } #log-content h3 { font-size: 14px; } .diff { border-left: 3px solid var(--look); padding-left: 10px; } +.lane { border-bottom: 1px solid var(--rule); padding: 10px 0; } +.time-run h3 { font-size: 14px; margin: 4px 0; color: var(--text); } +.lane .time-run + .time-run { margin-left: 12px; border-left: 1px solid var(--rule); } +.timeline { display: block; background: var(--panel-2); margin: 8px 0; } +.timeline rect { fill: var(--panel); stroke: var(--working); } +.timeline text { fill: var(--text); font: 12px var(--font-mono); } +.stage-times { padding-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; } +.stage-grid th { font-weight: var(--head-weight); } +.usual { font-size: 12px; font-weight: normal; color: var(--dim); } +.cell-run { display: none; } +@media (max-width: 760px) { + .stage-grid, .stage-grid thead, .stage-grid tbody, .stage-grid tr, .stage-grid th, .stage-grid td { display: block; } + .stage-grid thead tr { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stage-grid thead th:first-child { grid-column: 1 / -1; } + .stage-grid tbody th { background: var(--panel-2); } + .cell-run { display: inline; font-weight: var(--head-weight); } +} @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/nav.html b/src/console/nav.html index adda64ac..ab2ebd48 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/time.html b/src/console/time.html new file mode 100644 index 00000000..7d8d803a --- /dev/null +++ b/src/console/time.html @@ -0,0 +1,55 @@ + + +
+ + +thirdshift / Console
thirdshift status --all --jsonStages drawn to scale from {{ views.start }} to {{ views.end }}. Times below each lane include the whole stage.
+Older Commands are on History.
+ {% if views.lanes.is_empty() %}No Commands in this window.
{% endif %} + {% for lane in views.lanes %} +{{ run.title }}
+ + {% if run.stages.is_empty() %}No stage times retained.
{% endif %} +Live Runs first, then finished Runs newest first. Tables continue in that order, with each repository's Usual range. Repeated stages list every visit.
+ {% if views.grids.is_empty() %}No Runs yet.
{% endif %} + {% for grid in views.grids %} +| Stage Usual range | {% for run in grid.runs %}{{ run.subject }} {{ run.state }} {{ run.title }} | {% endfor %}
|---|---|
| {{ row.name }} Usual range: {{ row.usual }} | {% for cell in row.cells %}{{ grid.runs[loop.index0].subject }}: {{ cell }} | {% endfor %}
No stage times retained.
{% endif %} + {% endfor %} + {% endif %} +