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
18 changes: 16 additions & 2 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
64 changes: 64 additions & 0 deletions console/reproductions/time-grid.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 3 additions & 3 deletions console/tests/inbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,20 @@ 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();
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 ['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}`);
Expand Down
125 changes: 125 additions & 0 deletions console/tests/time.test.cjs
Original file line number Diff line number Diff line change
@@ -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} <script>unsafe()</script>` },
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);
});
1 change: 1 addition & 0 deletions docs/code-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod conversation;
mod logs;
mod peek;
mod restart;
mod time;

use std::io::Write;
use std::net::Ipv4Addr;
Expand All @@ -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",
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading