From 1e284c3c528479268d331410f3f57ea81a2dad67 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:49:34 +0000 Subject: [PATCH 1/2] Add authenticated Console Stop and Requeue (#823) --- console/README.md | 13 ++++ console/tests/inbox.test.cjs | 3 +- console/tests/stop.test.cjs | 107 ++++++++++++++++++++++++++++++ docs/code-navigation.md | 2 +- src/console.rs | 37 +++++++++-- src/console/assets/console.css | 3 + src/console/assets/console.js | 65 +++++++++++++----- src/console/assets/stop.js | 31 +++++++++ src/console/auth.rs | 2 +- src/console/logs.html | 2 +- src/console/peek.html | 6 +- src/console/peek.rs | 15 ++++- src/console/reader.html | 1 + src/console/stop.html | 20 ++++++ src/console/stop.rs | 73 +++++++++++++++++++++ src/logs/recording.rs | 6 +- src/logs/status/inspect.rs | 9 --- src/logs/stop.rs | 73 ++++++++++++++++++--- tests/console/accounts.rs | 3 + tests/console/stop.rs | 116 +++++++++++++++++++++++++++++++++ 20 files changed, 539 insertions(+), 48 deletions(-) create mode 100644 console/tests/stop.test.cjs create mode 100644 src/console/assets/stop.js create mode 100644 src/console/stop.html create mode 100644 src/console/stop.rs create mode 100644 tests/console/stop.rs diff --git a/console/README.md b/console/README.md index 962417d8..b5db7142 100644 --- a/console/README.md +++ b/console/README.md @@ -133,6 +133,19 @@ The Follow newest line checkbox lets you read back while the Session grows. The Playwright log test checks appends, retained DOM identity, folding across batches, incomplete lines, reconnection and following on a phone viewport. +A live Command's Details offers Stop beside its equivalent CLI command. +The confirmation page explains `needs-triage`, offers Requeue instead +(`thirdshift stop --requeue`), and requires the current account's CSRF token. +Both actions use the CLI's Stop, including its process identity checks and +refusal to stop a single Ticket's Run. The Activity log names the Console +and the signed-in email; localhost mode records `localhost (no sign-in)`. +The page shows “still ending” and a stopping bar until cleanup records the +outcome, then lists the branches and pull requests left on `origin`. +It updates through `/events`, with polling if the stream disconnects. +The stopping bar respects reduced motion. HTTP tests use real Commands +with a waiting fake Harness and fake GitHub; Playwright checks confirmation, +cancellation, live controls and pushed completion on a phone. + Claude Edit events include old/new text and show unified diff excerpts. Codex `file_change` events retain only file paths and change kinds, so the reader shows their plain fallback and says the patch is unavailable. The Spec-review diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index caf7e08d..a1629d63 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -166,7 +166,8 @@ test("keyboard selection and the open peek survive an Inbox push", async t => { const fixture = JSON.parse(original); fixture.issue.title = 'Keyboard-selected Command after push'; await writeFile(path, JSON.stringify(fixture)); - await page.getByText(fixture.issue.title, { exact: true }).waitFor(); + await row.locator('.issue-title').filter({ hasText: fixture.issue.title }).waitFor(); + await page.locator('#peek').getByText(fixture.issue.title, { exact: true }).waitFor(); assert.equal(await row.getAttribute('id'), id); assert.equal(await row.getAttribute('aria-current'), 'true'); assert.equal(await page.locator('#peek').isVisible(), true); diff --git a/console/tests/stop.test.cjs b/console/tests/stop.test.cjs new file mode 100644 index 00000000..00e46861 --- /dev/null +++ b/console/tests/stop.test.cjs @@ -0,0 +1,107 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = 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, folder; +async function firstLine(child) { + const lines = createInterface({ input: child.stdout }); + try { return (await once(lines, 'line', { signal: AbortSignal.timeout(10000) }))[0]; } + finally { lines.close(); } +} +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-console-stop-')); + 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'); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], { + env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] + }); + exited = once(server, 'exit'); + origin = await firstLine(server); + 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 }); +}); + +for (const requeue of [false, true]) { + test(`Stop confirms, cancels and streams completion (${requeue ? 'requeue' : 'triage'})`, { skip: process.platform !== 'linux' }, async t => { + // A disposable live process accepts TERM; HTTP tests separately exercise + // real Command cleanup, fake Harness and fake GitHub label changes. + const command = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); console.log('ready'); setInterval(() => {}, 1000)"], { stdio: ['ignore', 'pipe', 'inherit'] }); + const ended = once(command, 'exit'); + t.after(async () => { command.kill('SIGKILL'); await ended; }); + await firstLine(command); + const stat = await readFile(`/proc/${command.pid}/stat`, 'utf8'); + const ticks = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]; + const identity = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${ticks}`; + const path = join(folder, '7-fixture.command.json'); + const request = join(folder, '7-fixture.command.stop.json'); + const record = { + version: 1, kind: 'Run', name: 'acme/widgets#7', repository: 'acme/widgets', hostname: 'fixture', + issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Work to stop' }, + process_id: command.pid, process_start: identity, harness: 'codex', + started_at: new Date().toISOString(), ended_at: null, stages: [], outcome: null + }; + await rm(request, { force: true }); + await writeFile(path, JSON.stringify(record)); + await writeFile(join(folder, '7-fixture.log'), 'Waiting for Stop\n'); + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + // Each existing page can reach the same exact Command's Stop control. + for (const route of ['/', '/lanes', '/stages', '/waterfall', '/history', '/logs', '/security']) { + await page.goto(`${origin}${route}`); + if (route === '/history' || route === '/security') { + // These lists contain ended/Security Commands; use their shared peek + // route directly to verify the live Command's action is available. + await page.goto(`${origin}/?page=waterfall`); + } + if (route === '/') await page.locator('[data-group="working"] > summary').click(); + await page.locator('.peek-link').first().click(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + } + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + await page.getByRole('heading', { name: 'Stop this Command?' }).waitFor(); + assert.match(await page.locator('main').textContent(), /needs-triage/); + await page.getByRole('link', { name: 'Cancel', exact: true }).click(); + assert.equal(await readFile(request, 'utf8').catch(() => null), null); + await page.goto(`${origin}/stop?command=widgets%237`); + const observer = await context.newPage(); + await observer.goto(`${origin}/waterfall`); + await observer.locator('.peek-link').first().click(); + await observer.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + assert.equal((await context.request.post(`${origin}/stop`, { form: { command: 'widgets#7', confirmed: 'yes', ending: 'triage' } })).status(), 403); + await page.getByRole('button', { name: requeue ? 'Requeue instead' : 'Confirm Stop', exact: true }).click(); + await page.getByText('still ending', { exact: true }).waitFor(); + assert.equal(await page.locator('.stopping-bar').evaluate(node => getComputedStyle(node).animationName), 'none'); + assert.equal(await page.getByRole('button', { name: /Stop|Requeue/ }).count(), 0); + const asked = JSON.parse(await readFile(request, 'utf8')); + assert.equal(asked.requeue, requeue); + assert.equal(asked.from_console, true); + await page.evaluate(() => { window.sameDocument = true; }); + record.outcome = 'interrupted'; + record.ended_at = new Date().toISOString(); + record.issue_branch = 'issue-7'; + record.pull_request = 'https://github.com/acme/widgets/pull/10'; + await writeFile(path, JSON.stringify(record)); + await page.getByText('Issue branch left: issue-7', { exact: true }).waitFor(); + await page.getByText('Pull request left: https://github.com/acme/widgets/pull/10', { exact: true }).waitFor(); + await observer.getByRole('button', { name: 'Stop', exact: true }).waitFor({ state: 'hidden' }); + assert.equal(await page.evaluate(() => window.sameDocument), true); + assert.equal(await page.getByText('still ending', { exact: true }).count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.deepEqual(errors, []); + }); +} diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 7ae9c1d7..93c55004 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -27,7 +27,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | 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` | | Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | -| 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 | +| Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — shared request, signaling, waiting and inspection; `src/console/stop.rs` — confirmation and authenticated Console action; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`, `tests/console/stop.rs`, `console/tests/stop.test.cjs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | | Security audit, Security review, Security run | `src/security.rs` and `src/security/` | `tests/security_review.rs`, `tests/security_run.rs` | diff --git a/src/console.rs b/src/console.rs index 67bcea7d..cdf52671 100644 --- a/src/console.rs +++ b/src/console.rs @@ -8,6 +8,7 @@ mod live; mod logs; mod peek; mod restart; +mod stop; mod time; mod waterfall; @@ -52,9 +53,29 @@ enum View { SignOut, SignInPage, SignIn, + StopPage, + Stop, } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/stop", + cli: &[ + "thirdshift stop widgets#7", + "thirdshift stop widgets#7 --requeue", + ], + method: Method::GET, + view: View::StopPage, + }, + PageRoute { + path: "/stop", + cli: &[ + "thirdshift stop widgets#7", + "thirdshift stop widgets#7 --requeue", + ], + method: Method::POST, + view: View::Stop, + }, PageRoute { path: "/waterfall", cli: &[ @@ -246,6 +267,8 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route (View::SignOut, Method::POST) => post(auth::sign_out), (View::SignInPage, Method::GET) => get(auth::sign_in_page), (View::SignIn, Method::POST) => post(auth::sign_in), + (View::StopPage, Method::GET) => get(stop::show), + (View::Stop, Method::POST) => post(stop::submit), _ => unreachable!("unknown Console page method"), }; router = router.route(page.path, handler); @@ -378,7 +401,7 @@ async fn inspect(RawQuery(query): RawQuery) -> Response { let command = query_value(&query, "command"); match tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; - let details = status::inspect::details(&config, &command)?; + let details = crate::logs::stop::inspect(&config, &command)?; Ok(peek::Peek::new(command, details).render()?) }) .await @@ -501,9 +524,13 @@ mod tests { .route("/known", post(|| async {})) .with_state(changes), ); - // Each unique path has one fallback; merging GET and POST at sign-in - // adds only the POST service, not another fallback service. - let fallback = 1; + // Each unique path has one fallback; merged methods share it. + let unique_paths = super::PAGES + .iter() + .map(|page| page.path) + .collect::>() + .len(); + let shared_fallbacks = super::PAGES.len() - unique_paths; let expected = super::PAGES .iter() .map(|page| { @@ -515,7 +542,7 @@ mod tests { }) .sum::() + get_methods - - fallback; + - shared_fallbacks; assert_eq!( registered_methods(router), expected, diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 2c3437e8..a8276120 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -78,6 +78,9 @@ footer { margin-top: 16px; } #peek { position: fixed; inset: auto 0 0; width: 100%; max-height: 65dvh; margin: 0; z-index: 10; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 20px #0003; } } .log-list { padding-left: 20px; } +.stopping-bar { height: 6px; background: var(--working); transform-origin: left; animation: stopping 1.5s ease-in-out infinite alternate; } +@keyframes stopping { from { transform: scaleX(.2); } to { transform: scaleX(1); } } +@media (prefers-reduced-motion: reduce) { .stopping-bar { animation: none; } } .log-list li { margin: 10px 0; overflow-wrap: anywhere; } .log-list span { display: block; color: var(--dim); } #log-content { margin-top: 16px; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 05d1a3f9..4042c863 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -99,9 +99,12 @@ async function refresh() { try { while (pending) { pending = false; - const response = await fetch("/", { cache: "no-store" }); - if (!response.ok) throw new Error("Inbox unavailable"); - morph(await response.text()); + if (document.getElementById('inbox')) { + const response = await fetch("/", { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + morph(await response.text()); + } + await refreshPeek(); } } catch { startPolling(); @@ -111,16 +114,29 @@ let polling; function startPolling() { polling ??= setInterval(refresh, 5000); } -if (document.getElementById('inbox') && typeof EventSource === "function") { - const stream = new EventSource("/events"); - stream.addEventListener("commands", refresh); - stream.addEventListener("error", startPolling); - stream.addEventListener("open", () => { - clearInterval(polling); - polling = undefined; - refresh(); - }); -} else if (document.getElementById('inbox')) { startPolling(); } +let commandStream; +function startUpdates() { + if (commandStream) return; + if (typeof EventSource === "function") { + commandStream = new EventSource("/events"); + commandStream.addEventListener("commands", refresh); + commandStream.addEventListener("error", startPolling); + commandStream.addEventListener("open", () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); + } else { startPolling(); } +} +if (document.getElementById('inbox')) startUpdates(); + +function stopPeekUpdates() { + if (document.getElementById('inbox')) return; + commandStream?.close(); + commandStream = undefined; + clearInterval(polling); + polling = undefined; +} const peek = document.querySelector('#peek'); const content = document.querySelector('#peek-content'); @@ -140,6 +156,7 @@ function select(element, { scroll = true } = {}) { } function closePeek() { + stopPeekUpdates(); pendingPeek?.abort(); pendingPeek = null; peekCommand = null; @@ -150,22 +167,36 @@ function closePeek() { async function togglePeek(row) { if (!row.dataset.command) return; if (peekCommand === row.dataset.command) { closePeek(); return; } - pendingPeek?.abort(); - const request = new AbortController(); - pendingPeek = request; peekCommand = row.dataset.command; content.textContent = 'Loading details…'; peek.hidden = false; document.body.classList.add('peek-open'); + await loadPeek(row.dataset.peek); +} + +async function refreshPeek() { + if (peekCommand && !pendingPeek) { + await loadPeek(`/peek?${new URLSearchParams({ command: peekCommand })}`); + } +} + +async function loadPeek(url) { + pendingPeek?.abort(); + const request = new AbortController(); + pendingPeek = request; try { - const response = await fetch(row.dataset.peek, { signal: request.signal }); + const response = await fetch(url, { signal: request.signal, cache: 'no-store' }); if (!response.ok) throw new Error('Details unavailable'); const html = await response.text(); if (request.signal.aborted) return; // Only the server's escaped show template is inserted, never a log. content.replaceChildren(document.createRange().createContextualFragment(html)); + if (content.querySelector('[data-live="true"]')) startUpdates(); + else stopPeekUpdates(); } catch (error) { if (!request.signal.aborted && error.name !== 'AbortError') content.textContent = 'Details unavailable. Reload to try again.'; + } finally { + if (pendingPeek === request) pendingPeek = null; } } diff --git a/src/console/assets/stop.js b/src/console/assets/stop.js new file mode 100644 index 00000000..f1ca5acb --- /dev/null +++ b/src/console/assets/stop.js @@ -0,0 +1,31 @@ +// The Stop page shares the Command push stream, with polling after a disconnect. +let refreshing = false, pending = false, polling; +async function refresh() { + pending = true; + if (refreshing) return; + refreshing = true; + try { + while (pending) { + pending = false; + const response = await fetch(location.href, { cache: 'no-store' }); + if (!response.ok || response.redirected) throw new Error('Stop unavailable'); + const next = new DOMParser().parseFromString(await response.text(), 'text/html'); + const replacement = next.getElementById('stop-state'); + if (!replacement) throw new Error('Stop unavailable'); + const current = document.getElementById('stop-state'); + if (current.innerHTML !== replacement.innerHTML) current.replaceWith(replacement); + } + } catch { startPolling(); } + finally { refreshing = false; } +} +function startPolling() { polling ??= setInterval(refresh, 5000); } +if (typeof EventSource === 'function') { + const stream = new EventSource('/events'); + stream.addEventListener('commands', refresh); + stream.addEventListener('error', startPolling); + stream.addEventListener('open', () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); +} else { startPolling(); } diff --git a/src/console/auth.rs b/src/console/auth.rs index 8aebbc4e..5c1c1490 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -35,7 +35,7 @@ impl Access { pub(super) struct Viewer { pub email: String, token: Option, - csrf: String, + pub(super) csrf: String, } impl Viewer { diff --git a/src/console/logs.html b/src/console/logs.html index 13b7a544..5122b648 100644 --- a/src/console/logs.html +++ b/src/console/logs.html @@ -3,6 +3,6 @@ Logs · thirdshift

thirdshift / Console

{% include "console/nav.html" %}

Logs

thirdshift logs {% if !owner.is_empty() %}--command {{ owner }} {% endif %}--json
- + {% if logs.is_empty() %}

No logs found.

{% endif %}
{% include "console/chrome.html" %} diff --git a/src/console/peek.html b/src/console/peek.html index c192fccd..91083970 100644 --- a/src/console/peek.html +++ b/src/console/peek.html @@ -1,5 +1,9 @@ -
+

Command details

+ {% if can_stop %}
{{ stop_cli }}
{% endif %} + {% if stopping %}

still ending

{% endif %} + {% for branch in branches %}

Issue branch left: {{ branch }}

{% endfor %} + {% for pr in pull_requests %}

Pull request left: {{ pr }}

{% endfor %} {{ cli }}

Full Command log

{% for log in logs %}

Session log: {{ log.name }}

{% endfor %} diff --git a/src/console/peek.rs b/src/console/peek.rs index 209eadd4..abf215d1 100644 --- a/src/console/peek.rs +++ b/src/console/peek.rs @@ -9,6 +9,12 @@ pub(super) struct Peek { pub cli: String, pub facts: Vec, pub logs: Vec, + pub can_stop: bool, + pub live: bool, + pub stopping: bool, + pub stop_cli: String, + pub branches: Vec, + pub pull_requests: Vec, } pub(super) struct Log { @@ -22,7 +28,8 @@ pub(super) struct Fact { } impl Peek { - pub fn new(command: String, value: Value) -> Self { + pub fn new(command: String, inspection: crate::logs::stop::Inspection) -> Self { + let value = inspection.details; let mut logs = Vec::new(); if let Some(sessions) = value["session_logs"].as_array() { for session in sessions.iter().filter_map(Value::as_str) { @@ -41,6 +48,12 @@ impl Peek { command.replace('\'', "'\\''") ); Self { + live: value["state"] == "live", + stop_cli: format!("thirdshift stop '{}'", command.replace('\'', "'\\''")), + can_stop: inspection.can_stop, + stopping: inspection.stopping, + branches: inspection.branches, + pull_requests: inspection.pull_requests, command, cli, facts: Fact::children(value), diff --git a/src/console/reader.html b/src/console/reader.html index ee91b109..7c2c4ce0 100644 --- a/src/console/reader.html +++ b/src/console/reader.html @@ -3,6 +3,7 @@ {{ log.kind }} · thirdshift

thirdshift / Console

{% include "console/nav.html" %}

{{ log.kind }}

{{ log.name }} {% if log.live %}live{% endif %}

thirdshift logs --file {{ log.id }}

+

Command details

{% if log.live %}Live{% else %}Complete{% endif %}

{# Only the conversation renderer's escaped HTML is trusted. #}
{{ html|safe }}
diff --git a/src/console/stop.html b/src/console/stop.html new file mode 100644 index 00000000..04e14b73 --- /dev/null +++ b/src/console/stop.html @@ -0,0 +1,20 @@ + + +Stop · thirdshift +

thirdshift / Console

{% include "console/nav.html" %} +
+

{{ details.command }}

+ {% if details.can_stop %} +

Stop this Command?

+

Its issue will be labelled needs-triage for the Day shift. Requeue instead puts it back in line with ready-for-agent.

+
+ + + + {{ details.stop_cli }} + {{ details.stop_cli }} --requeue + Cancel +
+ {% endif %} + {{ details|safe }} +
diff --git a/src/console/stop.rs b/src/console/stop.rs new file mode 100644 index 00000000..568db0d3 --- /dev/null +++ b/src/console/stop.rs @@ -0,0 +1,73 @@ +//! Confirmation and observation of the shared CLI Stop. +use anyhow::Result; +use askama::Template; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::{Extension, Form, extract::RawQuery, http::StatusCode}; +use serde::Deserialize; + +use super::{auth::Viewer, peek::Peek}; +use crate::{config::UserConfig, logs::stop}; + +#[derive(Template)] +#[template(path = "console/stop.html")] +struct StopPage { + theme: String, + csrf: String, + details: Peek, +} + +pub(super) async fn show( + Extension(viewer): Extension, + RawQuery(query): RawQuery, +) -> Response { + let command = super::query_value(&query, "command"); + match tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let inspection = stop::inspect(&config, &command)?; + Ok(StopPage { + theme: config.console.theme, + csrf: viewer.csrf, + details: Peek::new(command, inspection), + } + .render()?) + }) + .await + { + Ok(Ok(html)) => Html(html).into_response(), + _ => (StatusCode::NOT_FOUND, "Command details are unavailable.").into_response(), + } +} + +#[derive(Deserialize)] +pub(super) struct StopForm { + command: String, + #[serde(default)] + confirmed: String, + ending: String, +} + +pub(super) async fn submit( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + if form.confirmed != "yes" || !matches!(form.ending.as_str(), "triage" | "requeue") { + return StatusCode::BAD_REQUEST.into_response(); + } + let location = crate::logs::status::inspect::url("/stop", &form.command, None); + let result = tokio::task::spawn_blocking(move || { + stop::request_stop( + &form.command, + stop::Request { + who: viewer.email, + requeue: form.ending == "requeue", + from_console: true, + }, + ) + }) + .await; + match result { + Ok(Ok(_)) => Redirect::to(&location).into_response(), + Ok(Err(error)) => (StatusCode::CONFLICT, error.to_string()).into_response(), + Err(error) => super::page(Err(error)), + } +} diff --git a/src/logs/recording.rs b/src/logs/recording.rs index e10e1cdb..c4dca8be 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -271,7 +271,11 @@ impl Record { if let Some(request) = self.stop_request() { self.activity_write( &root, - &format!("{subject} stopped from the CLI by {}", request.who), + &format!( + "{subject} stopped from the {} by {}", + request.source(), + request.who + ), None, ); } diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs index 286e4ac9..48bbaf5d 100644 --- a/src/logs/status/inspect.rs +++ b/src/logs/status/inspect.rs @@ -9,15 +9,6 @@ use serde::Serialize; use crate::config::UserConfig; use crate::logs::reading::{Command, Observation, Snapshot}; -/// Exactly the detailed observations used by show, for the local Console. -pub(crate) fn details(config: &UserConfig, input: &str) -> Result { - let snapshot = - Snapshot::load_with_silence(&config.logs_dir, Local::now(), config.silent_interval)?; - Ok(serde_json::to_value( - snapshot.resolve(input)?.observation.details()?, - )?) -} - pub(crate) fn log_path( config: &UserConfig, input: &str, diff --git a/src/logs/stop.rs b/src/logs/stop.rs index 4e75f632..6ed1c1a3 100644 --- a/src/logs/stop.rs +++ b/src/logs/stop.rs @@ -22,9 +22,15 @@ pub enum Ending { pub struct Request { pub who: String, pub requeue: bool, + #[serde(default)] + pub from_console: bool, } impl Request { + pub(super) fn source(&self) -> &'static str { + if self.from_console { "Console" } else { "CLI" } + } + pub fn ending(&self) -> Ending { if self.requeue { Ending::Requeue @@ -61,11 +67,6 @@ fn require_live(record: &CommandRecord) -> Result<()> { } pub fn stop(input: &str, requeue: bool) -> Result<()> { - let config = UserConfig::load()?; - let snapshot = Snapshot::load(&config.logs_dir, chrono::Local::now())?; - let path = snapshot.resolve(input)?.stop_target(input)?; - let tree = read(&path)?; - require_live(&tree.command)?; let user = std::process::Command::new("id") .arg("-un") .output() @@ -76,7 +77,24 @@ pub fn stop(input: &str, requeue: bool) -> Result<()> { let request = Request { who: String::from_utf8(user.stdout)?.trim().to_owned(), requeue, + from_console: false, }; + let path = request_stop(input, request)?; + wait( + &path, + &mut WallClock(Instant::now()), + &mut std::io::stdout(), + ) +} + +/// Both callers publish and signal the same captured Command. Only the CLI +/// blocks waiting; the Console observes cleanup through its existing stream. +pub(crate) fn request_stop(input: &str, request: Request) -> Result { + let config = UserConfig::load()?; + let snapshot = Snapshot::load(&config.logs_dir, chrono::Local::now())?; + let path = snapshot.resolve(input)?.stop_target(input)?; + let tree = read(&path)?; + require_live(&tree.command)?; let mut file = tempfile::NamedTempFile::new_in(path.parent().context("Command record has no directory")?)?; file.write_all(&serde_json::to_vec(&request)?)?; @@ -92,11 +110,46 @@ pub fn stop(input: &str, requeue: bool) -> Result<()> { let _ = std::fs::remove_file(request_path(&path)); return Err(error); } - wait( - &path, - &mut WallClock(Instant::now()), - &mut std::io::stdout(), - ) + Ok(path) +} + +pub(crate) struct Inspection { + pub details: serde_json::Value, + pub can_stop: bool, + pub stopping: bool, + pub branches: Vec, + pub pull_requests: Vec, +} + +/// Availability follows the CLI's child refusal and captured process identity. +pub(crate) fn inspect(config: &UserConfig, input: &str) -> Result { + let snapshot = Snapshot::load_with_silence( + &config.logs_dir, + chrono::Local::now(), + config.silent_interval, + )?; + let selected = snapshot.resolve(input)?; + let pending = selected + .owner + .is_some_and(|owner| requested(&owner.record_path).is_some()); + let details = serde_json::to_value(selected.observation.details()?)?; + let stopping = pending && !selected.process_exited() && details["outcome"].is_null(); + let work = if selected.process_exited() || !details["outcome"].is_null() { + selected + .owner + .map(|owner| reading::retained_work(&owner.record_path, chrono::Local::now())) + .transpose()? + .unwrap_or_default() + } else { + reading::RetainedWork::default() + }; + Ok(Inspection { + details, + can_stop: selected.stop_target(input).is_ok() && !pending, + stopping, + branches: work.branches.into_iter().collect(), + pull_requests: work.pull_requests.into_iter().collect(), + }) } trait Clock { diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index 39f4b5c5..d480059f 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -8,6 +8,9 @@ use std::time::Instant; const EMAIL: &str = "owner@example.test"; const PASSWORD: &str = "correct horse battery staple"; +#[path = "stop.rs"] +mod stop; + fn configured() -> tempfile::TempDir { let home = tempfile::tempdir().unwrap(); configure(home.path()); diff --git a/tests/console/stop.rs b/tests/console/stop.rs new file mode 100644 index 00000000..c1c55e2d --- /dev/null +++ b/tests/console/stop.rs @@ -0,0 +1,116 @@ +use super::*; + +#[test] +fn console_stop_confirms_authenticates_and_triages_or_requeues_a_live_command() { + for requeue in [false, true] { + let scenario = support::Scenario::new(); + let home = scenario.path("home"); + scenario.user_config_is(&format!( + "[console]\nport = 0\nallowed_emails = [\"{EMAIL}\"]\n" + )); + assert!(password(&home, None, PASSWORD, PASSWORD).0.success()); + scenario.issue_labelled(7, &["bug", "ready-for-agent"]); + scenario.agent_does(&format!( + "trap 'while ! test -f {root}/release; do sleep 0.02; done; exit 143' TERM\n\ + echo unfinished > work.txt\ntouch {root}/started\n\ + while ! test -f {root}/release; do sleep 0.05; done\nexit 1\n", + root = scenario.path("").display() + )); + let _held = HeldFixture { + run: Some(scenario.run_until(&[&scenario.issue_url(7)], &[], "started")), + release: scenario.path("release"), + }; + let console = + Console::start_mode(Path::new(env!("CARGO_BIN_EXE_thirdshift")), &home, false); + let route = "/stop?command=widgets%237"; + assert_eq!(get(&console, route, "").status(), 303); + assert_eq!( + client() + .post(format!("{}/stop", console.address)) + .send() + .unwrap() + .status(), + 303 + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let peek = get(&console, "/peek?command=widgets%237", &session) + .text() + .unwrap(); + assert!(peek.contains(">Stop"), "{peek}"); + assert!(peek.contains("thirdshift stop"), "{peek}"); + let confirm = get(&console, route, &session).text().unwrap(); + assert!(confirm.contains("needs-triage"), "{confirm}"); + assert!(confirm.contains("Requeue instead"), "{confirm}"); + let token = csrf(&confirm); + let post = |token: &str, confirmed: &str| { + client() + .post(format!("{}/stop", console.address)) + .header("cookie", &session) + .form(&[ + ("command", "widgets#7"), + ("csrf", token), + ("confirmed", confirmed), + ("ending", if requeue { "requeue" } else { "triage" }), + ]) + .send() + .unwrap() + }; + assert_eq!(post("", "yes").status(), 403); + assert_eq!(post(&token, "").status(), 400); + assert!( + !fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap() + .contains("stopped from") + ); + let response = post(&token, "yes"); + assert_eq!(response.status(), 303, "{}", response.text().unwrap()); + let ending = get(&console, route, &session).text().unwrap(); + assert!(ending.contains("still ending"), "{ending}"); + assert!(ending.contains("stopping-bar"), "{ending}"); + assert!(!ending.contains("name=\"ending\""), "{ending}"); + let stream = get(&console, "/events", &session); + let mut stream = BufReader::new(stream); + fs::write(scenario.path("release"), "").unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + assert!(Instant::now() < deadline, "Stop outcome never arrived"); + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0); + if !line.contains("event: commands") { + continue; + } + let page = get(&console, route, &session).text().unwrap(); + if page.contains("still ending") { + continue; + } + assert!(page.contains("interrupted"), "{page}"); + assert!(page.contains("Issue branch left: issue-7"), "{page}"); + assert!(!page.contains("name=\"ending\""), "{page}"); + break; + } + assert_eq!( + scenario.issue_labels(7), + if requeue { + vec!["bug", "ready-for-agent"] + } else { + vec!["bug", "in-progress", "needs-triage"] + } + ); + assert_eq!( + scenario.origin_file("issue-7", "work.txt"), + Some("unfinished\n".into()) + ); + let activity = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + assert!( + activity.contains(&format!("Run #7 stopped from the Console by {EMAIL}")), + "{activity}" + ); + assert_eq!(post(&token, "yes").status(), 409); + let peek = get(&console, "/peek?command=widgets%237", &session) + .text() + .unwrap(); + assert!(!peek.contains(">Stop"), "{peek}"); + } +} From 84ed7a1af6e630241d23e66970360ae549328f4a Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:56:29 +0000 Subject: [PATCH 2/2] Address Console Stop review findings and retain regressions --- console/README.md | 4 ++ console/reproductions/session-stop.py | 61 +++++++++++++++++++++++++++ console/tests/logs.test.cjs | 3 ++ src/args.rs | 32 ++++++++------ src/console.rs | 4 ++ src/console/assets/changes.js | 36 ++++++++++++++++ src/console/assets/console.js | 50 ++++++---------------- src/console/assets/stop.js | 42 +++++------------- src/console/logs.html | 2 +- src/console/reader.html | 2 +- src/logs/reading/catalog.rs | 49 +++++++++++++++++---- src/logs/stop.rs | 33 ++++++++++++--- src/main.rs | 10 +++-- tests/stop.rs | 35 +++++++++++++++ 14 files changed, 263 insertions(+), 100 deletions(-) create mode 100644 console/reproductions/session-stop.py create mode 100644 src/console/assets/changes.js diff --git a/console/README.md b/console/README.md index b5db7142..14843534 100644 --- a/console/README.md +++ b/console/README.md @@ -145,6 +145,10 @@ It updates through `/events`, with polling if the stream disconnects. The stopping bar respects reduced motion. HTTP tests use real Commands with a waiting fake Harness and fake GitHub; Playwright checks confirmation, cancellation, live controls and pushed completion on a phone. +`thirdshift stop [--requeue] --json` waits quietly and returns the +outcome, `issue_branches` and `pull_requests` as one JSON object. +On Linux after a build, `python3 console/reproductions/session-stop.py` checks that +a live Session reader's Details link reaches its exact Command's Stop. Claude Edit events include old/new text and show unified diff excerpts. Codex `file_change` events retain only file paths and change kinds, so the reader diff --git a/console/reproductions/session-stop.py b/console/reproductions/session-stop.py new file mode 100644 index 00000000..0d3815cf --- /dev/null +++ b/console/reproductions/session-stop.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""A live Session reader must reach its Command's Stop confirmation.""" +import datetime +import html +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +root = Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix="thirdshift-review-session-stop-") as temporary: + home = Path(temporary) + folder = home / ".thirdshift/logs/acme/widgets/commands/issue" + folder.mkdir(parents=True) + (home / ".thirdshift/config.toml").write_text("[console]\nport = 0\n") + command_log = folder / "7-fixture.log" + session_log = folder / "7-fixture-implement.jsonl" + command_log.write_text("Waiting for Stop\n") + session_log.write_text('{"type":"unfamiliar","text":"waiting"}\n') + stat = Path(f"/proc/{os.getpid()}/stat").read_text().rsplit(") ", 1)[1].split() + identity = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + ":" + stat[19] + record = dict(version=1, kind="Run", name="acme/widgets#7", repository="acme/widgets", + hostname="fixture", issue=dict(number=7, url="https://github.com/acme/widgets/issues/7", title="Live Command"), + process_id=os.getpid(), process_start=identity, harness="codex", + started_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ended_at=None, outcome=None, session_log=str(session_log), stages=[]) + (folder / "7-fixture.command.json").write_text(json.dumps(record)) + server = subprocess.Popen([str(root / "target/debug/thirdshift"), "console", "--no-sign-in"], + env={**os.environ, "HOME": str(home)}, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + origin = server.stdout.readline().strip() + assert origin.startswith("http://127.0.0.1:"), origin + def get(route): + try: + with urllib.request.urlopen(origin + route, timeout=10) as response: + return response.status, response.read().decode() + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + status, command = get("/peek?" + urllib.parse.urlencode({"command": str(command_log)})) + assert status == 200 and ">Stop" in command, (status, command) + status, inventory = get("/logs") + assert status == 200 + session_row = next(row for row in re.findall(r"", inventory, re.S) + if "Session log" in row and session_log.name in row) + reader_url = html.unescape(re.search(r'href="([^"]+)"', session_row).group(1)) + status, reader = get(reader_url) + assert status == 200 + details_url = html.unescape(re.search(r'class="peek-link" href="([^"]+)"', reader).group(1)) + status, details = get(details_url) + print(f"Session reader's Command details: HTTP {status}; {details}") + assert status == 200 and ">Stop" in details, ( + "Live Session reader must expose its owning Command's Stop control", status, details) + finally: + server.terminate() + server.communicate(timeout=10) diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs index 1c3e394e..e60b35dd 100644 --- a/console/tests/logs.test.cjs +++ b/console/tests/logs.test.cjs @@ -59,6 +59,9 @@ test("a live Session appends without redraw, folds across batches, resumes offse await page.goto(`${origin}/logs`); assert.equal(await page.locator(".log-list strong").count(), 2); await page.getByRole("link", { name: /Session log/ }).click(); + await page.getByRole('link', { name: 'Command details', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Close details (Esc)', exact: true }).click(); const initial = await page.locator("#log-content .message").first().elementHandle(); await page.locator(".tool-group").evaluate(node => { node.open = true; }); await appendFile(log, tool("second tool")); diff --git a/src/args.rs b/src/args.rs index bcb15181..9a8bcd14 100644 --- a/src/args.rs +++ b/src/args.rs @@ -44,6 +44,7 @@ pub enum Command { Stop { command: String, requeue: bool, + json: bool, }, Logs { command: String, @@ -298,19 +299,26 @@ pub fn parse(args: &[String]) -> Result { }; } Some("stop") => { - return match &args[1..] { - [command] if !command.starts_with('-') => Ok(Command::Stop { - command: command.clone(), - requeue: false, - }), - [command, flag] if flag == "--requeue" && !command.starts_with('-') => { - Ok(Command::Stop { - command: command.clone(), - requeue: true, - }) - } - _ => bail!("usage: thirdshift stop [--requeue]"), + let usage = "usage: thirdshift stop [--requeue] [--json]"; + let Some((command, options)) = args[1..].split_first() else { + bail!(usage) }; + if command.starts_with('-') { + bail!(usage); + } + let (mut requeue, mut json) = (false, false); + for option in options { + match option.as_str() { + "--requeue" if !requeue => requeue = true, + "--json" if !json => json = true, + _ => bail!(usage), + } + } + return Ok(Command::Stop { + command: command.clone(), + requeue, + json, + }); } Some("show") => { return match &args[1..] { diff --git a/src/console.rs b/src/console.rs index cdf52671..6a723769 100644 --- a/src/console.rs +++ b/src/console.rs @@ -63,6 +63,8 @@ const PAGES: &[PageRoute] = &[ cli: &[ "thirdshift stop widgets#7", "thirdshift stop widgets#7 --requeue", + "thirdshift stop widgets#7 --json", + "thirdshift stop widgets#7 --requeue --json", ], method: Method::GET, view: View::StopPage, @@ -72,6 +74,8 @@ const PAGES: &[PageRoute] = &[ cli: &[ "thirdshift stop widgets#7", "thirdshift stop widgets#7 --requeue", + "thirdshift stop widgets#7 --json", + "thirdshift stop widgets#7 --requeue --json", ], method: Method::POST, view: View::Stop, diff --git a/src/console/assets/changes.js b/src/console/assets/changes.js new file mode 100644 index 00000000..1f3d065f --- /dev/null +++ b/src/console/assets/changes.js @@ -0,0 +1,36 @@ +// One lifecycle for Command notices, coalesced refreshes and polling recovery. +export function watchCommands(update) { + let active = true, refreshing = false, pending = false, polling, stream; + function startPolling() { + if (active) polling ??= setInterval(refresh, 5000); + } + async function refresh() { + if (!active) return; + pending = true; + if (refreshing) return; + refreshing = true; + try { + while (active && pending) { + pending = false; + await update(); + } + } catch { startPolling(); } + finally { refreshing = false; } + } + if (typeof EventSource === 'function') { + stream = new EventSource('/events'); + stream.addEventListener('commands', refresh); + stream.addEventListener('error', startPolling); + stream.addEventListener('open', () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); + } else { startPolling(); } + return () => { + active = false; + pending = false; + stream?.close(); + clearInterval(polling); + }; +} diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 4042c863..b4751d6b 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -1,3 +1,5 @@ +import { watchCommands } from './changes.js'; + // Browser preferences are conveniences; malformed or unavailable storage hides nothing. function stored(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } @@ -90,52 +92,24 @@ function morph(html) { } } -// Coalesce notices, but fetch again if a change arrives during the request. -let refreshing = false, pending = false; async function refresh() { - pending = true; - if (refreshing) return; - refreshing = true; - try { - while (pending) { - pending = false; - if (document.getElementById('inbox')) { - const response = await fetch("/", { cache: "no-store" }); - if (!response.ok) throw new Error("Inbox unavailable"); - morph(await response.text()); - } - await refreshPeek(); - } - } catch { - startPolling(); - } finally { refreshing = false; } -} -let polling; -function startPolling() { - polling ??= setInterval(refresh, 5000); + if (document.getElementById('inbox')) { + const response = await fetch("/", { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + morph(await response.text()); + } + await refreshPeek(); } -let commandStream; +let stopWatching; function startUpdates() { - if (commandStream) return; - if (typeof EventSource === "function") { - commandStream = new EventSource("/events"); - commandStream.addEventListener("commands", refresh); - commandStream.addEventListener("error", startPolling); - commandStream.addEventListener("open", () => { - clearInterval(polling); - polling = undefined; - refresh(); - }); - } else { startPolling(); } + stopWatching ??= watchCommands(refresh); } if (document.getElementById('inbox')) startUpdates(); function stopPeekUpdates() { if (document.getElementById('inbox')) return; - commandStream?.close(); - commandStream = undefined; - clearInterval(polling); - polling = undefined; + stopWatching?.(); + stopWatching = undefined; } const peek = document.querySelector('#peek'); diff --git a/src/console/assets/stop.js b/src/console/assets/stop.js index f1ca5acb..e0e8b00e 100644 --- a/src/console/assets/stop.js +++ b/src/console/assets/stop.js @@ -1,31 +1,11 @@ -// The Stop page shares the Command push stream, with polling after a disconnect. -let refreshing = false, pending = false, polling; -async function refresh() { - pending = true; - if (refreshing) return; - refreshing = true; - try { - while (pending) { - pending = false; - const response = await fetch(location.href, { cache: 'no-store' }); - if (!response.ok || response.redirected) throw new Error('Stop unavailable'); - const next = new DOMParser().parseFromString(await response.text(), 'text/html'); - const replacement = next.getElementById('stop-state'); - if (!replacement) throw new Error('Stop unavailable'); - const current = document.getElementById('stop-state'); - if (current.innerHTML !== replacement.innerHTML) current.replaceWith(replacement); - } - } catch { startPolling(); } - finally { refreshing = false; } -} -function startPolling() { polling ??= setInterval(refresh, 5000); } -if (typeof EventSource === 'function') { - const stream = new EventSource('/events'); - stream.addEventListener('commands', refresh); - stream.addEventListener('error', startPolling); - stream.addEventListener('open', () => { - clearInterval(polling); - polling = undefined; - refresh(); - }); -} else { startPolling(); } +import { watchCommands } from './changes.js'; + +watchCommands(async () => { + const response = await fetch(location.href, { cache: 'no-store' }); + if (!response.ok || response.redirected) throw new Error('Stop unavailable'); + const next = new DOMParser().parseFromString(await response.text(), 'text/html'); + const replacement = next.getElementById('stop-state'); + if (!replacement) throw new Error('Stop unavailable'); + const current = document.getElementById('stop-state'); + if (current.innerHTML !== replacement.innerHTML) current.replaceWith(replacement); +}); diff --git a/src/console/logs.html b/src/console/logs.html index 5122b648..7a3b2653 100644 --- a/src/console/logs.html +++ b/src/console/logs.html @@ -3,6 +3,6 @@ Logs · thirdshift

thirdshift / Console

{% include "console/nav.html" %}

Logs

thirdshift logs {% if !owner.is_empty() %}--command {{ owner }} {% endif %}--json
- + {% if logs.is_empty() %}

No logs found.

{% endif %}
{% include "console/chrome.html" %} diff --git a/src/console/reader.html b/src/console/reader.html index 7c2c4ce0..c7eb3ce1 100644 --- a/src/console/reader.html +++ b/src/console/reader.html @@ -3,7 +3,7 @@ {{ log.kind }} · thirdshift

thirdshift / Console

{% include "console/nav.html" %}

{{ log.kind }}

{{ log.name }} {% if log.live %}live{% endif %}

thirdshift logs --file {{ log.id }}

-

Command details

+

Command details

{% if log.live %}Live{% else %}Complete{% endif %}

{# Only the conversation renderer's escaped HTML is trusted. #}
{{ html|safe }}
diff --git a/src/logs/reading/catalog.rs b/src/logs/reading/catalog.rs index 0e980a12..63573259 100644 --- a/src/logs/reading/catalog.rs +++ b/src/logs/reading/catalog.rs @@ -40,6 +40,16 @@ pub(crate) struct Log { pub owners: Vec, #[serde(skip)] pub path: PathBuf, + #[serde(skip)] + pub selector: String, +} + +#[derive(Default)] +struct Observed { + live: bool, + command: String, + owners: Vec, + selector: String, } pub(crate) fn id(path: &Path) -> String { @@ -59,7 +69,7 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< command: &Command, root: &Path, ancestors: &[String], - observed: &mut BTreeMap)>, + observed: &mut BTreeMap, ) -> Result<()> { let mut owners = ancestors.to_vec(); owners.push(id(command.record_path.strip_prefix(root)?)); @@ -69,15 +79,28 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< .as_ref() .map(|issue| format!("{}#{}", command.record.repository, issue.number)) .unwrap_or_else(|| command.record.name.clone()); - observed.entry(command.command_log.clone()).or_insert(( - command.state == "live", - subject.clone(), - owners.clone(), - )); + let observation = Observation::Record(command); + let selector = observation.selector().context("Command has no selector")?; + observed + .entry(command.command_log.clone()) + .or_insert(Observed { + live: command.state == "live", + command: subject.clone(), + owners: owners.clone(), + selector: selector.clone(), + }); for session in Observation::Record(command).sessions()? { let live = command.state == "live" && command.record.session_log.as_ref() == Some(&session); - observed.insert(session, (live, subject.clone(), owners.clone())); + observed.insert( + session, + Observed { + live, + command: subject.clone(), + owners: owners.clone(), + selector: selector.clone(), + }, + ); } for child in command.dispatched.iter().chain(&command.children) { collect(child, root, &owners, observed)?; @@ -109,7 +132,12 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< continue; } let relative = path.strip_prefix(root)?; - let (live, command, mut owners) = observed.remove(&path).unwrap_or_default(); + let Observed { + live, + command, + mut owners, + selector, + } = observed.remove(&path).unwrap_or_default(); if let Some(decoded) = owner::decode(&path) { owners.push(id(decoded.command_log(&path).strip_prefix(root)?)); } @@ -125,6 +153,11 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< live, command, owners, + selector: if selector.is_empty() { + path.to_string_lossy().into_owned() + } else { + selector + }, path, }); } diff --git a/src/logs/stop.rs b/src/logs/stop.rs index 6ed1c1a3..474fb3e4 100644 --- a/src/logs/stop.rs +++ b/src/logs/stop.rs @@ -66,7 +66,7 @@ fn require_live(record: &CommandRecord) -> Result<()> { Ok(()) } -pub fn stop(input: &str, requeue: bool) -> Result<()> { +pub fn stop(input: &str, requeue: bool, json: bool) -> Result<()> { let user = std::process::Command::new("id") .arg("-un") .output() @@ -84,6 +84,7 @@ pub fn stop(input: &str, requeue: bool) -> Result<()> { &path, &mut WallClock(Instant::now()), &mut std::io::stdout(), + json, ) } @@ -168,15 +169,15 @@ impl Clock for WallClock { } } -fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write) -> Result<()> { +fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write, json: bool) -> Result<()> { let mut reminder = clock.elapsed(); loop { let tree = read(path)?; if tree.command.outcome.is_some() || !tree.command.live(&mut OnMachine) { - print_ending(path, &tree, output)?; + print_ending(path, &tree, output, json)?; return Ok(()); } - if clock.elapsed().saturating_sub(reminder) >= Duration::from_secs(60) { + if !json && clock.elapsed().saturating_sub(reminder) >= Duration::from_secs(60) { writeln!(output, "{} still ending", tree.command.name)?; output.flush()?; reminder = clock.elapsed(); @@ -185,7 +186,27 @@ fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write) -> Result< } } -fn print_ending(path: &Path, tree: &CommandTree, output: &mut impl Write) -> Result<()> { +fn print_ending( + path: &Path, + tree: &CommandTree, + output: &mut impl Write, + json: bool, +) -> Result<()> { + let work = reading::retained_work(path, chrono::Local::now())?; + if json { + writeln!( + output, + "{}", + serde_json::json!({ + "version": 1, + "name": tree.command.name, + "outcome": tree.command.outcome.as_deref().unwrap_or("died before recording an outcome"), + "issue_branches": work.branches, + "pull_requests": work.pull_requests, + }) + )?; + return Ok(()); + } writeln!( output, "{}: {}", @@ -195,7 +216,6 @@ fn print_ending(path: &Path, tree: &CommandTree, output: &mut impl Write) -> Res .as_deref() .unwrap_or("died before recording an outcome") )?; - let work = reading::retained_work(path, chrono::Local::now())?; for branch in work.branches { writeln!(output, "Issue branch left: {branch}")?; } @@ -262,6 +282,7 @@ mod tests { path: path.clone(), }, &mut output, + false, ) .unwrap(); assert_eq!( diff --git a/src/main.rs b/src/main.rs index eb83ef0f..ffe9e984 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,7 @@ usage: thirdshift Run the factory on the iss thirdshift console sign-out [--email
] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log thirdshift show [--json] Show one Command in full - thirdshift stop [--requeue] Stop a live Command and wait for cleanup + thirdshift stop [--requeue] [--json] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs thirdshift logs --file [--follow] Read an exact listed log thirdshift logs [--session ] [--follow] Read or follow a log @@ -158,8 +158,12 @@ fn main() -> ExitCode { Err(error) => failure(&error), }; } - Ok(Command::Stop { command, requeue }) => { - return match logs::stop::stop(&command, requeue) { + Ok(Command::Stop { + command, + requeue, + json, + }) => { + return match logs::stop::stop(&command, requeue, json) { Ok(()) => ExitCode::SUCCESS, Err(error) => failure(&error), }; diff --git a/tests/stop.rs b/tests/stop.rs index 2216df4d..58971bf1 100644 --- a/tests/stop.rs +++ b/tests/stop.rs @@ -14,6 +14,41 @@ fn stop_and_finish(scenario: &Scenario, mut held: HeldRun, args: &[&str]) -> Run stopped } +#[test] +fn json_stop_reports_the_outcome_and_retained_work_for_both_endings() { + for requeue in [false, true] { + let scenario = Scenario::new(); + scenario.issue_labelled(7, &["ready-for-agent"]); + scenario.agent_does(&format!( + "echo unfinished > work.txt\ntouch '{}'\nwhile true; do sleep 0.05; done\n", + scenario.path("started").display() + )); + let held = scenario.run_until(&[&scenario.issue_url(7)], &[], "started"); + let args = if requeue { + vec!["stop", "widgets#7", "--requeue", "--json"] + } else { + vec!["stop", "widgets#7", "--json"] + }; + let stopped = stop_and_finish(&scenario, held, &args); + let output: serde_json::Value = serde_json::from_str(&stopped.stdout).unwrap(); + assert_eq!( + output, + serde_json::json!({ + "version": 1, "name": "acme/widgets#7", "outcome": "interrupted", + "issue_branches": ["issue-7"], "pull_requests": [] + }) + ); + assert_eq!( + scenario.issue_labels(7), + if requeue { + vec!["ready-for-agent"] + } else { + vec!["in-progress", "needs-triage"] + } + ); + } +} + #[test] fn stopping_a_pass_with_no_issue_ends_it_without_changing_labels() { for kind in ["architect", "secure"] {