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
6 changes: 6 additions & 0 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ Command name, including Security runs, Triage runs and Custom processes.
Long repository names wrap on desktop and phone; the full Command subject
remains available on hover and in Command details.

Live Fix runs and Review runs also show a clickable **PR #number** in the compact
Working row, including Overview, for explicit and Pass-selected targets, locally
and across the Fleet. Base branch CI repairs show their recorded **Base branch**
before PR publication and retain it alongside the PR link afterwards. Older
records without a target keep their repository and Command label.

For Console CSS and JavaScript development, serve the assets from this checkout:

```sh
Expand Down
114 changes: 114 additions & 0 deletions console/tests/working-targets.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { once } = require('node:events');
const { mkdtemp, mkdir, writeFile, readFile, rm } = require('node:fs/promises');
const { tmpdir, hostname } = require('node:os');
const { join, resolve } = require('node:path');
const { createInterface } = require('node:readline');
const { chromium } = require('./playwright.cjs');
const { replaceRecord } = require('./records.cjs');

test('Working identifies live Review and Fix targets locally and across the Fleet', { skip: process.platform !== 'linux' }, async t => {
const root = await mkdtemp(join(tmpdir(), 'thirdshift-working-targets-'));
const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift');
const children = [];
let browser;
t.after(async () => {
await browser?.close();
for (const { child, exited } of children) {
if (child.exitCode === null) child.kill();
await exited;
}
await rm(root, { recursive: true, force: true });
});
const homes = ['local', 'remote'].map(name => join(root, name));
const cli = (home, args, input) => execFileSync(binary, args, { env: { ...process.env, HOME: home }, input, encoding: 'utf8' });
const records = [];
for (const home of homes) {
const dir = join(home, '.thirdshift/logs/acme/widgets/commands/fix');
await mkdir(dir, { recursive: true });
await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n');
const child = spawn(binary, ['console', '--no-sign-in'], {
env: { ...process.env, HOME: home, THIRDSHIFT_FLEET_INTERVAL_MS: '100' }, stdio: ['ignore', 'pipe', 'inherit'],
});
const running = { child, exited: once(child, 'exit') };
children.push(running);
const lines = createInterface({ input: child.stdout });
try { [running.origin] = await once(lines, 'line', { signal: AbortSignal.timeout(15000) }); }
finally { lines.close(); }
const stat = await readFile(`/proc/${child.pid}/stat`, 'utf8');
const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`;
for (const [name, kind, number] of [
['review-explicit', 'Review run', 41], ['review-pass', 'Review run', 42],
['fix-explicit', 'Fix run', 43], ['fix-pass', 'Fix run', 45],
['fix-base', 'Fix run', null], ['fix-legacy', 'Fix run', null],
]) {
const now = new Date().toISOString();
const value = { version: 1, name, kind, repository: 'acme/widgets', hostname: 'fixture',
process_id: child.pid, process_start: start, harness: 'codex',
started_at: now, ended_at: null, outcome: null, issue: null,
stages: [{ name: 'ci-fix', began_at: now, session_log: null }],
pull_request: number && `https://github.com/acme/widgets/pull/${number}`,
...(name === 'fix-base' ? { base_branch: 'release/2026.11', issue_branch: 'unrelated-repair-name' } : {}),
};
const path = join(dir, `${name}.command.json`);
await replaceRecord(path, JSON.stringify(value));
if (name === 'fix-base') records.push({ path, value });
}
}
const token = cli(homes[1], ['console', 'token', 'create', 'caller', '--scopes', 'read']).trim();
cli(homes[0], ['console', 'fleet', 'add', 'remote', children[1].origin], `${token}\n`);
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
for (const [width, height, capture] of [[1440, 900, 'DESKTOP'], [390, 844, 'MOBILE']]) {
await page.setViewportSize({ width, height });
for (const section of ['overview', 'working']) {
await page.goto(`${children[0].origin}/?section=${section}`);
await page.locator('.command[data-machine="remote"]').first().waitFor();
if (section === 'overview' && process.env[`BEFORE_${capture}`]) {
await page.screenshot({ path: process.env[`BEFORE_${capture}`], fullPage: true });
}
if (process.env.CAPTURE_BEFORE) continue;
const rows = page.locator('#inbox > [data-group="working"] .command');
assert.equal(await rows.count(), 12);
for (const machine of [hostname(), 'remote']) {
for (const [name, kind, number] of [
['review-explicit', 'Review run', 41], ['review-pass', 'Review run', 42],
['fix-explicit', 'Fix run', 43], ['fix-pass', 'Fix run', 45],
]) {
const row = page.locator(`.command[data-machine="${machine}"][data-command*="${name}.command.json"]`);
const subject = row.locator('.row-head .subject');
const link = subject.getByRole('link', { name: `PR #${number}`, exact: true });
assert.equal(await link.isVisible(), true, `${width}px ${machine} ${name}: visible PR target`);
assert.equal(await link.getAttribute('href'), `https://github.com/acme/widgets/pull/${number}`);
assert.equal(await subject.getByText('acme/widgets', { exact: true }).isVisible(), true);
assert.match(await subject.textContent(), new RegExp(kind));
}
const base = page.locator(`.command[data-machine="${machine}"][data-command*="fix-base.command.json"]`);
assert.equal(await base.locator('.row-head').getByText('Base branch release/2026.11', { exact: true }).isVisible(), true);
assert.equal(await base.locator('.row-head').getByRole('link', { name: 'PR #44', exact: true }).count(), 0);
const legacy = page.locator(`.command[data-machine="${machine}"][data-command*="fix-legacy.command.json"]`);
assert.match(await legacy.locator('.subject').textContent(), /acme\/widgets/);
assert.equal(await legacy.locator('.row-head').getByText(/^Base branch /).count(), 0);
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
if (section === 'overview' && process.env[`AFTER_${capture}`]) {
await page.screenshot({ path: process.env[`AFTER_${capture}`], fullPage: true });
}
}
}
if (process.env.CAPTURE_BEFORE) return;
for (const { path, value } of records) {
await replaceRecord(path, JSON.stringify({ ...value, pull_request: 'https://github.com/acme/widgets/pull/44' }));
}
// Both local notifications and Fleet polling update the open compact rows.
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
for (const machine of [hostname(), 'remote']) {
const base = page.locator(`.command[data-machine="${machine}"][data-command*="fix-base.command.json"] .row-head`);
await base.getByRole('link', { name: 'PR #44', exact: true }).waitFor();
assert.equal(await base.getByText('Base branch release/2026.11', { exact: true }).isVisible(), true);
}
}
});
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.

Live Fix runs and Review runs show a clickable **PR #number** beside their repository and run type in compact Working rows, including Overview, on desktop and phone. Explicit targets and Pass-selected targets use the same presentation for local and Fleet Commands. A Fix Pass repairing Base branch CI shows the exact selected **Base branch** before a PR exists, and keeps that branch visible alongside the PR link once published. Older records without a recorded target keep their repository and Command label.

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.
Expand Down
1 change: 1 addition & 0 deletions site/architecture/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ <h1>Architecture</h1>
<p>The Inbox's batched pull request read also captures titles and the latest head commit's combined checks and status. Ready for review subjects link to the recorded PR and show its cached title, including Review runs without an issue. Missing titles keep the PR link with “(title unavailable)”; rows without a PR use their issue or Command subject. Title changes wake open pages without a network read during rendering. Ready for review rows show CI green, red, pending, no checks or unknown, using the same saved answer as <code>status</code>. CI changes wake open pages without changing the row's Ready state; failed reads keep the last answer with the existing freshness warning.</p>
<p>The same query captures pull request mergeability independently of head CI. A conflicting pull request stays Ready for review with a separate <strong>Merge conflict</strong> pill beside CI, even when CI is green. Mergeability changes also wake open pages; unknown answers show no conflict pill. <code>status --json</code> includes <code>github_mergeable</code>, and <code>show --json</code> reads the locally kept GitHub facts and observation times without refreshing them over the network.</p>
<p>The Console reads ready rows' pull requests and Needs you rows' issues and pull requests in a batched GraphQL query in the background once a minute, with blocking <code>gh</code> calls off its async thread. It keeps one answer per machine beside the logs. A changed answer or warning wakes open Inbox pages through the file watcher; unchanged reads update freshness without rendering or waking pages. <code>thirdshift status</code> uses that answer and reads GitHub itself only when it is missing or five minutes old. Failed reads retain the last facts and failure; after five minutes without a good read, both readers warn with when GitHub last answered and why the latest read failed. Both share a quota gate: fewer than 500 GraphQL points or REST requests holds off all their reads until reset, with a warning, preserving the rest for the factory. These limits and timings are not User config settings. An ended Needs you row moves to Done, Resolved or History when its issue closes or its pull request merges; a Triage row also moves when <code>needs-triage</code> is removed. Architect and Security runs use their dispatched Spec run's or Ticket run's issue and pull request. Live Silent and Slow rows stay until their Commands end. Ready rows leave on a merged or closed pull request. Resolved counts from the settlement time, and only rows GitHub cannot speak for age out while still waiting. Reopening brings back rows still in Done or Resolved. Rows in History retain the last facts without further reads, including when another row shares their issue or pull request. Dismissals hold across GitHub changes, and Undo uses the latest kept facts. These reads never write to GitHub (<a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0023-the-inbox-reads-github-to-learn-the-day-shift-acted.md">ADR-0023</a>).</p>
<p>Local and Fleet Commands share the compact Working-row presentation, including Overview: live Fix and Review runs show a clickable <strong>PR #number</strong> beside the repository and run type on desktop and phone, for explicit and Pass-selected targets. Base branch CI repairs retain an optional <code>base_branch</code> in the Command record before the session starts. The row shows that exact selection before a PR exists and keeps it alongside the PR link once recorded, without deriving it from the repair branch or requesting GitHub. Older records keep their existing fallback.</p>
<p>Security fix dispatch stores advisory metadata in the dispatched Command record’s <code>security_fix_findings</code> list before launch checks. Command details links each advisory with its title and severity (or “ungraded”), and the parent Security run marks the advisories it dispatched a fix for using that same list. Failed and interrupted fixes retain their links. The list is also exposed by <code>thirdshift show &lt;command&gt; --json</code>. Older records need no migration and no advisory links are inferred from issue bodies.</p>
<p>Any signed-in account can use <a href="/docs/#dismissing-a-row">Dismiss</a> on the row in Needs you or Ready for review, always visible beside Copy where available. With JavaScript, <code>d</code> dismisses the selected row and selects the next; text fields and open dialogs keep their keys. An inline dismissal shows a temporary notice with Undo for that Command, outside the live Inbox so row replacement cannot remove it. The notice stays while hovered, focused, saving or showing an error. Without JavaScript, rows keep their full Dismiss form. Command details also offers Dismiss and Undo for a dismissed row. Command details shows each CLI form: <code>thirdshift dismiss &lt;command&gt;</code> or <code>thirdshift dismiss &lt;command&gt; --undo</code>. Both use the Console's session and CSRF checks. The browser keeps pending feedback and disables duplicate submissions through live updates; failed requests show their error and allow a retry. The dismissal lives beside the Command record and changes only the row's group; filesystem notifications update open pages, and the Activity log names the account and the Console. A live Command keeps running, and nothing is written to GitHub.</p>
<p>A dismissal accumulates covered kinds for the whole row, across child Ticket runs, and <code>status --json</code> exposes them as <code>dismissal.kinds</code>. An uncovered kind brings the row back, including a Finding behind a dismissed Security failure only while GitHub still shows its findings waiting. Each read adds one paginated REST list per repository with a Security run whose findings were waiting, respecting the quota reserve and using the Security run’s private-storage selection. The kept answer holds only finding URLs and waiting flags, with the first observed triage time; it retains no finding title, severity or write-up. A Finding leaves saying “findings triaged” once its records no longer wait: a draft advisory without severity or an open <code>security-finding</code> issue labelled <code>needs-triage</code> waits, and a record closed by a merged fix does not.</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>Live Fix runs and Review runs show a clickable <strong>PR #number</strong> beside their repository and run type in compact Working rows, including Overview, on desktop and phone. Explicit targets and Pass-selected targets use the same presentation for local and Fleet Commands. A Fix Pass repairing Base branch CI shows the exact selected <strong>Base branch</strong> before a PR exists, and keeps that branch visible alongside the PR link once published. Older records without a recorded target keep their repository and Command label.</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>
Expand Down
Loading