diff --git a/console/README.md b/console/README.md index 545bc526..df9818a2 100644 --- a/console/README.md +++ b/console/README.md @@ -147,6 +147,23 @@ 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. +`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 shows their plain fallback and says the patch is unavailable. The Spec-review 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/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/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/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 86c3cfcd..a775ff58 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -28,7 +28,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | 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` | | Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.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/args.rs b/src/args.rs index 1f2f772a..3d2c9b4b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -45,6 +45,7 @@ pub enum Command { Stop { command: String, requeue: bool, + json: bool, }, Logs { command: String, @@ -300,19 +301,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 85dbe1ee..deaf2533 100644 --- a/src/console.rs +++ b/src/console.rs @@ -10,6 +10,7 @@ mod live; mod logs; mod peek; mod restart; +mod stop; mod time; mod waterfall; @@ -54,12 +55,36 @@ enum View { SignOut, SignInPage, SignIn, + StopPage, + Stop, SetPasswordPage, SendCode, SetPassword, } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/stop", + 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, + }, + PageRoute { + path: "/stop", + 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, + }, PageRoute { path: "/set-password", cli: &["thirdshift console password"], @@ -269,6 +294,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), (View::SetPasswordPage, Method::GET) => get(auth::set_password_page), (View::SendCode, Method::POST) => post(auth::code), (View::SetPassword, Method::POST) => post(auth::set_password), @@ -404,7 +431,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 @@ -527,9 +554,13 @@ mod tests { .route("/known", post(|| async {})) .with_state(changes), ); - // Each unique path has one fallback; merging GET and POST at sign-in - // and set-password adds only the POST services, not more fallbacks. - 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| { @@ -541,7 +572,7 @@ mod tests { }) .sum::() + get_methods - - 2 * fallback; + - shared_fallbacks; assert_eq!( registered_methods(router), expected, 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.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..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,37 +92,25 @@ 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; - const response = await fetch("/", { cache: "no-store" }); - if (!response.ok) throw new Error("Inbox unavailable"); - morph(await response.text()); - } - } catch { - startPolling(); - } finally { refreshing = 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(); } -let polling; -function startPolling() { - polling ??= setInterval(refresh, 5000); +let stopWatching; +function startUpdates() { + stopWatching ??= watchCommands(refresh); +} +if (document.getElementById('inbox')) startUpdates(); + +function stopPeekUpdates() { + if (document.getElementById('inbox')) return; + stopWatching?.(); + stopWatching = undefined; } -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(); } const peek = document.querySelector('#peek'); const content = document.querySelector('#peek-content'); @@ -140,6 +130,7 @@ function select(element, { scroll = true } = {}) { } function closePeek() { + stopPeekUpdates(); pendingPeek?.abort(); pendingPeek = null; peekCommand = null; @@ -150,22 +141,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..e0e8b00e --- /dev/null +++ b/src/console/assets/stop.js @@ -0,0 +1,11 @@ +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/auth.rs b/src/console/auth.rs index c45034b6..11c530be 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -38,7 +38,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..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/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..c7eb3ce1 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/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/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..474fb3e4 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 @@ -60,12 +66,7 @@ fn require_live(record: &CommandRecord) -> Result<()> { Ok(()) } -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)?; +pub fn stop(input: &str, requeue: bool, json: bool) -> Result<()> { let user = std::process::Command::new("id") .arg("-un") .output() @@ -76,7 +77,25 @@ 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(), + json, + ) +} + +/// 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 +111,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 { @@ -115,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(); @@ -132,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, "{}: {}", @@ -142,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}")?; } @@ -209,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 a286a181..a7469b8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,7 +84,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 @@ -168,8 +168,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/console/accounts.rs b/tests/console/accounts.rs index 1218d545..f4337cc1 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -11,6 +11,9 @@ const PASSWORD: &str = "correct horse battery staple"; #[path = "password_codes.rs"] mod password_codes; +#[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}"); + } +} 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"] {