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
9 changes: 8 additions & 1 deletion console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,14 @@ folding still works through live updates. Keyboard row navigation skips folded
rows, and the same controls work in every Theme and on phones. Without
JavaScript, groups start open and their native disclosures still work.
The folders on the left can filter to one section, including Done and Resolved.
Rows show on one line each: state, subject, title, gist and age. Ready for review
Rows show state, subject, title, gist and age. A Spec with recorded Tickets also
shows `2 / 5 Tickets complete`, including on phones and on the visible row of a
Pass that dispatched it. This counts recorded merged Tickets, including Tickets
closed before the run, and updates with the Command record. Fleet uses the owning
machine's records. The last count remains after the run ends; absent or empty
Ticket accounting shows no counter. Completing all Tickets still leaves Spec
review and Delivery to finish; the row's state and stage show where it stands.
Ready for review
subjects link to the recorded pull request and show its title from the batched
GitHub refresh, including Review runs without an issue. Until a title is known,
the PR link stays usable with “(title unavailable)”. Without a PR, rows keep
Expand Down
8 changes: 8 additions & 0 deletions console/tests/fleet.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone'
issue: null, process_id: 0, process_start: 'gone', harness: 'codex',
started_at: now, ended_at: now, stages: [], session_log: null,
outcome: 'failed', cause: index ? 'Remote fixture needs your attention' : 'Local fixture needs your attention',
tickets: index ? ['merged', 'waiting'].map((state, i) => ({
issue: { number: 10 + i, url: `https://github.com/acme/widgets/issues/${10 + i}`, title: `Ticket ${i}` },
state, blockers: [],
})) : [],
}));
}
const cli = (home, args, input) => execFileSync(binary, args, { env: { ...process.env, HOME: home }, input, encoding: 'utf8' });
Expand Down Expand Up @@ -97,6 +101,8 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone'
assert.equal(await page.locator('.command').count(), 2);
await remoteRows.getByRole('button', { name: 'Dismiss', exact: true }).waitFor();
assert.match(await remoteRows.locator('.machine-tag').textContent(), /remote-workstation/);
await remoteRows.locator(':scope > .row-head > .ticket-progress').getByText('1 / 2 Tickets complete', { exact: true }).waitFor();
assert.equal(await page.locator('.command[data-console=""] .ticket-progress').count(), 0, 'local records supply no progress');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
if (process.env.AFTER_DESKTOP) {
for (const [width, height, path] of [[1440, 900, process.env.AFTER_DESKTOP], [390, 844, process.env.AFTER_MOBILE]]) {
Expand Down Expand Up @@ -124,9 +130,11 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone'
await page.waitForFunction(() => localStorage.getItem('thirdshift:console:notification-count'));
const remotePath = join(homes[1], '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json');
const added = JSON.parse(await readFile(remotePath, 'utf8'));
added.tickets[1].state = 'merged';
added.kind = 'Process'; added.process_name = 'custom-build';
await writeFile(remotePath, JSON.stringify(added));
await remoteRows.locator('.subject[title="Process custom-build on acme/widgets"]').waitFor();
await remoteRows.locator(':scope > .row-head > .ticket-progress').getByText('2 / 2 Tickets complete', { exact: true }).waitFor();
added.name = 'another-remote-command'; added.cause = 'Another remote problem';
await writeFile(remotePath.replace('1-fixture', '2-fixture'), JSON.stringify(added));
await page.waitForFunction(() => notices.length === 1);
Expand Down
67 changes: 67 additions & 0 deletions console/tests/inbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,73 @@ async function pageFor(t, options) {
return page;
}

test('Ticket progress stays visible with stage and health, updates, and follows a dispatched Spec', { skip: process.platform !== 'linux' }, async t => {
const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json');
const original = await readFile(path, 'utf8');
const fixture = JSON.parse(original);
const stat = await readFile(`/proc/${server.pid}/stat`, 'utf8');
const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`;
const began = new Date(Date.now() - 15 * 60 * 1000).toISOString();
const log = path.replace('.command.json', '.log');
Object.assign(fixture, {
name: 'spec-progress', process_id: server.pid, process_start: start,
started_at: began, ended_at: null, outcome: null, cause: null,
stages: [{ name: 'Tickets', began_at: began, session_log: null }],
tickets: ['merged', 'merged', 'running', 'failed', 'waiting', 'not started'].map((state, index) => ({
issue: { number: 310 + index, url: `https://github.com/acme/widgets/issues/${310 + index}`, title: `Ticket ${index}` },
state, blockers: [],
})),
});
const page = await pageFor(t);
const row = page.locator('#inbox > .group > .commands > .command[data-command*="1-fixture.command.json"]');
const head = row.locator(':scope > .row-head');
const progress = head.locator('.ticket-progress');
try {
await utimes(log, new Date(began), new Date(began));
await replaceRecord(path, JSON.stringify(fixture));
await page.goto(`${origin}/?section=overview`);
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) {
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, `${family}-light`);
await progress.getByText('2 / 6 Tickets complete', { exact: true }).waitFor();
await head.locator('.gist').getByText(/Tickets.*silent 15 min/).waitFor();
assert.equal(await head.locator('.state').textContent(), 'Silent');
assert.equal(await progress.evaluate(el => el.scrollWidth <= el.clientWidth), true, `${family}: full counter`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${family}: page fits`);
}
}
fixture.tickets[2].state = 'merged';
await replaceRecord(path, JSON.stringify(fixture));
await progress.getByText('3 / 6 Tickets complete', { exact: true }).waitFor();

const now = new Date().toISOString();
const spec = { ...fixture, started_at: now, tickets: fixture.tickets.map(ticket => ({ ...ticket, state: 'merged' })),
stages: [{ name: 'Spec review', began_at: now, session_log: null }] };
Object.assign(fixture, { kind: 'Pickup run', name: 'pickup-progress', issue: null, tickets: [],
stages: [{ name: 'dispatched Spec run', began_at: now, session_log: null }], dispatched: [spec] });
await replaceRecord(path, JSON.stringify(fixture));
await progress.getByText('6 / 6 Tickets complete', { exact: true }).waitFor();
assert.equal(await row.getAttribute('data-live'), 'true', 'Tickets complete still leaves Spec review running');
assert.equal(await row.evaluate(el => el.closest('.group').dataset.group), 'working');
assert.equal(await row.locator(':scope > .children').isVisible(), false, 'progress is on the visible Pass row');
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
await progress.waitFor({ state: 'visible' });
await head.locator('.gist').waitFor({ state: 'visible' });
assert.equal(await progress.evaluate(el => el.scrollWidth <= el.clientWidth), true);
}
fixture.outcome = 'failed'; fixture.ended_at = now;
fixture.dispatched[0].outcome = 'failed'; fixture.dispatched[0].ended_at = now;
await replaceRecord(path, JSON.stringify(fixture));
await head.locator('.state').getByText('Failed', { exact: true }).waitFor();
assert.equal(await progress.textContent(), '6 / 6 Tickets complete', 'ended accounting is retained');
} finally {
await replaceRecord(path, original);
await utimes(log, new Date(), new Date());
}
});

test('the header machine name returns to the full Overview from filtered and secondary pages', async t => {
for (const javaScriptEnabled of [true, false]) {
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
Expand Down
2 changes: 2 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ Open the localhost address it prints. The Console shows the same Inbox as `third

Click the serving machine's name in the Console header to return to Overview (`/?section=overview`) from another page or section, clearing any machine filter.

Specs with recorded Tickets show a count such as **2 / 5 Tickets complete** on their Inbox row, including on phones and when a Pass dispatched the Spec. It counts merged Tickets, including those closed before the run, from the owning machine's last recorded accounting. Record updates refresh the count, and ended runs retain it. Missing or empty accounting shows no counter. All Tickets complete can still leave Spec review or Delivery running; the row keeps its state, stage and health information.

Every Inbox group starts open. Click its heading, or focus it and press Enter or Space, to fold its rows while keeping its heading and count visible. Each browser remembers its group choices through live updates, folder changes and reloads when storage is available. Folding still works through live updates without storage, and native disclosures work without JavaScript. Counts and active-run totals include folded rows; keyboard row navigation skips them. This works in every Theme and on phones.

Command details shows the last five lines of recent Command output above **Full Command log**, updating as output arrives. It reads at most 4 KiB, so a long log stays a small preview. This helps assess a Slow run without leaving its details. Empty or unavailable logs say so; the full-log link stays available.
Expand Down
1 change: 1 addition & 0 deletions site/architecture/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ <h1>Architecture</h1>
<p>Rust's data-carrying <code>enum</code>s represent the unattended Ticket run, Claim and CI states. Exhaustive <code>match</code> means adding a state fails compilation until every match handles it. Pickup's reservation protocol across competing hosts is model-checked with <code>stateright</code> inside <code>cargo test</code> (<a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0017-pickup-reserves-issues-on-origin.md">ADR-0017</a>). These checks matter when nobody is watching the control flow.</p>
<details class="reading-more fold">
<summary>Console and host resources</summary>
<p>Inbox Ticket progress comes from typed Ticket records: merged Tickets over all recorded Tickets, including those closed before the Spec run. It updates with existing record notifications, uses the owning machine's records for Fleet, and stays visible on a Pass that dispatched a Spec. Empty accounting has no counter; completing Tickets does not finish Spec review or Delivery.</p>
<p>Fleet starts and administration call the owning machine's API. Start choices and cron Passes come from its <code>/api/v1/start-options</code>, gated by <code>run</code>; User config and Update require <code>admin</code>. Controls use live <code>whoami</code> scopes for the signed-in person. Page forwarding names that person; the owner intersects their scopes with the token's, starts the Command and records the person and calling Console. CLI <code>start</code>, <code>config</code> and <code>update --machine</code> use the token holder. API endpoints stay local and cannot borrow another Console's Fleet credentials.</p>
<p>Cron jobs' explicit <strong>Fleet schedules</strong> page and <code>cronjobs --fleet [--json]</code> share request-owned aggregation of local and owning-machine inventories. Repository metadata and nullable Command kinds come from each owner; the client groups by repository and machine without resolving remote paths or converting cron time zones. Independent bounded reads preserve healthy results alongside unavailable, refused, incompatible and link-only states; link-only entries receive no requests. Page reads forward the signed-in person and require effective <code>read</code> access. <code>/api/v1/cronjobs</code> stays local and refuses Fleet selection, and Run now keeps its separate authorization.</p>
<p>The Inbox polls live Fleet entries every 15 seconds only while a page subscription is open. Each request runs independently off the async executor with its own three-second timeout, so a bad machine never holds up a good one or a page request. Last good rows and the last error stay in memory only. Rows carry machine tags; each machine shows its answer age and active-run count. After 60 seconds without a good answer it is unreachable, with grey rows. Refused tokens and older Consoles have distinct diagnostics and links. <code>?machine=&lt;name&gt;</code> filters the Inbox; its absence includes the Fleet in every group, the tab's Needs you count and browser notifications. Remote details use a request-owned client to fetch the owning machine's <code>/api/v1</code> and render with the local Peek, Waterfall and log templates. Nothing is cached beyond the request or written to disk. The owning machine enforces <code>security</code> for Security run details and findings; the caller renders its refusal. Detail links retain the Fleet entry, and each row and view also links to the owning Console. The CLI's <code>show</code> and <code>logs</code> use the same remote reader with <code>--machine &lt;name&gt;</code>, returning the owner's JSON.</p>
Expand Down
1 change: 1 addition & 0 deletions site/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@
</code></pre>
<p>Open the localhost address it prints. The Console shows the same Inbox as <code>thirdshift status</code>, with live stages, logs and Session traces. It opens on Overview, showing Needs you, Ready for review, Landing and Working together under labeled headings with counts. The folders beside the one-line rows filter to an individual section; <code>/?section=done</code> opens Done and <code>/?section=overview</code> returns to Overview. Hidden sections stay hidden in Overview, and these views work without JavaScript. A clicked row&#39;s Command details open alongside on a wide screen. Ended rows show their relative age, with the recorded finish in this machine&#39;s time zone on hover, or in full without JavaScript, for example <strong>Ended 2026-10-23 19:03 EDT · 52 min ago</strong>. Every date and time the Console shows reads this way, to the minute. This is time since finish, not run duration. Nested Ticket runs and History use each Command&#39;s own finish time; missing or invalid finish times say <strong>End time unknown</strong>. The finish information also works without JavaScript. With <code>run</code>, use <strong>Start work</strong> on Inbox or <strong>Run now</strong> on Cron jobs to request a Command with confirmation. The Console starts work only when asked; the CLI and operating system&#39;s scheduler remain available too.</p>
<p>Click the serving machine&#39;s name in the Console header to return to Overview (<code>/?section=overview</code>) from another page or section, clearing any machine filter.</p>
<p>Specs with recorded Tickets show a count such as <strong>2 / 5 Tickets complete</strong> on their Inbox row, including on phones and when a Pass dispatched the Spec. It counts merged Tickets, including those closed before the run, from the owning machine&#39;s last recorded accounting. Record updates refresh the count, and ended runs retain it. Missing or empty accounting shows no counter. All Tickets complete can still leave Spec review or Delivery running; the row keeps its state, stage and health information.</p>
<p>Every Inbox group starts open. Click its heading, or focus it and press Enter or Space, to fold its rows while keeping its heading and count visible. Each browser remembers its group choices through live updates, folder changes and reloads when storage is available. Folding still works through live updates without storage, and native disclosures work without JavaScript. Counts and active-run totals include folded rows; keyboard row navigation skips them. This works in every Theme and on phones.</p>
<p>Command details shows the last five lines of recent Command output above <strong>Full Command log</strong>, updating as output arrives. It reads at most 4 KiB, so a long log stays a small preview. This helps assess a Slow run without leaving its details. Empty or unavailable logs say so; the full-log link stays available.</p>
<details class="fold"><summary><h4>Fleet</h4></summary>
Expand Down
1 change: 1 addition & 0 deletions site/how-it-works/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ <h1>How thirdshift works</h1>
<p>A later Command on the same pull request, as on the same issue, takes up an earlier row waiting in Ready for review or Needs you. This includes a <a href="/docs/#fix-runs">Fix run</a> on a pull request with no linked issue: the earlier row says “taken up by a later Command started at &lt;time&gt;”. Ready for review rows with <b>CI red</b> or <b>Merge conflict</b> offer <b>Start Fix run</b> when the owning machine currently permits <code>run</code> and knows the repository's Launch directory. Confirm the pull request, repository and machine to start there using its User config and CLI defaults; Cancel or Escape starts nothing. Other-account Commands stay read-only. <b>Copy fix command</b> remains on the row and in Command details: <code>thirdshift fix &lt;PR URL&gt;</code>. Copy it and run it from a clone of the repository.</p>
<p>Every Console tab title names its host; the Inbox also keeps its Needs you count through live updates.</p>
<p>Click the serving machine's name in the Console header to return to Overview (<code>/?section=overview</code>) from another page or section, clearing any machine filter.</p>
<p>A Spec's Inbox row shows its last recorded Ticket progress, such as <code>2 / 5 Tickets complete</code>, on desktop and phone. A Pass that dispatched the Spec carries that count on its visible row. All Tickets complete can still leave Spec review or Delivery running.</p>
<p>The Console and <code>status</code> show the serving OS account's Commands by default. <code>thirdshift console other-accounts readable</code>, or a comma-separated list of local account names, adds readable Other-account Commands with their owner shown. They appear in Needs you and its tab count, but have no Stop, Requeue, Dismiss or Undo. Those actions apply only to the serving account's Commands. Grant read access with filesystem permissions or ACLs; thirdshift never grants access. <a href="/docs/#other-local-accounts">Account visibility</a>.</p>
<p>Ready for review subjects link directly to the recorded pull request and show its title, including Review runs without an issue. Titles come from the batched GitHub refresh and update on open pages. Until a title is known, the PR link stays usable with “(title unavailable)”. Without a PR, rows use their issue subject and title, or the Command subject when neither exists. Activating the link opens GitHub without opening Details.</p>
<p>Command details opens with the Command's issue subject linked to the issue and its full title, above Stop and Dismiss. Long titles wrap; an unknown title says “(title unavailable)”. A Command without an issue shows its Command subject alone. The recorded issue title is also in <code>thirdshift show &lt;command&gt; --json</code>.</p>
Expand Down
Loading