Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [--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
Expand Down
61 changes: 61 additions & 0 deletions console/reproductions/session-stop.py
Original file line number Diff line number Diff line change
@@ -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</button>" in command, (status, command)
status, inventory = get("/logs")
assert status == 200
session_row = next(row for row in re.findall(r"<li\b.*?</li>", 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</button>" in details, (
"Live Session reader must expose its owning Command's Stop control", status, details)
finally:
server.terminate()
server.communicate(timeout=10)
3 changes: 2 additions & 1 deletion console/tests/inbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions console/tests/logs.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
107 changes: 107 additions & 0 deletions console/tests/stop.test.cjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
}
2 changes: 1 addition & 1 deletion docs/code-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
32 changes: 20 additions & 12 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum Command {
Stop {
command: String,
requeue: bool,
json: bool,
},
Logs {
command: String,
Expand Down Expand Up @@ -300,19 +301,26 @@ pub fn parse(args: &[String]) -> Result<Command> {
};
}
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 <command> [--requeue]"),
let usage = "usage: thirdshift stop <command> [--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..] {
Expand Down
41 changes: 36 additions & 5 deletions src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod live;
mod logs;
mod peek;
mod restart;
mod stop;
mod time;
mod waterfall;

Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -269,6 +294,8 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver<u64>) -> 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),
Expand Down Expand Up @@ -404,7 +431,7 @@ async fn inspect(RawQuery(query): RawQuery) -> Response {
let command = query_value(&query, "command");
match tokio::task::spawn_blocking(move || -> Result<String> {
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
Expand Down Expand Up @@ -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::<std::collections::BTreeSet<_>>()
.len();
let shared_fallbacks = super::PAGES.len() - unique_paths;
let expected = super::PAGES
.iter()
.map(|page| {
Expand All @@ -541,7 +572,7 @@ mod tests {
})
.sum::<usize>()
+ get_methods
- 2 * fallback;
- shared_fallbacks;
assert_eq!(
registered_methods(router),
expected,
Expand Down
Loading
Loading