diff --git a/console/README.md b/console/README.md index 2fd102b8..e3fe47f6 100644 --- a/console/README.md +++ b/console/README.md @@ -189,8 +189,12 @@ command, without opening Command details. If clipboard access is unavailable, a dialog selects the same text for copying by keyboard. The dialog keeps that snapshot through live updates. -Dismiss is in Command details, and on Needs you and Ready for review rows -without JavaScript. Undo remains in Command details. Both immediately +Dismiss is always visible on the row in Needs you and Ready for review, +beside Copy where available. `d` dismisses the selected row and selects the next +one, without opening Command details. An inline dismissal shows a temporary +`Dismissed · Undo` notice; Undo restores that Command's row. Notices +stay while hovered, focused, saving or showing an error. Command details also +keeps Dismiss and Undo. Both immediately show “Dismissing…” or “Taking back dismissal…” while the request is pending. The button stays disabled through live updates to prevent duplicate submissions. A failed request shows @@ -409,7 +413,8 @@ Experiment keeps its fixed Eval tasks on resume. `j` / `k` select headings and visible rows, wrapping at either end; on the Inbox, open Command details follow the selection. `Space` toggles a row's -peek, or folds a heading; `Enter` opens that row's full Command log. `z` folds +peek, or folds a heading; `Enter` opens that row's full Command log. `d` dismisses +a selected dismissible row and selects the next row. `z` folds the current History or Security 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, `b` Lanes, `t` Stages, `f` Waterfall, `h` History, `o` Logs, `s` Security, `j` Cron jobs, diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 6858ee93..36d2bd5d 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -101,6 +101,123 @@ async function showDetails(page, row) { await row.locator(':scope > .row-head > .state').click(); } +test('inline Dismiss leaves Needs you without opening details and offers Undo on a phone', async t => { + const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); + await page.goto(origin); + const waiting = '#inbox > [data-group="needs you"] .command'; + const row = page.locator(waiting).first(); + const command = await row.getAttribute('data-command'); + const subject = await row.locator(':scope > .row-head > .subject').textContent(); + const count = await page.locator(waiting).count(); + await row.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.waitForFunction(({ waiting, count }) => document.querySelectorAll(waiting).length === count - 1, { waiting, count }); + assert.equal(await page.locator('#peek').isVisible(), false); + const notice = page.locator('#dismissal-notices > [role="status"]').filter({ hasText: `Dismissed ${subject}` }); + await notice.waitFor(); + assert.equal(await notice.locator('[name="command"]').inputValue(), command); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await notice.getByRole('button', { name: 'Undo', exact: true }).click(); + await page.waitForFunction(({ waiting, count }) => document.querySelectorAll(waiting).length === count, { waiting, count }); + await notice.waitFor({ state: 'detached' }); + assert.equal(await page.locator('#peek').isVisible(), false); +}); + +test('j then d dismisses and selects the next row, respecting typing, dialogs and modifiers', async t => { + const page = await pageFor(t); + await page.goto(origin); + const rows = page.locator('#inbox > [data-group="needs you"] .command'); + const count = await rows.count(); + const next = await rows.nth(1).getAttribute('data-command'); + await page.keyboard.press('j'); + const command = await page.locator('.command[aria-current="true"]').getAttribute('data-command'); + for (const key of ['Control+d', 'Meta+d', 'Alt+d', 'Shift+d']) await page.keyboard.press(key); + await page.keyboard.press('?'); + await page.locator('#keys[open]').waitFor(); + assert.equal(await page.locator('#keys dt').getByText('d', { exact: true }).count(), 1); + await page.keyboard.press('d'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Shift+T'); + await page.locator('#themes[open]').waitFor(); + await page.getByRole('button', { name: 'Make a Theme from this' }).click(); + await page.waitForFunction(() => document.querySelector('#theme-document').value.includes('"tokens"')); + await page.locator('#theme-document').fill('typing '); + await page.locator('#theme-document').press('d'); + assert.equal(await page.locator('#theme-document').inputValue(), 'typing d'); + await page.getByRole('button', { name: 'Close Themes' }).click(); + assert.equal(await rows.count(), count); + await rows.first().focus(); + await page.keyboard.press('d'); + await page.waitForFunction(next => document.querySelector('.command[aria-current="true"]')?.dataset.command === next, next); + await page.waitForFunction(count => document.querySelectorAll('#inbox > [data-group="needs you"] .command').length === count - 1, count); + assert.equal(await page.locator('#peek').isVisible(), false); + await folder(page, 'done').click(); + const doneRow = page.locator(`#inbox > [data-group="done"] .command[data-command="${command}"]`); + await doneRow.focus(); + assert.equal(await doneRow.getByRole('button', { name: 'Dismiss', exact: true }).count(), 0); + await page.keyboard.press('d'); + assert.equal(await page.locator('#dismissal-notices form').count(), 1); + await page.locator('#dismissal-notices').getByRole('button', { name: 'Undo', exact: true }).click(); + await folder(page, 'needs you').click(); + await page.waitForFunction(count => document.querySelectorAll('#inbox > [data-group="needs you"] .command').length === count, count); +}); + +test('inline Dismiss and notice Undo keep pending and error feedback through live updates and retry', async t => { + const page = await pageFor(t); + await page.clock.install(); + await page.goto(origin); + const row = page.locator('#inbox > [data-group="needs you"] .command').first(); + await showDetails(page, row); + const form = row.locator(':scope > .row-head form[data-dismiss]'); + const recordPath = await form.locator('[name="command"]').inputValue(); + const original = await readFile(recordPath, 'utf8'); + await page.route(`${origin}/dismiss`, route => route.fulfill({ status: 409, body: 'Please retry Dismiss' })); + try { + await form.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await form.getByText('Could not save the dismissal: Please retry Dismiss', { exact: true }).waitFor(); + assert.equal(await form.locator('button').isEnabled(), true); + assert.equal(await page.locator('#dismissal-notices form').count(), 0); + await page.unrouteAll({ behavior: 'wait' }); + await form.getByRole('button', { name: 'Dismiss', exact: true }).click(); + const notice = page.locator('#dismissal-notices > [role="status"]'); + await notice.waitFor(); + await page.locator('#peek-content').getByRole('button', { name: 'Undo', exact: true }).waitFor(); + await page.route(`${origin}/dismiss/undo`, route => route.fulfill({ status: 409, body: 'Please retry Undo' })); + await notice.getByRole('button', { name: 'Undo', exact: true }).click(); + await notice.getByText('Could not save the dismissal: Please retry Undo', { exact: true }).waitFor(); + await page.mouse.move(0, 0); + await page.clock.fastForward(30000); + assert.equal(await notice.isVisible(), true); + assert.equal(await notice.locator('button').isEnabled(), true); + await page.unrouteAll({ behavior: 'wait' }); + let release; + const held = new Promise(resolve => { release = resolve; }); + let requests = 0; + await page.route(`${origin}/dismiss/undo`, async route => { + requests++; + await held; + await route.continue(); + }); + try { + await notice.getByRole('button', { name: 'Undo', exact: true }).click(); + await notice.getByText('Taking back dismissal…', { exact: true }).waitFor(); + assert.equal(await notice.locator('button').isDisabled(), true); + const updated = JSON.parse(original); + updated.cause = 'Live refresh while notice Undo is pending'; + await replaceRecord(recordPath, JSON.stringify(updated)); + await page.locator('#peek-content').getByText(updated.cause, { exact: true }).waitFor(); + assert.equal(await notice.locator('button').isDisabled(), true); + await notice.locator('form').evaluate(form => form.requestSubmit()); + release(); + await notice.waitFor({ state: 'detached' }); + await page.locator('#peek-content').getByRole('button', { name: 'Dismiss', exact: true }).waitFor(); + assert.equal(requests, 1); + } finally { release(); } + } finally { + await page.unrouteAll({ behavior: 'wait' }); + await replaceRecord(recordPath, original); + } +}); + test('the Inbox opens with Details closed, and Dismiss works by keyboard from Details beside a clicked row', async t => { const page = await pageFor(t); const observer = await pageFor(t); @@ -127,35 +244,55 @@ test('the Inbox opens with Details closed, and Dismiss works by keyboard from De }); for (const [outcome, group] of [['failed', 'needs you'], ['ready for review', 'ready for review']]) { - test(`Inbox row Dismiss works in ${group} without JavaScript on a phone`, async t => { - const directory = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); - const recordPath = join(directory, '223-fixture.command.json'); - const fixture = JSON.parse(await readFile(join(directory, '1-fixture.command.json'), 'utf8')); - fixture.issue = { number: 223, url: 'https://github.com/acme/widgets/issues/223', title: 'Dismiss from the Inbox' }; - fixture.outcome = outcome; - fixture.cause = null; - fixture.pull_request = outcome === 'ready for review' ? 'https://github.com/acme/widgets/pull/224' : null; - await writeFile(recordPath, JSON.stringify(fixture)); - t.after(async () => { - await rm(recordPath, { force: true }); - await rm(join(directory, '223-fixture.command.dismiss.json'), { force: true }); + for (const javaScriptEnabled of [false, true]) { + test(`Inbox row Dismiss works in ${group} ${javaScriptEnabled ? 'with' : 'without'} JavaScript on a phone`, async t => { + const directory = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const recordPath = join(directory, '223-fixture.command.json'); + const fixture = JSON.parse(await readFile(join(directory, '1-fixture.command.json'), 'utf8')); + fixture.issue = { number: 223, url: 'https://github.com/acme/widgets/issues/223', title: 'Dismiss from the Inbox' }; + fixture.outcome = outcome; + fixture.cause = null; + fixture.pull_request = outcome === 'ready for review' ? 'https://github.com/acme/widgets/pull/224' : null; + await writeFile(recordPath, JSON.stringify(fixture)); + t.after(async () => { + await rm(recordPath, { force: true }); + await rm(join(directory, '223-fixture.command.dismiss.json'), { force: true }); + }); + const page = await pageFor(t, { javaScriptEnabled, viewport: { width: 390, height: 844 } }); + if (javaScriptEnabled) await page.clock.install(); + await page.goto(`${origin}/?page=inbox`); + const section = page.locator(`#inbox > [data-group="${group}"]`); + if (group === 'ready for review') await folder(page, group).click(); + const row = section.locator('.command').filter({ has: page.locator('.subject a[href$="/issues/223"]') }); + await row.getByRole('button', { name: 'Dismiss', exact: true }).click(); + if (javaScriptEnabled) { + await row.waitFor({ state: 'detached' }); + const notice = page.locator('#dismissal-notices > [role="status"]'); + await notice.waitFor(); + await notice.getByRole('button', { name: 'Undo', exact: true }).click(); + await row.waitFor(); + await row.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await row.waitFor({ state: 'detached' }); + await page.waitForFunction(() => !document.querySelector('#dismissal-notices button').disabled); + await page.mouse.move(0, 0); + await page.clock.fastForward(10001); + await notice.waitFor({ state: 'detached' }); + } else await page.waitForURL(`${origin}/`); + assert.equal(await section.locator('.subject a[href$="/issues/223"]').count(), 0); + assert.equal(await page.locator('#peek').isVisible(), false); + await folder(page, 'done').click(); + const dismissed = page.locator('#inbox > [data-group="done"] .command').filter({ has: page.locator('.subject a[href$="/issues/223"]') }); + assert.equal(await dismissed.getByRole('button', { name: 'Dismiss', exact: true }).count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + if (javaScriptEnabled) { + await showDetails(page, dismissed); + await page.locator('#peek-content').getByRole('button', { name: 'Undo', exact: true }).waitFor(); + } else { + await dismissed.locator('.peek-link').click(); + await page.getByRole('button', { name: 'Undo', exact: true }).waitFor(); + } }); - const page = await pageFor(t, { javaScriptEnabled: false, viewport: { width: 390, height: 844 } }); - await page.goto(`${origin}/?page=inbox`); - const section = page.locator(`#inbox > [data-group="${group}"]`); - if (group === 'ready for review') await folder(page, group).click(); - const row = section.locator('.command').filter({ has: page.locator('.subject a[href$="/issues/223"]') }); - await row.getByRole('button', { name: 'Dismiss', exact: true }).click(); - await page.waitForURL(`${origin}/`); - assert.equal(await section.locator('.subject a[href$="/issues/223"]').count(), 0); - assert.equal(await page.locator('#peek').isVisible(), false); - await folder(page, 'done').click(); - const dismissed = page.locator('#inbox > [data-group="done"] .command').filter({ has: page.locator('.subject a[href$="/issues/223"]') }); - assert.equal(await dismissed.getByRole('button', { name: 'Dismiss', exact: true }).count(), 0); - assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); - await dismissed.locator('.peek-link').click(); - await page.getByRole('button', { name: 'Undo', exact: true }).waitFor(); - }); + } } test('Dismiss keeps Undo in Command details and updates another open Inbox', async t => { diff --git a/docs/guide.md b/docs/guide.md index 10312364..670bbe59 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1172,11 +1172,15 @@ Command, its issue, labels, Claim and pull request stay as they are, and nothing is written to GitHub. The Activity log records each dismissal and each one taken back, naming the Unix user and the CLI. `dismiss --json` reports what it did. -In the Console, any signed-in account can click **Dismiss** in a waiting row's -Command details, beside Stop when available, or on the row itself when -JavaScript is off. One click -dismisses, with no confirmation page. A dismissed row offers **Undo** in Command -details. While either request is pending, its buttons show “Dismissing…” or +In the Console, any signed-in account can click **Dismiss** on the row in Needs +you or Ready for review, or in Command details beside Stop when available. +With JavaScript, `d` dismisses the selected row and selects the next row; text +fields and open dialogs keep their keys. An inline dismissal shows a temporary +`Dismissed · Undo` notice. Undo restores that Command's row. Notices +stay while hovered, focused, saving or showing an error. Without JavaScript, +rows retain their full Dismiss form. One click dismisses, with no confirmation +page. A dismissed row also offers **Undo** in Command details. +While either request is pending, its buttons show “Dismissing…” or “Taking back dismissal…” and stay disabled, including through live updates. A failed request shows the error and lets you retry. Command details shows each button's CLI form beside it. Both posts require a session and a valid CSRF token, open diff --git a/site/architecture/index.html b/site/architecture/index.html index 3de98c5d..33aed46d 100644 --- a/site/architecture/index.html +++ b/site/architecture/index.html @@ -200,7 +200,7 @@

Architecture

The Console reads this machine's Commands and opens on its Inbox. Filesystem changes wake its Inbox and log readers; process exits and elapsed-time boundaries have their own wakeups. A slow recovery check catches missed notifications and expired sessions. A signed-in account can Stop or Requeue a live Command. It does nothing the CLI can't do and starts no work (ADR-0016).

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 gh 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. thirdshift status 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 needs-triage 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 (ADR-0023).

-

Command details, and Inbox rows when JavaScript is off, offer any signed-in account Dismiss for a row in Needs you or Ready for review, and Command details offers Undo for a dismissed row. Command details shows each CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. 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.

+

Any signed-in account can use Dismiss on the row in Needs you or Ready for review, always visible beside Copy where available. With JavaScript, d 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: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. 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.

A dismissal accumulates covered kinds for the whole row, across child Ticket runs, and status --json exposes them as dismissal.kinds. 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 security-finding issue labelled needs-triage waits, and a record closed by a merged fix does not.

status --json exposes each row's kept issue, pull request and finding facts with their observation times in github. History freezes those times alongside the facts. Unchanged reads update observation times without waking pages. A dismissed row's Resolved clock stays anchored to its dismissal even if GitHub settles it later.

Before each agent session, optional host resource limits delay its start while available memory is too low or one-minute CPU load is too high. thirdshift resources and the Console's Resources page show the current snapshot and configured limits. Existing sessions continue.

diff --git a/site/docs/index.html b/site/docs/index.html index 0fa0ab0a..9b910529 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -1033,11 +1033,15 @@ Command, its issue, labels, Claim and pull request stay as they are, and nothing is written to GitHub. The Activity log records each dismissal and each one taken back, naming the Unix user and the CLI. dismiss --json reports what it did.

-

In the Console, any signed-in account can click Dismiss in a waiting row's -Command details, beside Stop when available, or on the row itself when -JavaScript is off. One click -dismisses, with no confirmation page. A dismissed row offers Undo in Command -details. While either request is pending, its buttons show “Dismissing…” or +

In the Console, any signed-in account can click Dismiss on the row in Needs +you or Ready for review, or in Command details beside Stop when available. +With JavaScript, d dismisses the selected row and selects the next row; text +fields and open dialogs keep their keys. An inline dismissal shows a temporary +Dismissed <subject> · Undo notice. Undo restores that Command's row. Notices +stay while hovered, focused, saving or showing an error. Without JavaScript, +rows retain their full Dismiss form. One click dismisses, with no confirmation +page. A dismissed row also offers Undo in Command details. +While either request is pending, its buttons show “Dismissing…” or “Taking back dismissal…” and stay disabled, including through live updates. A failed request shows the error and lets you retry. Command details shows each button's CLI form beside it. Both posts require a session and a valid CSRF token, open diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 3dd83d60..d5c0be12 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -177,7 +177,7 @@

How thirdshift works

thirdshift stop <command>
 thirdshift stop <command> --requeue

Stop waits for cleanup and leaves the issue for the Day shift, labelled needs-triage. With --requeue, it releases the Claim and puts ready-for-agent back so Pickup can continue the work. Stopping a Command.

-

A row in Needs you or Ready for review that asks nothing more can be Dismissed instead, live ones included. Any signed-in Console account can click Dismiss in Command details, beside Stop when available, or on the Inbox row itself without JavaScript, with no confirmation page. A dismissed row offers Undo there. Both buttons immediately show pending feedback and stay disabled while saving, including through live updates; a failed request shows its error and lets you retry. The CLI forms appear beside the buttons in Command details: thirdshift dismiss <command> and thirdshift dismiss <command> --undo. Every open page updates, and the Activity log names the account's email and the Console. Nothing else changes: the Command keeps running and nothing is written to GitHub. Dismissing a row.

+

A row in Needs you or Ready for review that asks nothing more can be Dismissed instead, live ones included. Any signed-in Console account can click Dismiss on the row, always visible beside Copy where available, or in Command details beside Stop when available, with no confirmation page. With JavaScript, d dismisses the selected row and selects the next; text fields and open dialogs keep their keys. An inline dismissal shows a temporary notice, “Dismissed <subject> · Undo”, whose Undo restores that Command’s row. The notice stays while hovered, focused, saving or showing an error. Without JavaScript, rows keep their full Dismiss form. A dismissed row also offers Undo in Command details. Both buttons immediately show pending feedback and stay disabled while saving, including through live updates; a failed request shows its error and lets you retry. The CLI forms appear beside the buttons in Command details: thirdshift dismiss <command> and thirdshift dismiss <command> --undo. Every open page updates, and the Activity log names the account's email and the Console. Nothing else changes: the Command keeps running and nothing is written to GitHub. Dismissing a row.

Slow becoming Silent, Silent becoming Slow, or a live Command dying, failing or ending ready brings a dismissed row back. A dismissed Security failure returns as a Finding only while GitHub still shows its findings waiting. One paginated REST list per repository with such a Security run reads the private records, respecting the quota reserve. A Finding leaves saying “findings triaged” once all its records are triaged: draft advisories without severity and open security-finding issues labelled needs-triage still wait; records closed by a merged fix do not. The kept answer holds waiting flags, never titles, severities or write-ups. Dismissing again adds the new kind; another Ticket going Silent in the same Spec run does not repeat a kind already dismissed.

The Command log records the whole command; a Session trace records each agent session, and the Activity log records starts and outcomes per repository. Export a Command's whole Trace for another tool with thirdshift logs <command> --export <dir>. Optional email notifications report how the Ticket run ended. Inspecting logs and exporting Traces.

diff --git a/site/index.html b/site/index.html index 9fefc532..eeae1c0d 100644 --- a/site/index.html +++ b/site/index.html @@ -355,7 +355,7 @@

Six workflows

A Pickup run takes the lowest-numbered Ready issue by default, the highest-numbered with pickup.order = "highest-first" or highest-first, after it has settled and while the repository is below its Claim limit. It also files one ordinary bug Ticket when the Base branch's latest push CI has stayed red for more than 30 minutes. Failed work stays with the Day shift until you hand it back by applying ready-for-agent again.

A Security run audits the Base branch and records findings privately. It is report-only by default. With security-fix, it can fix a reproduced finding through a Ticket run or Spec run. With security-close, it can close a finding its fix merged, once its proof-of-concept test passes against the merged Base branch. An optional Security review checks each Ticket run's change, including Tickets and the Spec PR, and holds Self-merge if introduced findings remain unaddressed or the review is incomplete. Its Harness is checked before work; an unavailable review Harness falls back to the default Harness, with one fallback also allowed for a Harness error before its first turn.

Start passes with thirdshift architect base main, thirdshift pickup base main or thirdshift secure base main. Your operating system schedules them. thirdshift status, thirdshift cronjobs and the Console show when each repository's Pass last fired, including repeated skips. Architect runs every few minutes and Security runs on a schedule, with fixing allowed. Both yield to work the Day shift shaped, and pause when a decision needs you.

-

The Console opens on this machine's Inbox: Commands grouped by what needs you, with their stages and logs. Like a mailbox, each group is a folder beside one-line rows, and a clicked row's Command details open alongside. It reads ready rows' pull requests and Needs you rows' issues and pull requests from GitHub in the background once a minute; changed answers update open pages automatically. thirdshift status uses the same kept answer. Failed reads keep the last facts; after five minutes, both show when GitHub last answered and why the latest read failed. With fewer than 500 GraphQL points or REST requests left, both wait for the quota to reset, leaving the rest to the factory. An ended Needs you row leaves when GitHub shows its issue closed or its pull request merged; a Triage row also leaves when needs-triage is removed. A Finding leaves with “findings triaged” once all its private records are triaged on GitHub. A draft advisory without severity, or an open security-finding issue labelled needs-triage, still waits; a finding closed by its merged fix does not. Live Silent and Slow rows wait until their Commands end. A signed-in account can Stop or Requeue a live Command, just as from the CLI. In Command details, Dismiss gives a row in Needs you or Ready for review a Dismissal in one click; Undo takes it back. Command details shows each CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. A dismissed row returns for a new kind of problem or when it ends ready for review; each kind counts once for the whole row.

+

The Console opens on this machine's Inbox: Commands grouped by what needs you, with their stages and logs. Like a mailbox, each group is a folder beside one-line rows, and a clicked row's Command details open alongside. It reads ready rows' pull requests and Needs you rows' issues and pull requests from GitHub in the background once a minute; changed answers update open pages automatically. thirdshift status uses the same kept answer. Failed reads keep the last facts; after five minutes, both show when GitHub last answered and why the latest read failed. With fewer than 500 GraphQL points or REST requests left, both wait for the quota to reset, leaving the rest to the factory. An ended Needs you row leaves when GitHub shows its issue closed or its pull request merged; a Triage row also leaves when needs-triage is removed. A Finding leaves with “findings triaged” once all its private records are triaged on GitHub. A draft advisory without severity, or an open security-finding issue labelled needs-triage, still waits; a finding closed by its merged fix does not. Live Silent and Slow rows wait until their Commands end. A signed-in account can Stop or Requeue a live Command, just as from the CLI. Dismiss is always visible on the row in Needs you or Ready for review, beside Copy where available; d dismisses the selected row and selects the next. An inline Dismissal shows a temporary notice with Undo; Command details also keeps Dismiss and Undo. Command details shows each CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. A dismissed row returns for a new kind of problem or when it ends ready for review; each kind counts once for the whole row.

Above its groups, the Inbox counts the runs active on this machine. The Console's Resources page, also available with thirdshift resources, shows host memory, CPU load and the User config limits that can delay new sessions.

Experiments compare a Candidate with a Baseline on private Eval suites. thirdshift experiment <candidate> builds both Arms and runs interleaved Trials with two workers, a fixed Bar and resume after interruption or a plan-limit warning. Trials and Graders run in bwrap sandboxes. Code Graders score mechanical checks offline from report packets and the prepared repository, then fresh model Graders score written criteria from blind packets with provider-only access. Graders cannot read raw Trial logs, Arm mappings or the Experiment’s other files. Results show the Verdict, wrong findings, borderline marks, paired intervals, each Arm's minutes, tokens by Model, reported cost and Void causes. Trials and Graders join the Command's Trace. Void Trials retry fresh up to twice; an exhausted task drops out of both Arms. More than a tenth dropped makes the Verdict inconclusive.

Experiments, including release Experiments, report approved Eval tasks missing Trial inputs before building either Arm. Before each Trial's session, the host prepares its repository's pinned Rust toolchain and listed components, sharing preparation across Trials with the same pin. An installation failure makes the Trial void with session_unavailable. Spec review Trials keep the production prompt and grade only the reports; their sandbox blocks gh calls and provides no PR service.

diff --git a/site/tests/pages.test.cjs b/site/tests/pages.test.cjs index 69571eda..82371fa1 100644 --- a/site/tests/pages.test.cjs +++ b/site/tests/pages.test.cjs @@ -93,6 +93,18 @@ async function unfoldGuide(page) { } } +test('Console documentation explains row Dismiss, d and the Undo notice', async t => { + const page = await pageForTest(t, { javaScriptEnabled: false }); + for (const path of ['/', '/how-it-works/', '/architecture/', '/docs/']) { + await page.goto(`${origin}${path}`); + const text = await page.locator('main p').filter({ hasText: /[Ii]nline [Dd]ismissal/ }).textContent(); + assert.match(text, /Dismiss[^.]*on (?:the |its |each )?row/); + assert.match(text, /\bd\b[^.]*dismiss/i); + assert.match(text, /notice/); + assert.match(text, /Undo/); + } +}); + test("the guide starts with a folded overview and its sections toggle independently without JavaScript", async (t) => { const page = await pageForTest(t, { javaScriptEnabled: false }); await page.goto(`${origin}/how-it-works/`); diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 4cd723ff..361304dd 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -58,6 +58,12 @@ details[data-fact] > dl { margin: 4px 0 6px; } .command p { margin: 4px 0 0; } .row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } .row-actions form { margin: 4px 0 0; } +.row-controls, .row-controls > .copy { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } +.row-controls form { margin: 0; gap: 6px; } +.row-controls [role="status"]:empty { display: none; } +#dismissal-notices { position: fixed; bottom: 12px; right: 12px; z-index: 6; max-width: min(32em, calc(100vw - 24px)); max-height: 40vh; overflow-y: auto; } +#dismissal-notices > div { padding: 10px; margin-top: 6px; border: 1px solid var(--rule); border-radius: var(--radius); background: var(--panel); color: var(--text); overflow-wrap: anywhere; } +#dismissal-notices form { display: inline-flex; margin: 0; align-items: baseline; gap: 6px; } .now { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .command[data-state="Died"], .command[data-state="Failed"], .command[data-state="Silent"], .command[data-state="Finding"], .command[data-state="Triage"] { background: var(--panel); border-left: 3px solid var(--urgent); color: var(--urgent-text); } .command[data-state="Slow"] { border-left: 3px solid var(--look); color: var(--look-text); } @@ -219,8 +225,8 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co .one-line .row-head > .new::before { content: "New"; font-size: 12px; color: var(--head); font-weight: var(--head-weight); } .one-line .row-head > .age { grid-column: 6; margin: 0; font-size: 12px; } .one-line .row-head .finish { display: none; } - .one-line .row-head > .copy { grid-column: 7; display: flex; align-items: baseline; gap: 6px; overflow: visible; } - .one-line .row-head > .copy button { padding: 0 8px; font-size: 12px; } + .one-line .row-head > .row-controls { grid-column: 7; white-space: normal; overflow: visible; } + .one-line .row-head > .row-controls button { padding: 0 8px; font-size: 12px; } .one-line .row-head [data-copy-status] { font-size: 12px; } .one-line .row-head [data-copy-status]:empty { display: none; } } @@ -232,7 +238,7 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co .one-line .row-head { grid-template-columns: auto minmax(0, 1fr) auto; row-gap: 2px; } .one-line .row-head > .new { display: none; } .one-line .row-head > .age { grid-column: 3; grid-row: 1; } - .one-line .row-head > .copy { grid-column: 3; grid-row: 2; justify-self: end; } + .one-line .row-head > .row-controls { grid-column: 3; grid-row: 2; justify-self: end; } .one-line .row-head > .issue-title { grid-column: 1 / 3; grid-row: 2; } .one-line .row-head > .gist { display: none; } .one-line .row-head > .issue-title:empty + .gist { display: block; grid-column: 1 / 3; grid-row: 2; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 6bee94f8..7ee6f57f 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -206,6 +206,39 @@ function showDismissal() { } } +function removeDismissalNotice(command) { + for (const form of document.querySelectorAll('#dismissal-notices form')) { + if (form.elements.command.value === command) form.parentElement.remove(); + } +} + +function dismissalNotice(form, subject) { + const command = form.elements.command.value; + removeDismissalNotice(command); + const notices = document.querySelector('#dismissal-notices'); + const notice = document.createElement('div'); + notice.setAttribute('role', 'status'); + const undo = document.createElement('form'); + undo.method = 'post'; + undo.action = '/dismiss/undo'; + undo.dataset.dismiss = ''; + for (const name of ['command', 'csrf']) undo.append(form.elements[name].cloneNode(true)); + const button = document.createElement('button'); + button.textContent = 'Undo'; + const status = document.createElement('span'); + status.setAttribute('role', 'status'); + undo.append(button, status); + notices.append(notice); + notice.append(`Dismissed ${subject} · `, undo); + // Keep Undo reachable while someone is using it, saving it, or retrying it. + function expire() { + if (!notice.isConnected) return; + if (notice.matches(':hover, :focus-within') || dismissals.has(command)) setTimeout(expire, 10000); + else notice.remove(); + } + setTimeout(expire, 10000); +} + function initializeSpanTree() { return initSpanTree({ openDetails() { peekCommand = null; @@ -463,6 +496,10 @@ document.addEventListener('submit', async event => { const command = form.elements.command.value; if (dismissals.get(command)?.pending) return; const undo = new URL(form.action).pathname === '/dismiss/undo'; + const row = form.closest('.one-line .command'); + const subject = row?.querySelector(':scope > .row-head > .subject').textContent; + const visible = row ? items().filter(item => item.matches('.command')) : []; + const next = visible[(visible.indexOf(row) + 1) % visible.length]; const state = { action: form.action, pending: true, message: undo ? 'Taking back dismissal…' : 'Dismissing…' @@ -476,6 +513,15 @@ document.addEventListener('submit', async event => { }); if (!response.ok) throw new Error(await response.text()); saved = true; + if (row && !undo) { + dismissalNotice(form, subject); + if (selected?.dataset.command === command) { + const nextRow = next !== row ? document.getElementById(next?.id) : null; + select(nextRow); + nextRow?.focus({ preventScroll: true }); + } + } + if (undo) removeDismissalNotice(command); pendingPeek?.abort(); pendingPeek = null; await refresh(); @@ -544,6 +590,11 @@ document.addEventListener('keydown', event => { : (index + (event.key === 'j' ? 1 : -1) + visible.length) % visible.length; select(visible[next] ?? null); if (!peek.hidden && selected?.matches('.one-line .command')) showPeek(selected); + } else if (event.key === 'd' && !event.shiftKey && selected) { + const form = selected.querySelector(':scope > .row-head form[data-dismiss]'); + if (!form || !selected.matches('.one-line .command')) return; + event.preventDefault(); + form.requestSubmit(); } else if (event.key === ' ' && selected) { event.preventDefault(); if (selected.tagName === 'SUMMARY') selected.parentElement.open = !selected.parentElement.open; diff --git a/src/console/chrome.html b/src/console/chrome.html index 21ef5706..aad795b3 100644 --- a/src/console/chrome.html +++ b/src/console/chrome.html @@ -1,4 +1,5 @@ +

Needs you information

Clipboard unavailable. Selected; copy it with your keyboard.

@@ -29,6 +30,7 @@

Sections

thirdshift console sections --hide

Keyboard

j / k
Next / previous row or heading, wrapping
+
d
Dismiss the selected row when available, then select the next row
Space
Toggle a row's Details, or fold a heading
Enter
Open the full Command log
Esc
Close one layer, then clear selection
diff --git a/src/console/row.html b/src/console/row.html index 8cd8c9b6..28611e05 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,5 +1,8 @@
  • -
    {% if row.live %}{% endif %}{{ row.state }} {% match row.subject_url %}{% when Some with (url) %}{{ row.subject }}{% when None %}{{ row.subject }}{% endmatch %} {{ row.title }}{{ row.gist()|readable }}{% match row.timing.finish %}{% when Some with (finish) %} · {% when None %}{% endmatch %}{{ row.timing.age }}{% if can_copy %} {% endif %}
    +
    {% if row.live %}{% endif %}{{ row.state }} {% match row.subject_url %}{% when Some with (url) %}{{ row.subject }}{% when None %}{{ row.subject }}{% endmatch %} {{ row.title }}{{ row.gist()|readable }}{% match row.timing.finish %}{% when Some with (finish) %} · {% when None %}{% endmatch %}{{ row.timing.age }} + {% if can_copy || row.can_dismiss %}
    {% if can_copy %} {% endif %} + {% if row.can_dismiss %}
    {% endif %}
    {% endif %} +

    {{ row.now_line|linkify }}

    Command logs and Session traces {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} @@ -7,8 +10,7 @@ {% if row.count > 1 %}

    {{ row.count }} Commands with this cause: {{ row.cause|linkify }}

    {% else if !row.cause.is_empty() %}

    {{ row.cause|linkify }}

    {% endif %} {% if !row.left.is_empty() %}

    {{ row.left|readable }}

    {% endif %} {% if !row.tickets.is_empty() %}
      {% for ticket in row.tickets %}
    • {{ ticket|linkify }}
    • {% endfor %}
    {% endif %} -

    Details · Full log

    - {% if row.can_dismiss %}
    {% endif %}
    + {% if !row.findings.is_empty() %}
      {% for finding in row.findings %}
    • {{ finding.title }} · {% match finding.severity %}{% when Some with (severity) %}{{ severity }}{% when None %}ungraded{% endmatch %} · {{ finding.url|linkify }}
    • {% endfor %}
    {% endif %} {% if !row.sessions.is_empty() %}{% endif %} {% if !row.children.is_empty() %}
      {% for child in row.children %}{{ self.child(child)|safe }}{% endfor %}
    {% endif %}