diff --git a/GLOSSARY.md b/GLOSSARY.md index 981091fb7..22359b187 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -251,12 +251,15 @@ _Avoid_: init, onboarding, configure ### Logs and records -**Session log**: -The full transcript of one agent session, one per session, a **Resume** and each **Repair** included. Kept with its repository's other logs, named for the session's kind and stamped with the local time its command started, so all of a command's Session logs sort together. The start stamp is a display and grouping fact; distinct Commands have distinct storage ownership even when their stamps agree. Model evidence is kept beside each Session log and belongs to the exact owning **Command**, including across child Runs. It survives an interrupted stream and is replaced by completed retained evidence only when a report is available, even when that report has no observed Models; an absent report leaves the stream evidence intact. Requested **Model** and **Effort** choices are recorded separately from observed Models. A session that ran the code review has its reviewers' reports kept beside its Session log. -_Avoid_: transcript, session file +**Session trace**: +The events and timed work of one agent session, one per session, a **Resume** and each **Repair** included. Each belongs to its owning **Command**'s **Trace**. Kept with its repository's other logs, named for the session's kind and stamped with the local time its command started, so all of a command's Session traces sort together. The start stamp is a display and grouping fact; distinct Commands have distinct storage ownership even when their stamps agree. Model evidence is kept beside each Session trace and belongs to the exact owning **Command**, including across child Runs. It survives an interrupted stream and is replaced by completed retained evidence only when a report is available, even when that report has no observed Models; an absent report leaves the stream evidence intact. Requested **Model** and **Effort** choices are recorded separately from observed Models. A session that ran the code review has its reviewers' reports kept beside its Session trace. +_Avoid_: Session log, transcript, session file + +**Trace**: +A **Command**'s whole tree of work, its child **Runs** and all of their **Session traces** included. Their shared identity keeps the tree together across sessions. **Command log**: -Everything one **Run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** printed, kept in a file of its own with its repository's other logs, stamped like its **Session logs**. The start stamp is for display and grouping; Commands with the same start time still have distinct storage ownership for their Command logs, Session logs and **Command records**. An Architect run's, Pickup run's or Security run's covers the Spec runs or Runs it dispatched, and a Spec run's covers its **Tickets**' Runs, as a Run's covers its **Base fix**: none of those keeps one of its own. It is kept once the command starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, and neither does a Run that fails before it starts work, such as on **Origin match**. +Everything one **Run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** printed, kept in a file of its own with its repository's other logs, stamped like its **Session traces**. The start stamp is for display and grouping; Commands with the same start time still have distinct storage ownership for their Command logs, Session traces and **Command records**. An Architect run's, Pickup run's or Security run's covers the Spec runs or Runs it dispatched, and a Spec run's covers its **Tickets**' Runs, as a Run's covers its **Base fix**: none of those keeps one of its own. It is kept once the command starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, and neither does a Run that fails before it starts work, such as on **Origin match**. _Avoid_: cron log, output log, run log (a **Run** is one kind of command) **Activity log**: @@ -268,7 +271,7 @@ What a **Command** keeps on its machine about where it is: its process, its issu _Avoid_: live record, state file, status file **Run notification**: -A message thirdshift sends when a **Run**, a **Spec run**, an **Architect run** or a **Security run** ends, whatever its outcome (ready, merged, failed or interrupted; for an Architect run that dispatched nothing, plan published, idea filed, idea already filed or review failed), to the address given with the email flag or the default in the **User config**. Its body tells everything its subject does and names the owning **Command**, so the body alone carries it. Each sends one only when asked to, by the flag or by the User config; a Spec run's notification lists each **Ticket**'s outcome, and a Ticket's **Run** never sends one of its own. After an **Inherited failure** it carries, beside the cause, what the Run says on stderr: where the checks fail on the **Base branch**, and the offer of a **Base fix** if nobody decided against one. An Architect run's notification tells how its **Architecture review** ended, naming the plan or idea issue, and how the Spec run or Run it dispatched ended, which sends none of its own; a skipped one sends none. A **Pickup run** that took a **Ready issue** sends one naming the Pickup run as its Command and the Spec run or Run it dispatched, which sends none of its own; a skipped one sends none. A **Security run**'s notification lists each **Security finding**'s severity, title and private link, never its write-up, since the message passes through a third party. When a Run leaves a reproduced finding unfixed and nobody decided against fixing, it offers both the command and the User config setting that allow fixing; a skipped one sends none. Its Session models section collects Model evidence kept beside each **Session log**, belonging to the exact owning **Command** across child Runs. Stream evidence survives interruption and is replaced by completed retained evidence only when a report is available; a present report with no observed Models is authoritative. Requested **Model** and **Effort** choices remain distinct from observed Models, with requested/default fallback wording when none were reported. Failing to collect evidence or send one never changes the outcome. +A message thirdshift sends when a **Run**, a **Spec run**, an **Architect run** or a **Security run** ends, whatever its outcome (ready, merged, failed or interrupted; for an Architect run that dispatched nothing, plan published, idea filed, idea already filed or review failed), to the address given with the email flag or the default in the **User config**. Its body tells everything its subject does and names the owning **Command**, so the body alone carries it. Each sends one only when asked to, by the flag or by the User config; a Spec run's notification lists each **Ticket**'s outcome, and a Ticket's **Run** never sends one of its own. After an **Inherited failure** it carries, beside the cause, what the Run says on stderr: where the checks fail on the **Base branch**, and the offer of a **Base fix** if nobody decided against one. An Architect run's notification tells how its **Architecture review** ended, naming the plan or idea issue, and how the Spec run or Run it dispatched ended, which sends none of its own; a skipped one sends none. A **Pickup run** that took a **Ready issue** sends one naming the Pickup run as its Command and the Spec run or Run it dispatched, which sends none of its own; a skipped one sends none. A **Security run**'s notification lists each **Security finding**'s severity, title and private link, never its write-up, since the message passes through a third party. When a Run leaves a reproduced finding unfixed and nobody decided against fixing, it offers both the command and the User config setting that allow fixing; a skipped one sends none. Its Session models section collects Model evidence kept beside each **Session trace**, belonging to the exact owning **Command** across child Runs. Stream evidence survives interruption and is replaced by completed retained evidence only when a report is available; a present report with no observed Models is authoritative. Requested **Model** and **Effort** choices remain distinct from observed Models, with requested/default fallback wording when none were reported. Failing to collect evidence or send one never changes the outcome. _Avoid_: completion email, alert ### Console @@ -298,7 +301,7 @@ The **Inbox**'s first group: what won't come right unless the **Day shift** acts _Avoid_: alerts, attention, triage queue **Silent**: -A live **Run** whose **Session log** and **Command log** have both stopped growing for longer than the silent time the **User config** sets, 10 minutes unless set: it may be stuck. Stages that wait on something else, such as CI watch, write a line at intervals while they wait, so silence means the same thing in every stage. A stall is silence, not a long stage: a Run that is past its **Usual range** but still logging is **Slow**. +A live **Run** whose **Session trace** and **Command log** have both stopped growing for longer than the silent time the **User config** sets, 10 minutes unless set: it may be stuck. Stages that wait on something else, such as CI watch, write a line at intervals while they wait, so silence means the same thing in every stage. A stall is silence, not a long stage: a Run that is past its **Usual range** but still logging is **Slow**. _Avoid_: stalled, stuck, hung **Slow**: @@ -364,7 +367,7 @@ The weekly **Command** that evaluates production work. A **Grader** gives **Scor _Avoid_: grading run, tasting, production monitoring **Run metrics**: -What a **Run**'s **Command record** and **Session logs** show about how it went, such as its outcome, **Repairs**, CI rounds, **Unaddressed findings**, stage times, tokens and tool errors. Each is kept apart, never combined into one number. +What a **Run**'s **Command record** and **Session traces** show about how it went, such as its outcome, **Repairs**, CI rounds, **Unaddressed findings**, stage times, tokens and tool errors. Each is kept apart, never combined into one number. _Avoid_: Run score, KPI **Bug origin**: diff --git a/README.md b/README.md index 030922deb..b290c2e19 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Run `sudo nginx -t`, then `sudo systemctl restart nginx` on a systemd host. The - [Machine status](docs/guide.md#machine-status), [showing Commands and reading logs](docs/guide.md#show-and-read-one-command), and [stopping a Command](docs/guide.md#stopping-a-command). - [The local Console](console/README.md): `thirdshift console --no-sign-in` serves the Inbox on localhost. - [Building and testing](docs/guide.md#building-from-source) and [releasing](docs/guide.md#releasing). +- [Session traces](docs/guide.md#logs) retain each Harness event and timed work in OTLP JSON Lines, with Model evidence and review reports beside them. - [Exact prompts](prompts/README.md) and [Factory skills](skills/), also [readable on the site](https://thirdshift.app/prompts/). - [Domain vocabulary](GLOSSARY.md) and [architectural decisions](docs/adr/). - [Open issues](https://github.com/JacobStephens2/thirdshift/issues) for known gaps and planned work. diff --git a/console/README.md b/console/README.md index ccc3cda18..0b3512d8d 100644 --- a/console/README.md +++ b/console/README.md @@ -153,7 +153,7 @@ The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), Waterfall (`4`), History (`5`), Logs (`6`), Security (`7`) and Cron jobs (`8`). Inbox through Security links keep the page in `?page=`. History includes every ended Command, including old logs, and filters by outcome and repository. Security shows retained findings -and Session logs. Older records without finding metadata still show their logs. +and Session traces. Older records without finding metadata still show their logs. Cron jobs (`/cronjobs`, or `g` then `j`) shows schedules and commands from the Unix account running the Console's `crontab -l`. The same inventory is available @@ -183,27 +183,51 @@ test checks their stage times and Usual ranges against the CLI; the Playwright time tests check scale in all windows, child selection, live ordering and 390 px. Waterfall chooses one retained Command with live, died and ended chips, keeping -its exact selection in `command=`. Runs, stages and tool calls share one axis, -including a Spec run's Ticket Runs. It pairs with `show --json` and `logs`: -stage intervals come from the Command record. For a Session trace, each tool -interval uses its span's start and end, including overlapping calls and fractions -of a second. A running call uses its start record and extends to the current time -while its Command is live. A call left open in an ended or died Command retains -its start with “Time unknown” for its duration. -Only sessions with an old Session log and no Session trace use Claude/Codex tool -summaries matched to scoped `HH:MM:SS` Command-log lines in order, across midnight. -Those estimated intervals extend to the next matched call or stage boundary and -include waiting. Unmatched legacy calls stay visible with “Time unknown” and no -bar. Session links open their retained evidence. The Waterfall browser test checks -selection, span times, running calls, scale, unknown times, escaping and phone -layout; Rust checks trace timing, legacy matching and CLI parity. -The retained Spec-review checks are `python3 console/reproductions/waterfall-fractional.py` -and `python3 console/reproductions/waterfall-stage.py` from the repo root after a build. -The Rust router test walks the registered routes and checks their CLI pairs. -The fractional Run/stage boundaries also run in the Rust integration tests. -The retained Session-trace regression is -`python3 console/reproductions/waterfall-trace-axis.py`; it checks that completed -tool spans still draw bars after a Command dies without recording its end. +its exact selection in `command=`. A span tree replaces the old Run sections: +Command → Runs → stages → Sessions → model calls, turns, tools and sub-agents. +Child Runs sit under the stage that started them; reused Sessions appear only +under their first stage. Commands, Runs and stages start expanded, Sessions +folded. Calls enter the DOM only after their Session is expanded. + +The same tree and selected span details are available from the CLI: + +```sh +thirdshift show widgets#7 --spans --json +thirdshift show widgets#7 --span --json +``` + +The tree uses the recorder's stable Command, Run and stage span IDs. Each row +has its kind, status, duration and bar on a shared axis. Errors carry their +message, running calls extend to now with a hatched bar, and estimated timings +are dotted and marked `*`. Converted Session tools keep the old scoped +Command-log estimates, including unknown times. Commands without traces still +show their Runs and stages. + +The open Slowest calls list ranks ten tool and model calls in the timeline's +window; turns and sub-agents are excluded. Selecting a call reveals its row +and summary Peek. Ancestors in the Peek select their own span. The card shows +applicable timing, usage, cost, call and error facts, relative-position bars, +input/output and attributes, plus a conversation link and Copy CLI. Conversation +links open the Session because that reader does not yet anchor individual +records. Command details opens the existing Peek with Stop, logs and the PR. + +Zoom fits a parent span or a padded window around a call. The sticky axis keeps +the zoom notice and Zoom out control visible; the slowest calls re-rank within +the window. Folds, selection, zoom and Peek disclosures survive live refresh. +On a phone, labels fill one row, bars and durations the next; revealed rows +stay between the sticky axis and the Peek bottom sheet. + +Within the tree, arrows and `j` / `k` move focus, Right expands or enters a +child, Left folds or returns to the parent, Home/End reach either end, and +Enter/Space opens the Peek. Outside the tree, the Console's usual keys apply. + +Rust route/CLI parity and timing tests live in `tests/console/spans.rs` and +`tests/console/waterfall.rs`. The Playwright span and Waterfall tests cover +folding at scale, keyboard navigation, Peek, zoom, live refresh, bar geometry, +escaping and 390 px. Retained timing checks also run with +`python3 console/reproductions/waterfall-fractional.py`, +`python3 console/reproductions/waterfall-stage.py`, and +`python3 console/reproductions/waterfall-trace-axis.py` after a build. `j` / `k` select headings and visible rows, wrapping at either end. `Space` folds a heading or toggles a row's peek; `Enter` opens that row's full Command @@ -216,12 +240,12 @@ beside the list on a wide screen or at the bottom of a phone. For an exact older Command, `show` and `logs` also accept the absolute record or Command-log path. The Console adds `::name` to a record path to distinguish -a dispatched Run from its enclosing Command. A retained Session-log path can +a dispatched Run from its enclosing Command. A retained Session-trace path can select a historical Command without a record; `logs --session` accepts an exact path from that Command's Session inventory as well as a session kind. -Page 6, `/logs`, lists every Command log and Session log in the current +Page 6, `/logs`, lists every Command log and Session trace in the current `commands/` layout, including Security logs, and marks the live ones. Every -Command row links to its Run and Session logs, including child Ticket Runs. +Command row links to its Command logs and Session traces, including child Ticket Runs. Older `sessions/` folders are not linked. The same inventory is available with `thirdshift logs` or `thirdshift logs --json`; `thirdshift logs --file ` reads an exact listed file and accepts `--follow`. @@ -231,7 +255,7 @@ logs as its Console link; the inventory's `owners` field lists those exact IDs. Command logs are plain text. Session traces from all six Harnesses render their normalized messages, tool calls and results as conversations with folded tool groups and Bash, Edit, Read and Grep views. Unfamiliar events stay visible as -plain raw lines. Older Claude and Codex Session logs retain their conversation +plain raw lines. Older Claude and Codex Session traces retain their conversation rendering and collapsed thinking. A live reader uses its own SSE stream to append escaped HTML from a byte offset. Reconnecting resumes from the last event's byte offset; incomplete live lines wait until their newline arrives. diff --git a/console/reproductions/span-tree-review.cjs b/console/reproductions/span-tree-review.cjs new file mode 100644 index 000000000..4c9b3d8fd --- /dev/null +++ b/console/reproductions/span-tree-review.cjs @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const { mkdtemp, mkdir, writeFile, readFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); +const repo = resolve(__dirname, '../..'); +const binary = process.env.THIRDSHIFT_BINARY || join(repo, 'target/debug/thirdshift'); +async function reproduce(mode) { + const home = await mkdtemp(join(tmpdir(), 'spec-996-')); + let server, browser, exited; + try { + const 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'); + const start = Date.now() - 30000; + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const identity = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]}`; + const session = join(folder, '7-fixture-implement.jsonl'); + const path = join(folder, '7-fixture.command.json'); + const record = { version: 1, kind: 'Run', repository: 'acme/widgets', name: 'issue-7', hostname: 'fixture', issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Widgets' }, process_id: process.pid, process_start: identity, harness: 'codex', started_at: new Date(start).toISOString(), ended_at: null, outcome: null, session_log: ['totals', 'phone'].includes(mode) ? session : null, stages: [{ name: 'implement', began_at: new Date(start).toISOString(), session_log: ['totals', 'phone'].includes(mode) ? session : null }] }; + if (mode !== 'empty') await writeFile(path, JSON.stringify(record)); + if (['totals', 'phone'].includes(mode)) { + const attr = (key, value) => ({ key, value: { stringValue: String(value) } }); + const span = (id, parentSpanId, name, operation, attrs = []) => ({ status: mode === 'phone' && operation === 'execute_tool' ? { code: 2, message: 'tool failed' } : undefined, spanId: id, parentSpanId, name, startTimeUnixNano: String(BigInt(start) * 1000000n), endTimeUnixNano: String(BigInt(start + 20000) * 1000000n), attributes: [attr('gen_ai.operation.name', operation), ...attrs] }); + await writeFile(session.replace('.jsonl', '.logs.jsonl'), ''); + await writeFile(session.replace('.jsonl', '.spans.jsonl'), JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans: [span('1111111111111111', undefined, 'invoke_agent implement', 'invoke_agent', [attr('gen_ai.usage.input_tokens', 100), attr('gen_ai.usage.output_tokens', 25), attr('thirdshift.cost.usd', 0.01)]), ...(mode === 'phone' ? Array.from({ length: 10 }, (_, i) => span((2000+i).toString(16).padStart(16,'0'), '1111111111111111', 'execute_tool Bash', 'execute_tool')) : [span('2222222222222222', '1111111111111111', 'model response', 'chat', [attr('gen_ai.usage.input_tokens', 100), attr('gen_ai.usage.output_tokens', 25)])])] }] }] }) + '\n'); + } + server = spawn(binary, ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + const [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); + lines.close(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${origin}/waterfall`); + if (mode === 'empty') { + assert.match(await page.locator('main').textContent(), /No Commands yet/); + await writeFile(path, JSON.stringify(record)); + await page.waitForSelector('#span-tree', { state: 'attached', timeout: 10000 }); + await page.waitForFunction(() => document.querySelectorAll('#span-tree [role="treeitem"]').length > 0, null, { timeout: 5000 }); + } else if (mode === 'chips') { + await page.locator('#span-tree [role="treeitem"]').first().waitFor(); + record.ended_at = new Date().toISOString(); record.outcome = 'merged'; + await writeFile(path, JSON.stringify(record)); + await page.waitForFunction(() => document.querySelector('#span-tree')?.textContent.includes('merged') || !document.querySelector('#span-tree .span-status-running'), null, { timeout: 10000 }); + const tree = await (await page.request.get(`${origin}/spans?command=widgets%237`)).json(); + assert.equal(tree.spans[0].status, 'merged'); + assert.match(await page.locator('.command-chips [aria-current]').textContent(), /merged/, 'Command chip must reflect the completed Command after the span tree refreshes'); + } else if (mode === 'phone') { + await page.locator('#span-tree > .span-item').waitFor(); + const widths = await page.evaluate(() => ({ actual: document.documentElement.scrollWidth, expected: innerWidth })); + assert.ok(widths.actual <= widths.expected, `390 px phone overflows: ${JSON.stringify(widths)}`); + } else if (mode === 'totals') { + await page.locator('#span-tree > .span-item > .span-row').click(); + await page.locator('#peek-content h2').waitFor(); + const tiles = await page.locator('#peek-content .span-tile').allTextContents(); + assert.ok(tiles.some(text => /100\s*Tokens in/.test(text)), `Command summary must include its 100 input tokens once, got ${JSON.stringify(tiles)}`); + assert.ok(tiles.some(text => /25\s*Tokens out/.test(text)), `Command summary must include its 25 output tokens once, got ${JSON.stringify(tiles)}`); + assert.ok(tiles.some(text => /0\.01\s*Cost/.test(text)), `Command summary must include its session cost, got ${JSON.stringify(tiles)}`); + } else throw Error('Expected empty, chips, or totals'); + console.log(`PASS ${mode}`); + } finally { + await browser?.close(); + if (server?.exitCode === null) server.kill(); + await exited; + await rm(home, { recursive: true, force: true }); + } +} +module.exports = reproduce; +if (require.main === module) reproduce(process.argv[2]).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/console/reproductions/waterfall-fractional.py b/console/reproductions/waterfall-fractional.py index c0fbab232..ef3ad6119 100644 --- a/console/reproductions/waterfall-fractional.py +++ b/console/reproductions/waterfall-fractional.py @@ -1,4 +1,4 @@ -import json, os, pathlib, re, subprocess, tempfile, urllib.request +import json, os, pathlib, subprocess, tempfile, urllib.request binary = pathlib.Path(__file__).resolve().parents[2] / 'target/debug/thirdshift' with tempfile.TemporaryDirectory(prefix='827-spec-') as temp: @@ -6,8 +6,18 @@ folder = home / '.thirdshift/logs/acme/widgets/commands/issue' folder.mkdir(parents=True) (home / '.thirdshift/config.toml').write_text('[console]\nport = 0\n') - session = folder / '7-20261001T120000+0000-implement.jsonl' - session.write_text(json.dumps({'type':'item.completed','item':{'id':'a','type':'command_execution','command':'rg widgets','status':'completed'}})+'\n') + session = folder / '7-20261001T120000+0000-implement.logs.jsonl' + session.write_text('') + session.with_name(session.name.replace('.logs.jsonl', '.spans.jsonl')).write_text(json.dumps({ + 'resourceSpans': [{'scopeSpans': [{'spans': [ + {'spanId': '1111111111111111', 'name': 'invoke_agent implement', + 'startTimeUnixNano': '1790856000100000000', 'endTimeUnixNano': '1790856060000000000', + 'attributes': [{'key': 'gen_ai.operation.name', 'value': {'stringValue': 'invoke_agent'}}]}, + {'spanId': '2222222222222222', 'parentSpanId': '1111111111111111', 'name': 'execute_tool shell', + 'startTimeUnixNano': '1790856000100125000', 'endTimeUnixNano': '1790856000250375000', + 'attributes': [{'key': 'gen_ai.operation.name', 'value': {'stringValue': 'execute_tool'}}]} + ]}]}] + }) + '\n') record = dict(version=1,kind='Run',repository='acme/widgets',name='run-7', issue=dict(number=7,url='https://github.com/acme/widgets/issues/7',title='Widgets'), hostname='fixture',process_id=0,process_start='gone',harness='codex', @@ -19,10 +29,11 @@ server = subprocess.Popen([str(binary),'console','--no-sign-in'],env={**os.environ,'HOME':str(home)},stdout=subprocess.PIPE,text=True) try: origin = server.stdout.readline().strip() - html = urllib.request.urlopen(origin+'/waterfall?command=widgets%237').read().decode() - tools = re.findall(r'
  • ')[1:] - assigned = [re.search(r'

    ([^<]*)

    ',stage).group(1) for stage in stages if 'class="waterfall-tool"' in stage] + tree = json.load(urllib.request.urlopen(origin+'/spans?command=widgets%237')) + spans = {span['id']: span for span in tree['spans']} + def stage_of(span): + while span['kind'] != 'stage': + span = spans[span['parent_id']] + return span['name'] + assigned = [stage_of(span) for span in tree['spans'] if span['kind'] == 'tool'] print('Stage containing implement tool:',assigned,flush=True) - assert assigned == ['implement'], 'Session tool must nest under implement; whole-second clock incorrectly puts it in previous Setup stage' + assert assigned == ['implement'], 'The Session trace must nest its tool under implement despite the fractional stage start' finally: server.terminate() server.wait(timeout=10) diff --git a/console/reproductions/waterfall-trace-axis.py b/console/reproductions/waterfall-trace-axis.py index bd150e167..185ae505b 100644 --- a/console/reproductions/waterfall-trace-axis.py +++ b/console/reproductions/waterfall-trace-axis.py @@ -1,5 +1,5 @@ """Retained Spec-review reproduction: measured spans survive a died Command.""" -import json, os, pathlib, re, subprocess, tempfile, urllib.request +import json, os, pathlib, subprocess, tempfile, urllib.request binary = pathlib.Path('target/debug/thirdshift').resolve() with tempfile.TemporaryDirectory(prefix='989-spec-died-') as temp: @@ -29,14 +29,11 @@ ) try: origin = server.stdout.readline().strip() - html = urllib.request.urlopen(origin + '/waterfall?command=widgets%237').read().decode() - tools = re.findall( - r'
  • ' - r'.*? 0, 'Ended tool span must draw a visible bar for a died Command' + tree = json.load(urllib.request.urlopen(origin + '/spans?command=widgets%237')) + tools = [span for span in tree['spans'] if span['kind'] == 'tool' and span['end'] is not None] + print('Retained tool timing:', tools) + assert len(tools) == 2 and all(span['end'] > span['start'] for span in tools), 'Ended tool spans must retain visible intervals for a died Command' + # console/tests/waterfall.test.cjs also checks the rendered bar widths. finally: server.terminate() server.wait(timeout=10) diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index e73e13afd..0552069fd 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -7,6 +7,7 @@ const { tmpdir } = require("node:os"); const { join, resolve } = require("node:path"); const { createInterface } = require("node:readline"); const { chromium } = require("playwright"); +const { record: traceRecord } = require("./session-trace.cjs"); let home, server, exited, browser, origin; before(async () => { @@ -29,8 +30,8 @@ before(async () => { } const security = join(configDir, 'logs/acme/widgets/commands/secure'); await mkdir(security, { recursive: true }); - const session = join(security, 'secure-fixture-security-audit.jsonl'); - await writeFile(session, 'Security Session output\n'); + const session = join(security, 'secure-fixture-security-audit.logs.jsonl'); + await writeFile(session, traceRecord('Security Session output')); await writeFile(join(security, 'secure-fixture.log'), 'Security Command output\n'); await writeFile(join(security, 'secure-fixture.command.json'), JSON.stringify({ version: 1, kind: 'Security run', name: 'secure-fixture', repository: 'acme/widgets', @@ -373,8 +374,8 @@ for (const storage of ['blocked', 'malformed']) { }); } -for (const trace of [false, true]) { - test(`heartbeat watches ${trace ? 'Session trace records' : 'old Session logs'} and is flat when Silent`, { skip: process.platform !== 'linux' }, async t => { +{ + test('heartbeat watches Session trace records and is flat when Silent', { skip: process.platform !== 'linux' }, async t => { // This fixture borrows the Console's real native process identity. HTTP // coverage uses an actual held Run on both Linux and macOS. const stat = await readFile(`/proc/${server.pid}/stat`, 'utf8'); @@ -382,15 +383,13 @@ for (const trace of [false, true]) { const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); const path = join(dir, '202-fixture.command.json'); const log = join(dir, '202-fixture.log'); - const sessionLog = join(home, 'heartbeat-session.jsonl'); - const session = trace ? join(home, 'heartbeat-session.logs.jsonl') : sessionLog; - const event = text => trace ? JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ body: { stringValue: text } }] }] }] }) + '\n' : text; - if (trace) await writeFile(sessionLog, 'Stale Session log'); + const session = join(home, 'heartbeat-session.logs.jsonl'); + const event = traceRecord; const fixture = JSON.parse(await readFile(join(dir, '1-fixture.command.json'), 'utf8')); - Object.assign(fixture, { kind: 'Run', process_id: server.pid, process_start: start, outcome: null, ended_at: null, session_log: sessionLog }); + Object.assign(fixture, { kind: 'Run', process_id: server.pid, process_start: start, outcome: null, ended_at: null, session_log: session }); fixture.issue.number = 202; const began = new Date(Date.now() - 20 * 60 * 1000).toISOString(); - fixture.stages = [{ name: 'implement', began_at: began, session_log: sessionLog }]; + fixture.stages = [{ name: 'implement', began_at: began, session_log: session }]; await writeFile(path, JSON.stringify(fixture)); await writeFile(log, 'command'); await writeFile(session, event('session')); @@ -443,7 +442,7 @@ for (const trace of [false, true]) { await page.emulateMedia({ reducedMotion: 'no-preference' }); assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'an ended Command must stop the garden and conveyor'); } finally { - await Promise.all([path, log, session, sessionLog].map(path => rm(path, { force: true }))); + await Promise.all([path, log, session].map(path => rm(path, { force: true }))); await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); } }); @@ -629,7 +628,7 @@ test('History filters and Security findings and sessions work from a phone', asy assert.match(await page.title(), /^Security ·/); assert.deepEqual(streams, []); assert.deepEqual(errors, []); - await page.locator('#peek a').filter({ hasText: 'Session log:' }).click(); + await page.locator('#peek a').filter({ hasText: 'Session trace:' }).click(); await page.waitForURL('**/log?*'); assert.match(await page.locator('body').textContent(), /Security Session output/); }); @@ -638,7 +637,7 @@ test("Enter activates the focused Security session link", async t => { const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); await page.goto(`${origin}/?page=security`); await page.locator('.peek-link').click(); - const sessionLink = page.locator('#peek a').filter({ hasText: 'Session log:' }); + const sessionLink = page.locator('#peek a').filter({ hasText: 'Session trace:' }); await sessionLink.waitFor(); await sessionLink.focus(); await page.keyboard.press('Enter'); diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs index 5ff2f7c6a..eb5923d53 100644 --- a/console/tests/logs.test.cjs +++ b/console/tests/logs.test.cjs @@ -7,13 +7,13 @@ const { tmpdir } = require("node:os"); const { join, resolve } = require("node:path"); const { createInterface } = require("node:readline"); const { chromium } = require("playwright"); +const { record: traceRecord } = require("./session-trace.cjs"); -for (const storage of ['Session log', 'Session trace', 'Session trace only']) { - describe(storage, () => { + describe('Session trace', () => { let home, server, exited, browser, origin, folder, log, recordPath, record; const encode = event => { const raw = JSON.stringify(event); - return (storage === 'Session log' ? raw : JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ body: { stringValue: raw } }] }] }] })) + '\n'; + return traceRecord(raw); }; const message = text => encode({ type: "item.completed", item: { id: text, type: "agent_message", text } }); const tool = text => encode({ type: "item.completed", item: { id: text, type: "command_execution", command: text, aggregated_output: "output", status: "completed", exit_code: 0 } }); @@ -23,9 +23,8 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { await mkdir(folder, { recursive: true }); await writeFile(join(home, ".thirdshift/config.toml"), "[console]\nport = 0\n"); const stem = "7-20261003T120000-0400~fixture"; - const sessionLog = join(folder, `${stem}-implement.jsonl`); - log = storage === 'Session log' ? sessionLog : join(folder, `${stem}-implement.logs.jsonl`); - if (storage === 'Session trace') await writeFile(sessionLog, 'Stale Session log\n'); + const sessionLog = join(folder, `${stem}-implement.logs.jsonl`); + log = sessionLog; recordPath = join(folder, `${stem}.command.json`); await writeFile(log, message("Initial retained line") + message("Tall initial message\n".repeat(150)) + tool("first tool")); await writeFile(join(folder, `${stem}.log`), "Command output\n"); @@ -66,7 +65,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { page.on("pageerror", error => errors.push(error.message)); 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: /Session trace/ }).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(); @@ -131,7 +130,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { const page = await browser.newPage(); t.after(() => page.close()); await page.goto(`${origin}/logs`); - await page.getByRole('link', { name: /Session log/ }).click(); + await page.getByRole('link', { name: /Session trace/ }).click(); await page.getByText('Patch unavailable in this log.', { exact: true }).waitFor({ state: 'attached' }); await appendFile(log, encode({ type: 'tool_call_ended', id: 'patch-live', tool: 'apply_patch', result: { source: 'codex_session_file', changes: [{ path: 'widgets.rs', kind: 'update', @@ -148,7 +147,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { assert.equal(await page.getByText('Patch unavailable in this log.', { exact: true }).count(), 0); }); - test("Session log polls appended lines when EventSource is unavailable", { skip: process.platform !== "linux" }, async t => { + test("Session trace polls appended lines when EventSource is unavailable", { skip: process.platform !== "linux" }, async t => { await appendFile(log, "\n"); // Finish the preceding test's unterminated final event. record.outcome = null; record.ended_at = null; @@ -170,7 +169,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { }; }); await page.goto(`${origin}/logs`); - await page.getByRole('link', { name: /Session log/ }).click(); + await page.getByRole('link', { name: /Session trace/ }).click(); const initial = await page.locator('#log-content .message').first().elementHandle(); await appendFile(log, message('Visible through polling fallback')); await page.getByText('Visible through polling fallback', { exact: true }).waitFor({ timeout: 12000 }); @@ -201,7 +200,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { finally { finishedPoll(); } }); await page.goto(`${origin}/logs`); - await page.getByRole('link', { name: /Session log/ }).click(); + await page.getByRole('link', { name: /Session trace/ }).click(); await page.waitForFunction(() => window.readerSource); const path = await page.locator('#log-content').getAttribute('data-stream'); await appendFile(log, message('First line after disconnect')); @@ -227,7 +226,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { 'Recovered SSE plus an older overlapping poll must append each Session line once'); }); - test('stable normalized messages update across stream batches and reload', { skip: process.platform !== 'linux' || storage === 'Session log' }, async t => { + test('stable normalized messages update across stream batches and reload', { skip: process.platform !== 'linux' }, async t => { record.outcome = null; record.ended_at = null; await writeFile(recordPath, JSON.stringify(record)); @@ -242,7 +241,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { t.after(() => context.close()); const page = await context.newPage(); await page.goto(`${origin}/logs`); - await page.getByRole('link', { name: /Session log/ }).click(); + await page.getByRole('link', { name: /Session trace/ }).click(); const original = await page.getByText('Snapshot first', { exact: true }).locator('..').elementHandle(); await appendFile(log, snapshot('Snapshot first complete')); await page.getByText('Snapshot first complete', { exact: true }).waitFor(); @@ -276,7 +275,7 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { }; }); await page.goto(`${origin}/logs`); - await page.getByRole('link', { name: /Session log/ }).click(); + await page.getByRole('link', { name: /Session trace/ }).click(); await page.waitForFunction(() => window.recoverySource?.opened); await page.evaluate(() => window.recoverySource.pause()); await appendFile(log, message('Delivered by polling')); @@ -290,4 +289,3 @@ for (const storage of ['Session log', 'Session trace', 'Session trace only']) { `SSE replayed a line already appended by polling: ${JSON.stringify(visible)}`); }); }); -} diff --git a/console/tests/session-trace.cjs b/console/tests/session-trace.cjs new file mode 100644 index 000000000..d6c95f252 --- /dev/null +++ b/console/tests/session-trace.cjs @@ -0,0 +1,8 @@ +// Session-trace fixtures retain raw Harness events inside OTLP log records. +function record(raw) { + return JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ + body: { stringValue: raw } + }] }] }] }) + '\n'; +} + +module.exports = { record }; diff --git a/console/tests/spans.test.cjs b/console/tests/spans.test.cjs new file mode 100644 index 000000000..4d408edd7 --- /dev/null +++ b/console/tests/spans.test.cjs @@ -0,0 +1,176 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = 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'); +const reproduceReviewFinding = require('../reproductions/span-tree-review.cjs'); + +for (const mode of ['empty', 'chips', 'totals']) { + test(`Spec regression: ${mode}`, { skip: process.platform !== 'linux' }, async () => { + await reproduceReviewFinding(mode); + }); +} + +let home, server, exited, browser, origin, folder, recordPath, session; +const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); +const attr = (key, value) => ({ key, value: typeof value === 'boolean' ? { boolValue: value } : { stringValue: String(value) } }); +const nano = ms => String(BigInt(ms) * 1000000n); +const span = (id, parent, name, start, end, attributes = [], status) => ({ + spanId: id, parentSpanId: parent, name, startTimeUnixNano: nano(start), + endTimeUnixNano: end ? nano(end) : '0', attributes, status +}); +const ids = { session: '1111111111111111', agent: '2222222222222222', tool: '3333333333333333', model: '4444444444444444', turn: '5555555555555555' }; +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-spans-')); + 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'); + const start = Date.parse('2026-10-01T12:00:00Z'); + session = join(folder, '99-20261001T120000+0000-implement.logs.jsonl'); + const record = { + version: 1, kind: 'Spec run', repository: 'acme/widgets', name: 'spec-7', hostname: 'fixture', + issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Widgets ' }, + process_id: 0, process_start: 'gone', harness: 'codex', started_at: new Date(start).toISOString(), + ended_at: new Date(start + 60000).toISOString(), outcome: 'merged', session_log: null, + stages: [{ name: 'Tickets', began_at: new Date(start).toISOString(), session_log: null }] + }; + recordPath = join(folder, '7-fixture.command.json'); + await writeFile(recordPath, JSON.stringify(record)); + await writeFile(join(folder, '7-fixture.issue-99.child.json'), JSON.stringify({ + ...record, kind: 'Run', name: 'ticket-99', issue: { ...record.issue, number: 99 }, parent_record: recordPath, + session_log: session, stages: [ + { name: 'implement', began_at: new Date(start + 1000).toISOString(), session_log: session }, + { name: 'pull request', began_at: new Date(start + 55000).toISOString(), session_log: session } + ] + })); + const spans = [ + span(ids.session, undefined, 'invoke_agent implement', start + 1000, start + 55000, [attr('gen_ai.operation.name', 'invoke_agent'), attr('thirdshift.session.prompt', 'Build '), attr('thirdshift.session.kind', 'implement')]), + span(ids.agent, ids.session, 'agent reviewer', start + 3000, start + 50000, [attr('thirdshift.type', 'subagent_started'), attr('thirdshift.description', 'Review widgets')]), + span(ids.tool, ids.agent, 'execute_tool Bash', start + 5000, start + 25000, [attr('gen_ai.operation.name', 'execute_tool'), attr('gen_ai.tool.name', 'Bash'), attr('gen_ai.tool.call.arguments', '{"command":"cargo test "}'), attr('gen_ai.tool.call.result', 'Tests failed'), attr('thirdshift.time.estimated', true)], { code: 2, message: 'test failed' }), + span(ids.model, ids.session, 'model response', start + 30000, start + 45000, [attr('gen_ai.operation.name', 'chat'), attr('gen_ai.response.model', 'test-model'), attr('gen_ai.usage.input_tokens', 100), attr('gen_ai.usage.output_tokens', 25), attr('thirdshift.cost.usd', '0.01')]), + span(ids.turn, ids.session, 'turn response', start + 1000, start + 55000, [attr('thirdshift.type', 'turn_ended')]), + ...Array.from({ length: 300 }, (_, i) => span((1000 + i).toString(16).padStart(16, '0'), ids.session, `execute_tool Read ${i}`, start + 46000, start + 46010, [attr('gen_ai.operation.name', 'execute_tool')])) + ]; + await writeFile(session, ''); + await writeFile(session.replace('.logs.jsonl', '.spans.jsonl'), JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans }] }] }) + '\n'); + server = spawn(binary, ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + 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 }); +}); + +test('span tree, summary Peek, zoom and keyboard work at laptop and 390 px', async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + for (const width of [1200, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto(`${origin}/waterfall?command=widgets%237`); + const tree = page.getByRole('tree', { name: 'Command spans' }); + await tree.waitFor(); + assert.equal(await tree.getByRole('treeitem').count(), 7, 'calls stay out of the DOM while their session is folded'); + const sess = tree.locator(`[data-span-id="${ids.session}"]`); + assert.equal(await sess.getAttribute('aria-expanded'), 'false'); + assert.match(await sess.textContent(), /#1/); + assert.match(await sess.textContent(), /1 error/); + const slow = page.locator('#slowest-calls'); + assert.equal(await slow.locator('[data-reveal-span]').count(), 10); + assert.match(await slow.locator('[data-reveal-span]').first().textContent(), /Bash/); + assert.equal(await slow.locator(`[data-reveal-span="${ids.turn}"]`).count(), 0); + await slow.locator(`[data-reveal-span="${ids.tool}"]`).click(); + const tool = tree.locator(`[data-span-id="${ids.tool}"]`); + await page.locator('#peek-content h2').waitFor(); + assert.equal(await tool.getAttribute('aria-selected'), 'true'); + assert.match(await page.locator('#peek-content').textContent(), /cargo test /); + assert.match(await page.locator('#peek-content').textContent(), /test failed/); + assert.equal(await page.locator('#peek-content details[open] summary').textContent(), 'Input'); + const copy = page.getByRole('button', { name: 'Copy CLI', exact: true }); + assert.match(await copy.getAttribute('data-copy-cli'), /show .* --span 3333333333333333 --json/); + const data = JSON.parse(execFileSync(binary, ['show', 'widgets#7', '--span', ids.tool, '--json'], { env: { ...process.env, HOME: home }, encoding: 'utf8' })); + assert.equal(data.error, 'test failed'); + await page.getByRole('button', { name: 'Zoom timeline around this call', exact: true }).click(); + assert.match(await page.locator('#span-axis').textContent(), /Timeline zoomed around/); + assert.ok(await tree.locator('.outside-window').count() > 0); + const row = await tool.boundingBox(); + const axis = await page.locator('#span-axis').boundingBox(); + const peek = await page.locator('#peek').boundingBox(); + assert.ok(row.y >= Math.max(0, axis.y + axis.height) - 1); + assert.ok(row.y + row.height <= (width === 390 ? peek.y : 844) + 1); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.equal(await page.locator('#peek-content unsafe, main unsafe').count(), 0); + await page.locator('#span-axis').getByRole('button', { name: 'Zoom out to whole Command' }).click(); + await tool.focus(); + await page.keyboard.press('ArrowLeft'); + assert.equal(await page.evaluate(() => document.activeElement.dataset.spanId), ids.agent); + await page.keyboard.press('ArrowLeft'); + assert.equal(await tree.locator(`[data-span-id="${ids.agent}"]`).getAttribute('aria-expanded'), 'false'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + assert.equal(await tool.getAttribute('aria-selected'), 'true'); + if (width === 390) { + // Even the last row needs scroll space above the bottom sheet. + const last = tree.locator('.span-item[data-kind="tool"]').last(); + await last.locator(':scope > .span-row').click(); + await page.waitForFunction(() => { + const row = document.querySelector('#span-tree [role="treeitem"][aria-selected="true"] > .span-row').getBoundingClientRect(); + const sheet = document.getElementById('peek').getBoundingClientRect(); + const axis = document.getElementById('span-axis').getBoundingClientRect(); + return row.top >= Math.max(0, axis.bottom) - 1 && row.bottom <= sheet.top + 1; + }); + } + await page.getByRole('button', { name: 'Command details', exact: true }).click(); + await page.locator('#peek-content .facts, #peek-content dl').first().waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /ticket-99/); + await page.keyboard.press('Escape'); + await page.keyboard.press('1'); + await page.waitForURL('**page=inbox'); + } + assert.deepEqual(errors, []); +}); + +test('refresh preserves folds, selected span, zoom and open Peek', async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(`${origin}/waterfall?command=widgets%237`); + await page.locator(`#slowest-calls [data-reveal-span="${ids.model}"]`).click(); + await page.getByRole('button', { name: 'Zoom timeline around this call' }).click(); + await page.locator('#peek-content details').filter({ hasText: /^Output/ }).locator('summary').click(); + const original = await readFile(recordPath, 'utf8'); + const record = JSON.parse(original); + record.issue.title = 'Widgets refreshed'; + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + record.process_id = process.pid; + record.process_start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]}`; + record.outcome = null; + record.ended_at = null; + } else { + // The observer publishes changes in its Inbox; this old fixture otherwise + // lives only in History. Move it into the recent Done group on other OSes. + record.ended_at = new Date().toISOString(); + } + await writeFile(recordPath, JSON.stringify(record)); + await page.waitForFunction(() => document.querySelector('#span-tree')?.textContent.includes('Widgets refreshed')); + assert.equal(await page.locator(`[data-span-id="${ids.session}"]`).getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator(`[data-span-id="${ids.model}"]`).getAttribute('aria-selected'), 'true'); + assert.match(await page.locator('#span-axis').textContent(), /Timeline zoomed around/); + assert.equal(await page.locator('#peek').isVisible(), true); + assert.equal(await page.locator('#peek-content details').filter({ hasText: /^Output/ }).getAttribute('open'), ''); + await writeFile(recordPath, original); +}); diff --git a/console/tests/stop.test.cjs b/console/tests/stop.test.cjs index 66775673f..b383e8b2a 100644 --- a/console/tests/stop.test.cjs +++ b/console/tests/stop.test.cjs @@ -72,7 +72,10 @@ for (const requeue of [false, true]) { await page.locator('[data-group="working"] > summary').click(); if (!requeue) await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); } - await page.locator('.peek-link').first().click(); + if (await page.locator('#span-tree').count()) { + await page.locator('.span-row').first().click(); + await page.getByRole('button', { name: 'Command details', exact: true }).click(); + } else 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(); @@ -83,7 +86,8 @@ for (const requeue of [false, true]) { 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.locator('.span-row').first().click(); + await observer.getByRole('button', { name: 'Command details', exact: true }).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(); diff --git a/console/tests/waterfall.test.cjs b/console/tests/waterfall.test.cjs index c0d118d8b..b52e64ebd 100644 --- a/console/tests/waterfall.test.cjs +++ b/console/tests/waterfall.test.cjs @@ -29,9 +29,22 @@ before(async () => { }; const parent = join(folder, '7-fixture.command.json'); await writeFile(parent, JSON.stringify(record)); - const session = join(folder, '99-20261001T120000+0000-implement.jsonl'); - const item = (id, command) => JSON.stringify({ type: 'item.completed', item: { id, type: 'command_execution', command, status: 'completed' } }) + '\n'; - await writeFile(session, item('a', 'first ') + item('b', 'unmatched') + item('c', 'last')); + const session = join(folder, '99-20261001T120000+0000-implement.logs.jsonl'); + const spans = [ + ['a', 'first ', '1790856015000000000', '1790856030000000000'], + ['b', 'unmatched', '0', '0'], + ['c', 'last', '1790856030000000000', '1790856040000000000'], + ].map(([id, command, start, end], index) => ({ + spanId: (index + 1).toString(16).padStart(16, '0'), name: `execute_tool Bash ${command}`, + startTimeUnixNano: start, endTimeUnixNano: end, + attributes: [ + { key: 'gen_ai.operation.name', value: { stringValue: 'execute_tool' } }, + { key: 'gen_ai.tool.call.id', value: { stringValue: id } }, + { key: 'gen_ai.tool.name', value: { stringValue: 'Bash' } }, + ] + })); + await writeFile(session, ''); + await writeFile(session.replace('.logs.jsonl', '.spans.jsonl'), JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans }] }] }) + '\n'); await writeFile(join(folder, '7-fixture.issue-99.child.json'), JSON.stringify({ ...record, kind: 'Run', name: 'ticket-99', issue: { ...record.issue, number: 99 }, parent_record: parent, started_at: '2026-10-01T12:00:10+00:00', ended_at: '2026-10-01T12:00:40+00:00', session_log: session, @@ -78,33 +91,41 @@ test('Waterfall draws overlapping Session-trace spans and running start records' for (const width of [1200, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto(`${origin}/waterfall?command=widgets%2310`); - const tools = page.locator('.waterfall-tool'); + await page.getByRole('button', { name: 'Every call', exact: true }).click(); + const tools = page.locator('.span-item[data-kind="tool"]'); assert.equal(await tools.count(), 4); - assert.equal(await tools.nth(0).getAttribute('data-start'), '2026-10-01T12:00:15.250+00:00'); - assert.equal(await tools.nth(0).getAttribute('data-seconds'), '3.5'); - assert.equal(await tools.nth(1).getAttribute('data-start'), '2026-10-01T12:00:16.500+00:00'); - assert.equal(await tools.nth(1).getAttribute('data-seconds'), '0.625'); - const track = await tools.nth(0).locator('.timeline').boundingBox(); - const first = await tools.nth(0).locator('.timeline rect').boundingBox(); - const second = await tools.nth(1).locator('.timeline rect').boundingBox(); + const data = await (await context.request.get(`${origin}/spans?command=widgets%2310`)).json(); + const spans = data.spans.filter(span => span.kind === 'tool').sort((a, b) => a.start - b.start); + assert.equal(spans[0].start, Date.parse('2026-10-01T12:00:15.250+00:00')); + assert.equal(spans[0].duration_ms, 3500); + assert.equal(spans[1].start, Date.parse('2026-10-01T12:00:16.500+00:00')); + assert.equal(spans[1].duration_ms, 625); + const track = await tools.nth(0).locator('.span-track').boundingBox(); + const first = await tools.nth(0).locator('.span-bar').boundingBox(); + const second = await tools.nth(1).locator('.span-bar').boundingBox(); assert.ok(Math.abs(first.width - track.width * 3.5 / 60) <= 1.01); assert.ok(Math.abs(second.width - track.width * 0.625 / 60) <= 1.01); assert.ok(Math.abs((first.x - track.x) / track.width - 15.25 / 60) < 0.002); assert.ok(first.x + first.width > second.x); - assert.equal(await tools.nth(2).getAttribute('data-start'), '2026-10-01T12:00:20.125+00:00'); - assert.equal(await tools.nth(2).getAttribute('data-seconds'), ''); + assert.equal(spans[2].start, Date.parse('2026-10-01T12:00:20.125+00:00')); + assert.equal(spans[2].duration_ms, null); assert.match(await tools.nth(2).textContent(), /Time unknown/); - assert.equal(await page.locator('main script, main unsafe').count(), 0); + assert.equal(await page.locator('main script:not([type="application/json"]), main unsafe').count(), 0); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); if (process.platform === 'linux') { await page.goto(`${origin}/waterfall?command=widgets%239`); - const running = page.locator('.waterfall-tool'); + await page.getByRole('button', { name: 'Every call', exact: true }).click(); + const running = page.locator('.span-item[data-kind="tool"]'); assert.equal(await running.count(), 1); assert.match(await running.textContent(), /still running/); - assert.ok(Date.parse(await running.getAttribute('data-start')) > Date.now() - 120000); - const seconds = Number(await running.getAttribute('data-seconds')); + const live = await (await context.request.get(`${origin}/spans?command=widgets%239`)).json(); + const call = live.spans.find(span => span.kind === 'tool'); + assert.ok(call.start > Date.now() - 120000); + const seconds = (Date.now() - call.start) / 1000; assert.ok(seconds >= 10 && seconds < 120, `${seconds} seconds`); - assert.ok((await running.locator('.timeline rect').boundingBox()).width > 0); + const initial = (await running.locator('.span-duration').textContent()); + await page.waitForFunction(initial => document.querySelector('.span-item[data-kind="tool"] .span-duration')?.textContent !== initial, initial); + assert.ok((await running.locator('.span-bar').boundingBox()).width > 0); } } const recordPath = join(home, '.thirdshift/logs/acme/widgets/commands/issue/10-fixture.command.json'); @@ -115,9 +136,10 @@ test('Waterfall draws overlapping Session-trace spans and running start records' for (const width of [1200, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto(`${origin}/waterfall?command=widgets%2310`); - assert.match(await page.locator('.waterfall-run h3').textContent(), /died/); + await page.getByRole('button', { name: 'Every call', exact: true }).click(); + assert.match(await page.locator('.command-chips [aria-current]').textContent(), /died/); for (const index of [0, 1]) { - assert.ok((await page.locator('.waterfall-tool .timeline rect').nth(index).boundingBox()).width > 0); + assert.ok((await page.locator('.span-item[data-kind="tool"] .span-bar').nth(index).boundingBox()).width > 0); } } }); @@ -142,24 +164,26 @@ test('Waterfall chips, nested scale and unknown tools work at desktop and 390 px assert.match(await chips.textContent(), /acme\/widgets#8 · died/); if (process.platform === 'linux') assert.match(await chips.textContent(), /acme\/widgets#9 · live/); await chips.getByRole('link', { name: 'acme/widgets#7 · merged' }).click(); - assert.equal(await page.locator('.waterfall-run').count(), 2); - const child = page.locator('.waterfall-run[data-depth="1"]'); - assert.equal(await child.locator('.waterfall-tool').count(), 3); - assert.match(await child.locator('.waterfall-tool').nth(1).textContent(), /Time unknown/); - const track = await child.locator(':scope > .timeline').boundingBox(); - const run = await child.locator(':scope > .timeline rect').boundingBox(); - const stageTrack = await child.locator('.waterfall-stage > .timeline').boundingBox(); - const toolTrack = await child.locator('.waterfall-tool .timeline').first().boundingBox(); - const first = await child.locator('.waterfall-tool .timeline rect').first().boundingBox(); + await page.getByRole('button', { name: 'Every call', exact: true }).click(); + assert.equal(await page.locator('.span-item[data-kind="run"]').count(), 2); + const child = page.locator('.span-item[data-kind="run"]').nth(1); + assert.equal(await child.locator('.span-item[data-kind="tool"]').count(), 3); + assert.match(await child.locator('.span-item[data-kind="tool"]').last().textContent(), /Time unknown/); + const track = await child.locator(':scope > .span-row > .span-track').boundingBox(); + const run = await child.locator(':scope > .span-row .span-bar').boundingBox(); + const stageTrack = await child.locator('.span-item[data-kind="stage"] > .span-row > .span-track').boundingBox(); + const toolTrack = await child.locator('.span-item[data-kind="tool"] .span-track').first().boundingBox(); + const first = await child.locator('.span-item[data-kind="tool"] .span-bar').first().boundingBox(); assert.ok(Math.abs(run.width - track.width / 2) <= 1.01); assert.ok(Math.abs(first.width - track.width / 4) <= 1.01); assert.ok(Math.abs((first.x - track.x) / track.width - 0.25) < 0.002); assert.equal(track.x, stageTrack.x); assert.equal(track.x, toolTrack.x); assert.equal(track.width, toolTrack.width); - assert.equal(await page.locator('main script, main unsafe').count(), 0); + assert.equal(await page.locator('main script:not([type="application/json"]), main unsafe').count(), 0); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); - await child.locator('.peek-link').click(); + await child.locator(':scope > .span-row').click(); + await page.getByRole('button', { name: 'Command details', exact: true }).click(); await page.locator('#peek-content h2').waitFor(); assert.match(await page.locator('#peek-content').textContent(), /ticket-99/); await page.keyboard.press('Escape'); diff --git a/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md b/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md index 0d81b8db6..a90ef81ba 100644 --- a/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md +++ b/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md @@ -15,6 +15,6 @@ This replaces ADR 0009's consequence that a skip leaves only its line in the sch ## Consequences -- The repository in the path is the GitHub repository, `/`, not the name of the local checkout, so log file names drop it: `commands/pickup/41-~.log`. Command logs are exclusively allocated with an immutable random owner token, so Commands sharing a start stamp retain separate output and records. Session logs share the captured stamp and token, as `commands/pickup/41-~-implement.jsonl`, including across child Runs. Model sidecars and review reports use that Session's owned stem. Start stamps remain display and grouping facts. Existing legacy Command logs, Session logs and records remain readable without migration. +- The repository in the path is the GitHub repository, `/`, not the name of the local checkout, so log file names drop it: `commands/pickup/41-~.log`. Command logs are exclusively allocated with an immutable random owner token, so Commands sharing a start stamp retain separate output and records. Session logs share the captured stamp and token, as `commands/pickup/41-~-implement.jsonl`, including across child Runs. Model sidecars and review reports use that Session's owned stem. Start stamps remain display and grouping facts. Existing legacy Command logs and records remain readable without migration. **Amended 2026-10-23 (#700, ADR 0019):** legacy Session logs were converted on both hosts in #994 before #997 removed their reader and the conversion commands. Retained Command records may still name an original Session-log path; readers resolve that reference to its converted Session trace. - Whether the scheduler is still firing is no longer a line per pass: an idle repository's Activity log ends on one skip line. - Logs written before this, under `/sessions/` and `/commands/`, are not moved by thirdshift. diff --git a/docs/adr/0021-a-command-may-run-its-security-review-on-another-harness.md b/docs/adr/0021-a-command-may-run-its-security-review-on-another-harness.md index ddf1795f7..25b5cda02 100644 --- a/docs/adr/0021-a-command-may-run-its-security-review-on-another-harness.md +++ b/docs/adr/0021-a-command-may-run-its-security-review-on-another-harness.md @@ -16,4 +16,4 @@ This is the first step towards choosing a Harness per session. The direction is - A Command checks its own Harness and its Security review's before work, including non-billed sign-in status where the CLI supports it. If a non-default review Harness can't run, the review uses `harness.default` (Claude Code when unset) with its own configured Model and Effort, warning with both Harnesses and the reason. If that default can't run, preflight fails. A Pass checks the review only when it may dispatch a Run. A Spec run hands the settled choice and the default fallback to every Ticket, and reviews each Ticket and the Spec PR, as #975's addendum requires. - Both Harnesses' Factory skills are linked into the worktree. -- The review's Session log, its `.models.json`, the Command log's line for it and the Run notification's session models name the Harness it ran on. The `Built with` line in the pull request and Run notification still names the Command's Harness. +- The review's Session trace, its `.models.json`, the Command log's line for it and the Run notification's session models name the Harness it ran on. The `Built with` line in the pull request and Run notification still names the Command's Harness. diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 428064693..3edbb87ab 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -19,20 +19,20 @@ Start with the issue's terms in `GLOSSARY.md`, then use this index to find their | Harness, Model, Effort | `src/harness.rs` and `src/harness/` | `tests/harness_choice.rs`, `tests/harness.rs` | | Session prompt, Factory skills | `src/prompt.rs`; `src/skills.rs`; `skills/` | `src/skills/tests.rs`, `tests/packaging.rs` | | Trusted account, the issue a session gets | `src/trust.rs` — `issue_trust`, `ticket_trust`, `untrusted_edit` over `Reads`; the GitHub adapter's trust reads, `src/github/trust.rs`; `src/github/session_issue.rs` — `issue_for_session`; `src/delivery.rs` — `hand_over_issue` | Inline tests, including `src/trust.rs` and `src/prompt.rs`; `tests/fresh_run.rs`, `tests/pickup_run.rs` | -| Session log, Resume | `src/session.rs` and `src/session/`; `src/session/kind.rs` — `Kind`, `Logged`: every session kind, its Session-log token, the Command record stage it enters and its `--session` rule (`selects`) | `src/session/execution_tests.rs`, inline tests in `src/session/kind.rs`, `tests/background_work.rs` | +| Session trace, Resume | `src/session.rs` and `src/session/`; `src/session/kind.rs` — `Kind`, `Logged`: every session kind, its Session-trace token, the Command record stage it enters and its `--session` rule (`selects`) | `src/session/execution_tests.rs`, inline tests in `src/session/kind.rs`, `tests/background_work.rs` | | OTLP JSON Lines (Session-trace file format) | `src/otlp_jsonl.rs` — application-independent requests, `Writer`, `Reader`; `src/otlp_jsonl/value.rs` — attribute values and byte encoding | `src/otlp_jsonl/tests.rs`; official examples and local Collector config in `src/otlp_jsonl/fixtures/` | | Session trace recording and Trace ancestry | `src/session/trace.rs` — streaming records, ended spans and retained amendments; `src/logs/trace.rs` — deterministic Command, Run and stage identities | `tests/session_traces.rs`, `src/session/recording/tests.rs`, `src/logs/recording/tests.rs` | | Whole Trace export | `src/logs/export.rs` — Command record ancestry and local OTLP JSON Lines export | `tests/export_trace.rs` | -| Session trace reading, old Session logs | `src/logs/session.rs` — readable file selection and raw-event unwrapping for the Console, `show`, Silent and heartbeat | `tests/console.rs`, `tests/show_logs.rs`, `tests/run_health.rs`, `console/tests/logs.test.cjs`, `console/tests/inbox.test.cjs` | +| Session trace reading | `src/logs/session.rs` — readable file selection and raw-event unwrapping for the Console, `show`, Silent and heartbeat | `tests/console.rs`, `tests/show_logs.rs`, `tests/run_health.rs`, `console/tests/logs.test.cjs`, `console/tests/inbox.test.cjs` | | Command log, Activity log | `src/logs.rs`; `src/logs/recording.rs` — `Record` | `src/logs/recording/tests.rs`, `tests/command_logs.rs`, `tests/activity_log.rs` | | Command-log allocation, Session naming, child inheritance and filename decoding | `src/logs/owner.rs` — `allocate`, `Owner`, `decode`, `session_matches`; `src/session/recording.rs` — captured Model recording | `src/logs/owner/tests.rs`, `src/logs/recording/tests.rs`, `src/session/recording/tests.rs`, `tests/command_logs.rs`, `tests/status.rs`, `tests/show_logs.rs` | | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` | | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | Console Lanes and Stages, stage times | `src/logs/status/time.rs` — time views from complete status observations; `src/console/time.rs`, `src/console/time.html` — page rendering | `tests/console.rs`, `console/tests/time.test.cjs` | -| Console Waterfall, tool-call timing | `src/logs/status/waterfall.rs` — retained Run/stage tree and scale; `src/logs/status/waterfall/trace.rs` — span times and running start records; `src/logs/status/waterfall/matching.rs` — legacy scoped Claude/Codex matching; `src/console/waterfall.rs` | `tests/console.rs`, `tests/console/waterfall.rs`, `console/tests/waterfall.test.cjs` | +| Console Waterfall, span tree and details | `src/logs/spans.rs` — shared CLI/Console tree and record-derived ancestry; `src/logs/spans/session.rs` — completed spans and live start records; `src/console/waterfall.rs`; `src/console/assets/spans.js` — folding, ranking, zoom and Peek | `tests/console/spans.rs`, `console/tests/spans.test.cjs` | | 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, each `--session` word matched by `src/session/kind.rs` — `selects`; `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` — normalized rendering for all six Harnesses and legacy Claude/Codex logs | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | +| 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` — normalized rendering for all six Harnesses and raw events inside converted traces | `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` | | Theme, hidden sections, Pause motion | `src/config/console.rs` — built-in names and restricted User config writes; `src/console/look.rs` — CLI/HTTP look actions; `src/console/assets/themes.css` — tokens; `src/console/assets/look.js` — picker and browser motion preference | `tests/console/look.rs`, `console/tests/inbox.test.cjs` | | Custom Theme, Theme import/export, custom fonts | `src/config/console/themes.rs` — portable format, defaults, validation, folder and confined font reads; `src/console/look.rs` — shared saves and Console log; `src/console/assets/look.js` — JSON editor | `tests/console/look.rs`, `console/tests/custom-themes.test.cjs`; format in `console/README.md` | diff --git a/docs/guide.md b/docs/guide.md index a13b9e002..c46511648 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -116,7 +116,7 @@ The designed behaviour described in this manual is implemented. Known gaps and p `--all` adds **History** for everything older or without a known ending. Each row has a state word, its issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as `architect-20261018T120000-0400` or `secure-20261018T120000-0400`. A Command or child Run with no outcome whose process has gone shows as `died at `; its start time bounds how long it stays in Needs you when no ending is known. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as `*.command.json`, with child records named for their issue as `*.issue-.child.json`. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome. -History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current `///commands//` layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with `--all`. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before [ADR 0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md) is ignored. +History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session traces in the current `///commands//` layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with `--all`. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before [ADR 0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md) is ignored. For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example: @@ -144,8 +144,8 @@ Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: ht "effort": "high", "started_at": "2026-10-18T12:00:00-04:00", "ended_at": null, - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], - "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"}], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl", "issue_branch": "issue-7", "pull_request": null, "outcome": null, @@ -204,7 +204,7 @@ For example, this tree excerpt omits the common Run fields shown above: }] } ``` -Every entry has `source`: `command_record`, `activity_log` or `session_log`. The latter two carry `repository`, `kind`, nullable `issue` (number, URL, nullable title), `harness`, `model`, `effort`, `started_at`, nullable `ended_at` and `outcome`, nullable `command_log`, `session_logs` (paths), `state`, `stages: []` and `stage: null`. They omit record-only fields such as process identity and branch information. Activity times use the UTC offset in the Command log's stamp; their outcome is the Activity log's full ending text. A Session entry has null Harness, Model, Effort, end time and outcome, and lists its grouped Session logs. Both flags can be combined as `status --all --json`. +Every entry has `source`: `command_record`, `activity_log` or `session_log`. The latter two carry `repository`, `kind`, nullable `issue` (number, URL, nullable title), `harness`, `model`, `effort`, `started_at`, nullable `ended_at` and `outcome`, nullable `command_log`, `session_logs` (paths), `state`, `stages: []` and `stage: null`. They omit record-only fields such as process identity and branch information. Activity times use the UTC offset in the Command log's stamp; their outcome is the Activity log's full ending text. A Session entry has null Harness, Model, Effort, end time and outcome, and lists its grouped Session traces. Both flags can be combined as `status --all --json`. ## Show and read one Command @@ -218,9 +218,9 @@ thirdshift logs 'widgets#7' --session implement thirdshift logs 'widgets#7' --session repair --follow ``` -`show` includes the Runs, stage history with start times and Usual ranges, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as `Bash: cargo test`, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable. +`show` includes the Runs, stage history with start times and Usual ranges, outcomes, and paths to Command logs and Session traces. Each live Run also gets its latest event, such as `Bash: cargo test`, from the last 64 KiB of its current Session trace. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session traces leave the event unavailable. -`status` and `show` describe live work as **Silent** when neither its Session log nor its Command log has grown for `inbox.silent_minutes` (10 by default). Silence starts no earlier than the current stage. A dispatch stage is never Silent or Slow and has no Usual range: the dispatched Run or Spec run carries its own. At three times the threshold, the words escalate: `silent 45 min · past 30`. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time. +`status` and `show` describe live work as **Silent** when neither its Session trace nor its Command log has grown for `inbox.silent_minutes` (10 by default). Silence starts no earlier than the current stage. A dispatch stage is never Silent or Slow and has no Usual range: the dispatched Run or Spec run carries its own. At three times the threshold, the words escalate: `silent 45 min · past 30`. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time. A stage's **Usual range** is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is **Slow**: `slower than usual (12–17 min), still working`. At twice the top it also shows the multiple, such as `2× usual` or `3× usual`. Silent takes precedence over Slow. @@ -239,19 +239,19 @@ Both JSON views add `health` to recorded Commands: `{"state":"ok"}`, `{"state":" "name": "acme/widgets#7", "state": "live", "latest_event": "Bash: cargo test", - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"}], "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", - "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", - "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl", + "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"], "children": [], "dispatched": [] } } ``` -The Command excerpt omits the common fields documented for `status --json`; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. `children` and `dispatched` retain the same recursive shape. `session_logs` lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. `latest_event` is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as `status --all --json`, with Session logs discovered by issue/kind and exact Command owner, without invented stages or liveness. +The Command excerpt omits the common fields documented for `status --json`; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. `children` and `dispatched` retain the same recursive shape. `session_logs` lists the selected Run's Session trace paths, including original sessions and Resumes that share a stage. `latest_event` is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-trace shape as `status --all --json`, with Session traces discovered by issue/kind and exact Command owner, without invented stages or liveness. -`logs` writes the Command log verbatim. `--session ` writes that kind's latest Session log as raw JSON lines; `implement` and `spec-review` include a later Resume, and `repair` selects the latest numbered Repair. A missing log or session kind gives an error. `--follow` first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session logs. The selected Command and Session log stay fixed while following. +`logs` writes the Command log verbatim. `--session ` writes that kind's latest Session trace as raw JSON lines; `implement` and `spec-review` include a later Resume, and `repair` selects the latest numbered Repair. A missing log or session kind gives an error. `--follow` first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session traces. The selected Command and Session trace stay fixed while following. ## Install @@ -322,7 +322,7 @@ Cleanup removes only thirdshift's own worktree, local Issue branch and the tempo ### Usage and cost -When `claude` is logged in with a claude.ai subscription, Runs use the plan's usage allowance, not API billing. The `total_cost_usd` in the session logs is the API-price equivalent, not a charge. An unattended Run that hits the plan's usage limit stalls or fails partway through. +When `claude` is logged in with a claude.ai subscription, Runs use the plan's usage allowance, not API billing. The `total_cost_usd` in the session traces is the API-price equivalent, not a charge. An unattended Run that hits the plan's usage limit stalls or fails partway through. ## Usage @@ -659,9 +659,9 @@ The Credentials are read only when `RESEND_API_KEY` is unset or empty, and only A Run that asks for a notification, by the flag or by `email.always`, makes the same checks as `email-test` before any other work: an address is known, and a key is found, in `RESEND_API_KEY` or else the Credentials. If either fails, or the Credentials are broken, the Run stops, exits `1` naming what's wrong (with no key, the message above listing every way to give one), and sends nothing. A Run that asks for no notification never reads the Credentials, so a broken file can't stop it, and `update`, `version` and `help` never read it either. Once they pass, every way the Run ends sends exactly one notification, after its outcome is final and its cleanup done: ready for review, merged, a [Failed run](#failed-runs) (including a later preflight failure such as an origin mismatch), or interrupted by Ctrl-C, SIGTERM or a closed terminal. - **Subject**: `[thirdshift] /# : `, where the outcome is `ready for review`, `merged`, `failed` or `interrupted`. The title is left out if it can't be read from GitHub. -- **Body**, plain text: a `Result:` line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; immediately followed by one `Issue:` line with the full original Issue URL, including after failure or interruption with no pull request, and a `Title:` line with the issue title when it was read; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its [`Base check:`, `Retry with:` and `Or set:` lines](#an-inherited-failure-links-the-base-branchs-checks), the session log path (if any), the same one stderr gives, the [Command log](#logs) path as a `Command log:` line, a `Sent by: thirdshift` line, the hostname and how long the Run took. The body tells everything the subject does, so copying the body alone is enough. +- **Body**, plain text: a `Result:` line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; immediately followed by one `Issue:` line with the full original Issue URL, including after failure or interruption with no pull request, and a `Title:` line with the issue title when it was read; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its [`Base check:`, `Retry with:` and `Or set:` lines](#an-inherited-failure-links-the-base-branchs-checks), the session trace path (if any), the same one stderr gives, the [Command log](#logs) path as a `Command log:` line, a `Sent by: thirdshift` line, the hostname and how long the Run took. The body tells everything the subject does, so copying the body alone is enough. -The email's `Built with` line names the command's configured Harness, Model and Effort and is marked `(configured)`. A `Session models` section lists sessions whose Harness was launched, including failed or interrupted sessions, Resumes and child Runs belonging to that Command. Models evidenced by the Harness's output or retained records are marked `(reported)`, including model switches and delegated Claude answers. When the Harness supplies no model evidence, the requested Model is labelled `(requested; actual model not reported)`, or its default is labelled `Harness default (actual model not reported)`. A listed session proves the Harness was launched; an unconfirmed request does not prove that Model executed. Effort is the session's configured Effort; it does not describe a delegated answer's Effort. Attribution records (`*.models.json`) are kept beside the Session logs; a failure to write or read them is a warning and never changes the Run's outcome. +The email's `Built with` line names the command's configured Harness, Model and Effort and is marked `(configured)`. A `Session models` section lists sessions whose Harness was launched, including failed or interrupted sessions, Resumes and child Runs belonging to that Command. Models evidenced by the Harness's output or retained records are marked `(reported)`, including model switches and delegated Claude answers. When the Harness supplies no model evidence, the requested Model is labelled `(requested; actual model not reported)`, or its default is labelled `Harness default (actual model not reported)`. A listed session proves the Harness was launched; an unconfirmed request does not prove that Model executed. Effort is the session's configured Effort; it does not describe a delegated answer's Effort. Attribution records (`*.models.json`) are kept beside the Session traces; a failure to write or read them is a warning and never changes the Run's outcome. A [Spec run](#spec-runs) sends at most one notification for the whole Spec, under the same rules, with its checks made once before any Ticket starts. Its subject names the Spec, its `Issue:` line links the parent Spec rather than a Ticket or pull request, its outcome is the Spec run's, and its body, after what a Run's holds (the Spec PR, if any), lists each Ticket's outcome, one line per Ticket as in the summary on stderr, such as `#21 landed with https://github.com/acme/widgets/pull/1` or `#22 blocked by #21`. The Ticket Runs inside it never send a notification of their own, whatever the User config says. @@ -701,33 +701,6 @@ request left behind. ### Logs -Convert old **Session logs** under this machine's configured `logs.dir`, across -all repositories and including Security runs, in two steps: - -```sh -thirdshift logs --convert -# Read /session-trace-conversion.json before proceeding. -thirdshift logs --remove-converted -``` - -Conversion writes `.logs.jsonl` and `.spans.jsonl` beside each original, using -the live Session-trace writer. The report lists each Session and whether its -conversation, Waterfall, `show` latest event and Model evidence round-trip -without a difference. Recovered Codex diffs are intentional additions, counted -as `codex_diffs`, when Codex's matching local session file still exists. -Missing event times use scoped Command-log progress, then the preceding -estimate or Command start, and carry `thirdshift.time.estimated`. They are -estimates rather than measurements; the Waterfall preserves its old reading. - -The first step keeps every original and never overwrites an existing trace. -Rerunning it skips passed conversions. Sessions with no identifiable Harness, -live Commands, existing traces or reader differences stay in the report for -inspection. The second step removes only passed originals whose original, -trace pair, Model sidecar and Command evidence still match the report's saved -fingerprints. Changed or failed Sessions keep their originals. Sidecars and -review reports stay beside the Session. Both steps and their report stay on -the machine, including Security-run evidence. - Export a Command's whole **Trace** for another tool with: ```sh @@ -743,8 +716,7 @@ Session trace in that Command. All share one trace ID, and every span's parent is included. Naming a child Run exports its whole owning Command too. The export stays on the machine; nothing is sent anywhere. Existing export -files are never overwritten. A Command record is required; legacy Session logs -without OTLP traces contribute no Session records. Missing files, malformed +files are never overwritten. A Command record is required. Missing files, malformed requests or missing parent spans fail the export before the output files are published. Wait for a live Command to finish before exporting its complete Trace; unfinished record intervals retain an unset end time rather than an @@ -758,31 +730,32 @@ Everything thirdshift logs goes under `~/.thirdshift/logs/`, or the `logs.dir` s ├── audits/ each Security audit's report, with its evidence ├── security-reviews/ each Security review's private report of old findings └── commands/ - ├── architect/ ~.log and Session logs - ├── pickup/ -~.log and Session logs - ├── secure/ ~.log and Session logs - └── issue/ -~.log and Session logs + ├── architect/ ~.log and Session traces + ├── pickup/ -~.log and Session traces + ├── secure/ ~.log and Session traces + └── issue/ -~.log and Session traces ``` -The folder already names the repository, so no file name repeats it. `audits/` and `security-reviews/` hold vulnerability evidence, so keep the logs private (see [Security runs](#security-runs)). A **Session log** is one session's full transcript, as the Harness's JSON output, such as Claude Code's `stream-json`: +The folder already names the repository, so no file name repeats it. `audits/` and `security-reviews/` hold vulnerability evidence, so keep the logs private (see [Security runs](#security-runs)). A **Session trace** records one session's events and timed work in two OTLP JSON Lines files: `.logs.jsonl` holds a log record for every Harness event as it arrives, including its raw output; `.spans.jsonl` holds the Session, model-call and tool-call spans as they end. Sessions write these files, Model evidence and any review reports; they no longer write the raw Session log. All of a Command's Session traces share its **Trace** identity, including child Runs: ``` -~/.thirdshift/logs///commands/issue/-~-implement.jsonl -~/.thirdshift/logs///commands/issue/-~-repair-.jsonl -~/.thirdshift/logs///commands/architect/architect-~-architecture-review.jsonl +~/.thirdshift/logs///commands/issue/-~-implement.logs.jsonl +~/.thirdshift/logs///commands/issue/-~-implement.spans.jsonl +~/.thirdshift/logs///commands/issue/-~-repair-.logs.jsonl +~/.thirdshift/logs///commands/architect/architect-~-architecture-review.logs.jsonl ``` -Session logs share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in `commands/pickup/`, an Architect run's in `commands/architect/`, and a Security run's in `commands/secure/`. Tickets and nested Base fixes inherit that same ownership too. +Session traces share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in `commands/pickup/`, an Architect run's in `commands/architect/`, and a Security run's in `commands/secure/`. Tickets and nested Base fixes inherit that same ownership too. When a Command that produces a [Run notification](#run-notifications) ends, its subject and body are saved beside its Command log as `.notification.txt`, whether or not email is enabled. For example, `7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.log` has `7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.notification.txt` beside it. The file starts with `Subject: `, then a blank line and the exact plain-text email body. A Pickup run writes one for the issue it took, and a Spec run writes one covering its Tickets; dispatched Runs, Ticket Runs and Base fixes write none of their own. A skipped Pass writes none. A failed write produces a `warning:` line and leaves the outcome unchanged. These files are not included in `thirdshift logs` or the Console's log inventory. -A Resume is logged as its session's kind plus `-resume`, e.g. `implement-resume.jsonl`. +A Resume is logged as its session's kind plus `-resume`, e.g. `implement-resume.logs.jsonl`. -A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift `, a Run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' Runs, a Run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the [Origin match](#what-a-run-does), and `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to `. A Command log that can't be written produces one `warning:` line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session log, the Run fails before starting that session. +A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift `, a Run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' Runs, a Run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the [Origin match](#what-a-run-does), and `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to `. A Command log that can't be written produces one `warning:` line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Run fails before starting that session. -The stamp is the local time the command started, with its UTC offset, as in `20261003T120000-0400`, in the machine's time zone, or `TZ`'s if set, so it agrees with `date` and `ls -l`. A command's Command log and all of its Session logs, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of the Session log of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a [Security review](#security-review) whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session log. A Spec run whose Tickets are not done names the Session log of the one Ticket whose Run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing `session log:` line and the [Run notification](#run-notifications) give the same path. +The stamp is the local time the command started, with its UTC offset, as in `20261003T120000-0400`, in the machine's time zone, or `TZ`'s if set, so it agrees with `date` and `ls -l`. A command's Command log and all of its Session traces, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of the Session trace of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a [Security review](#security-review) whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session trace. A Spec run whose Tickets are not done names the Session trace of the one Ticket whose Run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing `session trace:` line and the [Run notification](#run-notifications) give the same path. -New Command and Session logs insert `~` immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by `.command.json`. Model sidecars (`.models.json`) and review reports (`.standards` and `.spec`) use the exact Session-log stem. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename. +Command logs and Session traces insert `~` immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by `.command.json`. Model sidecars (`.models.json`) and review reports (`.standards` and `.spec`) use the Session stem before `.logs.jsonl`, for example `7-~-implement.models.json`. The Command record names the log-records file; Silent watches that file grow, and the Waterfall reads the trace spans. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Converted traces and older Command records remain readable without renaming; a record naming the former raw file resolves to its log-records file. Readers no longer accept the old Session-log format, and the one-time `logs --convert` and `--remove-converted` commands have been retired. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename. The **Activity log**, `activity.log`, is a short running record of what the factory did on the repository ([ADR-0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md)). A Run, a Spec run or a Pass writes a line when it starts work, naming its Command log, and one when it ends, with its outcome; a Run you type by hand writes them too, and the run a Pass dispatched, a Spec run's Tickets and a Base fix write none of their own. A skipped Pass writes a line only when its reason differs from the last line of its own kind, so a repository that sits idle shows one line, not one per pass, and the first skip after a pass that did work always shows: that is when the repository went idle. Every line starts with the local date and time: @@ -793,7 +766,7 @@ The **Activity log**, `activity.log`, is a short running record of what the fact 2026-10-03 10:00:01 Pickup run skipped: no Ready issue on acme/widgets ``` -Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one `warning:` line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Existing logs, including Session logs in `//sessions/` and logs from before the per-repository layout, are not moved. +Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one `warning:` line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Existing logs, including Session traces in `//sessions/` and logs from before the per-repository layout, are not moved. With `quiet_skips = true` in the `[activity]` section of the [User config](#user-config), a skipped Pass prints nothing on stdout or stderr, its dated first line included, and leaves only its Activity log line. A pass that does work, or fails, prints as ever, so a scheduler's log file catches only what went wrong. Without it, a skipped pass prints as it always has, for a pass you type by hand. @@ -879,7 +852,7 @@ thirdshift: 03:12:40 CI red on test, which also fails on main at 362b9ca; fix ma thirdshift: 03:12:40 Base check: test: https://github.com/acme/widgets/actions/runs/1/job/2 thirdshift: 03:12:40 Retry with: thirdshift https://github.com/acme/widgets/issues/7 base-fix thirdshift: 03:12:40 Or set: base.fix = true in ~/.thirdshift/config.toml, to allow a Base fix for every Run on this machine -thirdshift: 03:12:40 session log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….jsonl +thirdshift: 03:12:40 session trace: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….logs.jsonl thirdshift: 03:12:40 command log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….log ``` @@ -915,7 +888,7 @@ thirdshift --parallel 1 https://github.com/acme/widgets/issues/20 # one at a tim When nothing is left to run and any Ticket is not done, the Spec run is a **Failed spec run**: it leaves the Spec PR a draft, its checklist showing what's missing, prints its URL on stdout (if any Ticket has landed, so there is one), exits `1`, and lists on stderr each Ticket that landed, with its pull request, and each one not done, with why: ``` -thirdshift: 03:12:40 #21 failed: claude exited 1 (session log: ~/.thirdshift/logs/acme/widgets/commands/issue/21-….jsonl) +thirdshift: 03:12:40 #21 failed: claude exited 1 (session trace: ~/.thirdshift/logs/acme/widgets/commands/issue/21-….logs.jsonl) thirdshift: 03:12:40 #22 blocked by #21 thirdshift: 03:12:40 #23 landed with https://github.com/acme/widgets/pull/1 thirdshift: 03:12:40 #24 unready: labelled needs-info @@ -960,20 +933,20 @@ An Architect run: 6. Marks the plan ready, in one request: `needs-triage` is swapped for `ready-for-agent`, the plan is labelled `architect-plan`, the mark a later Architect run finds an open **Architect plan** by, and its other labels are kept. thirdshift adds that label, never the agent, and first creates it if the repository has none. A Spec's Tickets are left as the review labelled them, without `architect-plan`. 7. Dispatches the plan, unless given `--plan-only`, exactly as `thirdshift ` would from the same clone on the Architect run's Base branch: a [Spec run](#spec-runs) when the plan has sub-issues, a Run otherwise. Its Base branch is the Architect run's, so with `base ` it is ``, not the branch checked out. `merge` and `no-merge` apply to the Spec PR or the Run's pull request, `parallel ` to the Spec run, and `base-fix` and `no-base-fix` to whether that run may start a [Base fix](#base-fix), as if given to that command, and the [User config](#user-config) sets what they leave unsaid: `merge.always`, `spec.parallel`, `base.fix`, `launch.pull` and `logs.dir`. The Architecture review itself never watches CI, so a Base fix can only happen in the dispatched run. It makes the [Claim](#the-claim) as that command would too: once its checks pass, the plan's `ready-for-agent` is swapped for `in-progress`, and the plan keeps `architect-plan`. The one difference is that it sends no [Run notification](#run-notifications) of its own, whatever `email.always` says: the Architect run sends [the one](#one-run-notification). -The dispatched run's ending is the Architect run's: its exit code, its pull request's URL alone on stdout, and its last line on stderr, `PR is ready for review` or `PR is merged`. If it fails, the Architect run fails as that Failed run or Failed spec run does, with the cause, the session log and the Command log on stderr and the pull request's URL on stdout if it left one. The plan stays open and `architect-plan`, and `in-progress` unless the run left nothing on `origin` and so [released its Claim](#when-the-claim-ends), for `thirdshift ` to take up again: no later Architect run [retries it](#one-architect-plan-at-a-time). `parallel ` on a plan that is a single Ticket fails the same way as it does for `thirdshift ` on an issue that isn't a Spec: `parallel is only for a Spec, and # has no sub-issues`, exit `1`, before any implementing and before the Claim, with the plan left `ready-for-agent`, to run without it. +The dispatched run's ending is the Architect run's: its exit code, its pull request's URL alone on stdout, and its last line on stderr, `PR is ready for review` or `PR is merged`. If it fails, the Architect run fails as that Failed run or Failed spec run does, with the cause, the session trace and the Command log on stderr and the pull request's URL on stdout if it left one. The plan stays open and `architect-plan`, and `in-progress` unless the run left nothing on `origin` and so [released its Claim](#when-the-claim-ends), for `thirdshift ` to take up again: no later Architect run [retries it](#one-architect-plan-at-a-time). `parallel ` on a plan that is a single Ticket fails the same way as it does for `thirdshift ` on an issue that isn't a Spec: `parallel is only for a Spec, and # has no sub-issues`, exit `1`, before any implementing and before the Claim, with the plan left `ready-for-agent`, to run without it. With `--plan-only`, it instead exits `0` with the plan's URL alone on stdout, and `plan is ready for an agent` as stderr's last line. Read or edit the plan, then run it with `thirdshift `. The plan is labelled `architect-plan` here too, so until it is finished or closed the next Architect run is [skipped](#one-architect-plan-at-a-time). Progress lines on stderr say when the review starts, which plan it reported, when the labels are swapped, and when the plan is dispatched: `dispatching the plan , as thirdshift would`. The dispatched run's own progress lines follow. With no Strong candidate, the last line says which issue the Architect run ended on instead. -A review session that fails or is interrupted, a final message without one of the lines the prompt asks for, or a plan that fails a check ends the Architect run as a failure: exit `1`, nothing on stdout, the cause on stderr and then the paths of the session log and the Command log. Nothing is dispatched and no label is changed, so a plan the review did publish stays `needs-triage`, without `architect-plan`, for you to finish or close, and doesn't skip the next Architect run. A review that finds no deepening opportunity at all has no issue to name, so it ends without one of those lines and the Architect run fails this way too; its session log says what it looked at. +A review session that fails or is interrupted, a final message without one of the lines the prompt asks for, or a plan that fails a check ends the Architect run as a failure: exit `1`, nothing on stdout, the cause on stderr and then the paths of the session trace and the Command log. Nothing is dispatched and no label is changed, so a plan the review did publish stays `needs-triage`, without `architect-plan`, for you to finish or close, and doesn't skip the next Architect run. A review that finds no deepening opportunity at all has no issue to name, so it ends without one of those lines and the Architect run fails this way too; its session trace says what it looked at. ### One Run notification An Architect run asked for a [Run notification](#run-notifications), by `email` or by `email.always = true` in the [User config](#user-config) without `no-email`, sends exactly one, whatever its outcome, with or without `--plan-only`, unless it is skipped. A skipped Architect run, [however](#one-at-a-time) [it](#one-architect-plan-at-a-time) [is](#an-architect-idea-waits-for-triage) [skipped](#a-ready-issue-goes-first), sends none, even when asked, so a schedule can start one every few minutes without flooding your inbox: its line is in the log its scheduler keeps. It makes a Run's checks before any other work all the same, an address and a Resend API key, and stops with exit `1` if either is missing, so a run that would have been skipped fails on them too. The email goes after the outcome is final and printed, and a failed send is only a `warning:` line on stderr: it changes neither the exit code nor stdout. - **Subject**: `[thirdshift] / Architect run: `. With a dispatched run, the outcome is that run's: `ready for review`, `merged`, `failed` or `interrupted`. Without one, it is the review's: `plan published` (with `--plan-only`), `idea filed`, `idea already filed`, `review failed` (also for a plan that fails a check) or `interrupted`. The repository is left out if `origin` doesn't name one on GitHub. -- **Body**, plain text: a `Result:` line with the same outcome as the subject; a `Command: Architect run` line and a `Repository:` line naming the repository, when known; a `Review:` line saying how the Architecture review ended, with the URL of the plan or idea issue it named (`plan published: `, `idea filed: `, `idea already filed: `, `failed` or `interrupted`); a `Description:` line with that issue's title, when known, so you can see what the review proposed without opening the link; when the plan was dispatched, a `Dispatched:` line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the [lines after it](#an-inherited-failure-links-the-base-branchs-checks) of a dispatched run that failed on an Inherited failure, a `Base fix:` line for a dispatched run that started or waited on a [Base fix](#base-fix), the session log path (if any), the `Command log:` path (if any), the `Sent by:` line, the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does. +- **Body**, plain text: a `Result:` line with the same outcome as the subject; a `Command: Architect run` line and a `Repository:` line naming the repository, when known; a `Review:` line saying how the Architecture review ended, with the URL of the plan or idea issue it named (`plan published: `, `idea filed: `, `idea already filed: `, `failed` or `interrupted`); a `Description:` line with that issue's title, when known, so you can see what the review proposed without opening the link; when the plan was dispatched, a `Dispatched:` line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the [lines after it](#an-inherited-failure-links-the-base-branchs-checks) of a dispatched run that failed on an Inherited failure, a `Base fix:` line for a dispatched run that started or waited on a [Base fix](#base-fix), the session trace path (if any), the `Command log:` path (if any), the `Sent by:` line, the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does. The body also includes a `Strength:` line for a completed Architecture review: `Strong`, `Worth exploring` or `Speculative`. A published plan is always `Strong`; for an idea, including one already filed, the rating comes from this review's final message. If the review omits the rating or reports an unrecognized value, the line says `not reported`. Failed or interrupted reviews have no strength line. @@ -1115,7 +1088,7 @@ A Security run checks its gates in order: another **Pass** on the repository run The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when `launch.pull` is set. Its Session prompt runs `thirdshift-security-audit` in full audit mode with the `quick` profile, auditing the whole repository except vendored and third-party code. It names a `SECURITY.md` or conventional threat-model document when present, reads compatible earlier runs and ends `incomplete` instead of asking for input. -Each audit keeps a new output directory under `///audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in `commands/secure/`; Session logs are named `secure-~-security-audit.jsonl`; the Activity log records the start and ending as for other Passes. +Each audit keeps a new output directory under `///audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session trace. Command logs and Session traces are in `commands/secure/`; Session traces are named `secure-~-security-audit.logs.jsonl`; the Activity log records the start and ending as for other Passes. A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the `other` ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled `security-finding` and `needs-triage`. It is never replaced by a public finding issue. @@ -1127,7 +1100,7 @@ The review runs on `[security] harness` when that is set, with that Harness's ow Before any session, a command checks its review's Harness with the same PATH, Model, Effort and sign-in checks as its own. If a non-default review Harness can't be used, the review falls back to `harness.default`, or Claude Code when blank, with that Harness's own configured Model and Effort. A single `warning:` line names both Harnesses and the reason. If the review Harness is already the default, or the fallback also fails its check, the command fails at preflight. A Spec run checks before any Ticket starts and hands the settled choice to all Tickets and the Spec PR. With Security review off, no review Harness is checked. A Pass checks it only if it may dispatch a Run: a Security run that only audits, or `architect --plan-only`, does not. -If a non-default review Harness passes preflight but returns a Harness error before any turns or tokens, thirdshift warns in the same way and retries the review once on the default Harness. A failure on the default, a model refusal or a failure after work starts is not retried. Each attempt keeps its own Session log and `.models.json`; the retry uses `security-review-fallback`, also selected by `logs --session security-review`. Those records, the Command log and the Run notification name the Harness each attempt actually ran on; the `Built with` line still names the Command's. A Continuation reads `security.harness` again and applies these checks again. +If a non-default review Harness passes preflight but returns a Harness error before any turns or tokens, thirdshift warns in the same way and retries the review once on the default Harness. A failure on the default, a model refusal or a failure after work starts is not retried. Each attempt keeps its own Session trace and `.models.json`; the retry uses `security-review-fallback`, also selected by `logs --session security-review`. Those records, the Command log and the Run notification name the Harness each attempt actually ran on; the `Built with` line still names the Command's. A Continuation reads `security.harness` again and applies these checks again. The session reads the relevant attack-class guidance and supporting code without delegated auditors or the full audit workflow. It fixes only findings reproduced by a failing proof-of-concept test, keeps that test and commits the fix. Unaddressed introduced findings appear under Security in the PR's Unaddressed findings. A Merge run, including a Spec run asked to merge its Spec PR, leaves the PR ready for review and fails with the findings named if any remain, or if the review was refused or incomplete. A Run or Spec run not asked to merge records the outcome and continues. An old finding, whose proof-of-concept also fails at the pinned merge base, is reported in a private file under the repository's logs (`security-reviews/`). thirdshift records it as a draft advisory, or a `security-finding`/`needs-triage` issue on a private repository, reusing existing fingerprints and preserving the Day shift's grades. New records include scored reproduction and fix-size metadata so a later Security run can take the fix when allowed. Its details stay out of the PR and it does not hold Self-merge; fixing belongs to a separate Security run. The separate Security audit retains its full audit workflow. diff --git a/prompts/architecture-review.md b/prompts/architecture-review.md index 2e47a7cc6..b47a95d6c 100644 --- a/prompts/architecture-review.md +++ b/prompts/architecture-review.md @@ -28,5 +28,5 @@ Architecture review already filed: -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. ``` diff --git a/prompts/security-reproduction.md b/prompts/security-reproduction.md index b5be193e1..dd334857a 100644 --- a/prompts/security-reproduction.md +++ b/prompts/security-reproduction.md @@ -17,5 +17,5 @@ This session commits, pushes, opens and publishes nothing. Recorded finding: -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. ``` diff --git a/prompts/security-review.md b/prompts/security-review.md index 13b821526..7805d5967 100644 --- a/prompts/security-review.md +++ b/prompts/security-review.md @@ -28,5 +28,5 @@ For source edits, prefer an available structured patch/edit tool that makes the Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. ``` diff --git a/prompts/spec-review.md b/prompts/spec-review.md index efb3932f5..263ebf9d6 100644 --- a/prompts/spec-review.md +++ b/prompts/spec-review.md @@ -28,5 +28,5 @@ For source edits, prefer an available structured patch/edit tool that makes the Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. ``` diff --git a/site/docs/index.html b/site/docs/index.html index 1a2c2c0fe..686d573a7 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -159,7 +159,7 @@
  • Resolved: older completions with nothing waiting, back to inbox.days.
  • --all adds History for everything older or without a known ending. Each row has a state word, its issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as architect-20261018T120000-0400 or secure-20261018T120000-0400. A Command or child Run with no outcome whose process has gone shows as died at <stage>; its start time bounds how long it stays in Needs you when no ending is known. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as *.command.json, with child records named for their issue as *.issue-<n>.child.json. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.

    -

    History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current <logs.dir>/<owner>/<repo>/commands/<kind>/ layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with --all. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before ADR 0011 is ignored.

    +

    History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session traces in the current <logs.dir>/<owner>/<repo>/commands/<kind>/ layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with --all. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before ADR 0011 is ignored.

    For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example:

    Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: https://github.com/acme/widgets/issues/7
     
    @@ -181,8 +181,8 @@ "effort": "high", "started_at": "2026-10-18T12:00:00-04:00", "ended_at": null, - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], - "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"}], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl", "issue_branch": "issue-7", "pull_request": null, "outcome": null, @@ -234,7 +234,7 @@ }] } -

    Every entry has source: command_record, activity_log or session_log. The latter two carry repository, kind, nullable issue (number, URL, nullable title), harness, model, effort, started_at, nullable ended_at and outcome, nullable command_log, session_logs (paths), state, stages: [] and stage: null. They omit record-only fields such as process identity and branch information. Activity times use the UTC offset in the Command log's stamp; their outcome is the Activity log's full ending text. A Session entry has null Harness, Model, Effort, end time and outcome, and lists its grouped Session logs. Both flags can be combined as status --all --json.

    +

    Every entry has source: command_record, activity_log or session_log. The latter two carry repository, kind, nullable issue (number, URL, nullable title), harness, model, effort, started_at, nullable ended_at and outcome, nullable command_log, session_logs (paths), state, stages: [] and stage: null. They omit record-only fields such as process identity and branch information. Activity times use the UTC offset in the Command log's stamp; their outcome is the Activity log's full ending text. A Session entry has null Harness, Model, Effort, end time and outcome, and lists its grouped Session traces. Both flags can be combined as status --all --json.

    Show and read one Command

    @@ -245,8 +245,8 @@ thirdshift logs 'widgets#7' --session implement thirdshift logs 'widgets#7' --session repair --follow -

    show includes the Runs, stage history with start times and Usual ranges, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as Bash: cargo test, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable.

    -

    status and show describe live work as Silent when neither its Session log nor its Command log has grown for inbox.silent_minutes (10 by default). Silence starts no earlier than the current stage. A dispatch stage is never Silent or Slow and has no Usual range: the dispatched Run or Spec run carries its own. At three times the threshold, the words escalate: silent 45 min · past 30. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time.

    +

    show includes the Runs, stage history with start times and Usual ranges, outcomes, and paths to Command logs and Session traces. Each live Run also gets its latest event, such as Bash: cargo test, from the last 64 KiB of its current Session trace. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session traces leave the event unavailable.

    +

    status and show describe live work as Silent when neither its Session trace nor its Command log has grown for inbox.silent_minutes (10 by default). Silence starts no earlier than the current stage. A dispatch stage is never Silent or Slow and has no Usual range: the dispatched Run or Spec run carries its own. At three times the threshold, the words escalate: silent 45 min · past 30. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time.

    A stage's Usual range is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is Slow: slower than usual (12–17 min), still working. At twice the top it also shows the multiple, such as 2× usual or 3× usual. Silent takes precedence over Slow.

    Both JSON views add health to recorded Commands: {"state":"ok"}, {"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}, or {"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. The usual_ranges map gives each stage's range by name, for example {"implement":{"lower_seconds":720.0,"upper_seconds":1020.0}}; a stage is omitted until enough times exist. Health and ranges are reader observations, never written back to Command records.

    show --json is a versioned API, usable outside a repository. Version 1 wraps the selected Command in this shape:

    @@ -260,17 +260,17 @@ "name": "acme/widgets#7", "state": "live", "latest_event": "Bash: cargo test", - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"}], "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", - "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", - "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl", + "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.logs.jsonl"], "children": [], "dispatched": [] } } -

    The Command excerpt omits the common fields documented for status --json; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. children and dispatched retain the same recursive shape. session_logs lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. latest_event is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as status --all --json, with Session logs discovered by issue/kind and exact Command owner, without invented stages or liveness.

    -

    logs writes the Command log verbatim. --session <kind> writes that kind's latest Session log as raw JSON lines; implement and spec-review include a later Resume, and repair selects the latest numbered Repair. A missing log or session kind gives an error. --follow first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session logs. The selected Command and Session log stay fixed while following.

    +

    The Command excerpt omits the common fields documented for status --json; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. children and dispatched retain the same recursive shape. session_logs lists the selected Run's Session trace paths, including original sessions and Resumes that share a stage. latest_event is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-trace shape as status --all --json, with Session traces discovered by issue/kind and exact Command owner, without invented stages or liveness.

    +

    logs writes the Command log verbatim. --session <kind> writes that kind's latest Session trace as raw JSON lines; implement and spec-review include a later Resume, and repair selects the latest numbered Repair. A missing log or session kind gives an error. --follow first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session traces. The selected Command and Session trace stay fixed while following.

    Install

    @@ -330,7 +330,7 @@

    Usage and cost

    -

    When claude is logged in with a claude.ai subscription, Runs use the plan's usage allowance, not API billing. The total_cost_usd in the session logs is the API-price equivalent, not a charge. An unattended Run that hits the plan's usage limit stalls or fails partway through.

    +

    When claude is logged in with a claude.ai subscription, Runs use the plan's usage allowance, not API billing. The total_cost_usd in the session traces is the API-price equivalent, not a charge. An unattended Run that hits the plan's usage limit stalls or fails partway through.

    Usage

    @@ -597,9 +597,9 @@

    A Run that asks for a notification, by the flag or by email.always, makes the same checks as email-test before any other work: an address is known, and a key is found, in RESEND_API_KEY or else the Credentials. If either fails, or the Credentials are broken, the Run stops, exits 1 naming what's wrong (with no key, the message above listing every way to give one), and sends nothing. A Run that asks for no notification never reads the Credentials, so a broken file can't stop it, and update, version and help never read it either. Once they pass, every way the Run ends sends exactly one notification, after its outcome is final and its cleanup done: ready for review, merged, a Failed run (including a later preflight failure such as an origin mismatch), or interrupted by Ctrl-C, SIGTERM or a closed terminal.

    • Subject: [thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>, where the outcome is ready for review, merged, failed or interrupted. The title is left out if it can't be read from GitHub.
    • -
    • Body, plain text: a Result: line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; immediately followed by one Issue: line with the full original Issue URL, including after failure or interruption with no pull request, and a Title: line with the issue title when it was read; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its Base check:, Retry with: and Or set: lines, the session log path (if any), the same one stderr gives, the Command log path as a Command log: line, a Sent by: thirdshift line, the hostname and how long the Run took. The body tells everything the subject does, so copying the body alone is enough.
    • +
    • Body, plain text: a Result: line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; immediately followed by one Issue: line with the full original Issue URL, including after failure or interruption with no pull request, and a Title: line with the issue title when it was read; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its Base check:, Retry with: and Or set: lines, the session trace path (if any), the same one stderr gives, the Command log path as a Command log: line, a Sent by: thirdshift line, the hostname and how long the Run took. The body tells everything the subject does, so copying the body alone is enough.
    -

    The email's Built with line names the command's configured Harness, Model and Effort and is marked (configured). A Session models section lists sessions whose Harness was launched, including failed or interrupted sessions, Resumes and child Runs belonging to that Command. Models evidenced by the Harness's output or retained records are marked (reported), including model switches and delegated Claude answers. When the Harness supplies no model evidence, the requested Model is labelled (requested; actual model not reported), or its default is labelled Harness default (actual model not reported). A listed session proves the Harness was launched; an unconfirmed request does not prove that Model executed. Effort is the session's configured Effort; it does not describe a delegated answer's Effort. Attribution records (*.models.json) are kept beside the Session logs; a failure to write or read them is a warning and never changes the Run's outcome.

    +

    The email's Built with line names the command's configured Harness, Model and Effort and is marked (configured). A Session models section lists sessions whose Harness was launched, including failed or interrupted sessions, Resumes and child Runs belonging to that Command. Models evidenced by the Harness's output or retained records are marked (reported), including model switches and delegated Claude answers. When the Harness supplies no model evidence, the requested Model is labelled (requested; actual model not reported), or its default is labelled Harness default (actual model not reported). A listed session proves the Harness was launched; an unconfirmed request does not prove that Model executed. Effort is the session's configured Effort; it does not describe a delegated answer's Effort. Attribution records (*.models.json) are kept beside the Session traces; a failure to write or read them is a warning and never changes the Run's outcome.

    A Spec run sends at most one notification for the whole Spec, under the same rules, with its checks made once before any Ticket starts. Its subject names the Spec, its Issue: line links the parent Spec rather than a Ticket or pull request, its outcome is the Spec run's, and its body, after what a Run's holds (the Spec PR, if any), lists each Ticket's outcome, one line per Ticket as in the summary on stderr, such as #21 landed with https://github.com/acme/widgets/pull/1 or #22 blocked by #21. The Ticket Runs inside it never send a notification of their own, whatever the User config says.

    An Architect run takes the same flags and the same email.always, and sends one notification covering its review and the run it dispatched.

    A Pickup run takes the same flags and the same email.always too. One that took an issue sends one notification, the one the run it dispatched would have sent by hand, and a skipped one sends none.

    @@ -632,28 +632,6 @@

    Logs

    -

    Convert old Session logs under this machine's configured logs.dir, across -all repositories and including Security runs, in two steps:

    -
    thirdshift logs --convert
    -# Read <logs.dir>/session-trace-conversion.json before proceeding.
    -thirdshift logs --remove-converted
    -
    -

    Conversion writes .logs.jsonl and .spans.jsonl beside each original, using -the live Session-trace writer. The report lists each Session and whether its -conversation, Waterfall, show latest event and Model evidence round-trip -without a difference. Recovered Codex diffs are intentional additions, counted -as codex_diffs, when Codex's matching local session file still exists. -Missing event times use scoped Command-log progress, then the preceding -estimate or Command start, and carry thirdshift.time.estimated. They are -estimates rather than measurements; the Waterfall preserves its old reading.

    -

    The first step keeps every original and never overwrites an existing trace. -Rerunning it skips passed conversions. Sessions with no identifiable Harness, -live Commands, existing traces or reader differences stay in the report for -inspection. The second step removes only passed originals whose original, -trace pair, Model sidecar and Command evidence still match the report's saved -fingerprints. Changed or failed Sessions keep their originals. Sidecars and -review reports stay beside the Session. Both steps and their report stay on -the machine, including Security-run evidence.

    Export a Command's whole Trace for another tool with:

    thirdshift logs acme/widgets#7 --export ./trace
     
    @@ -665,8 +643,7 @@ Session trace in that Command. All share one trace ID, and every span's parent is included. Naming a child Run exports its whole owning Command too.

    The export stays on the machine; nothing is sent anywhere. Existing export -files are never overwritten. A Command record is required; legacy Session logs -without OTLP traces contribute no Session records. Missing files, malformed +files are never overwritten. A Command record is required. Missing files, malformed requests or missing parent spans fail the export before the output files are published. Wait for a live Command to finish before exporting its complete Trace; unfinished record intervals retain an unset end time rather than an @@ -677,29 +654,30 @@ ├── audits/ each Security audit's report, with its evidence ├── security-reviews/ each Security review's private report of old findings └── commands/ - ├── architect/ <stamp>~<token>.log and Session logs - ├── pickup/ <n>-<stamp>~<token>.log and Session logs - ├── secure/ <stamp>~<token>.log and Session logs - └── issue/ <n>-<stamp>~<token>.log and Session logs + ├── architect/ <stamp>~<token>.log and Session traces + ├── pickup/ <n>-<stamp>~<token>.log and Session traces + ├── secure/ <stamp>~<token>.log and Session traces + └── issue/ <n>-<stamp>~<token>.log and Session traces -

    The folder already names the repository, so no file name repeats it. audits/ and security-reviews/ hold vulnerability evidence, so keep the logs private (see Security runs). A Session log is one session's full transcript, as the Harness's JSON output, such as Claude Code's stream-json:

    -
    ~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-implement.jsonl
    -~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-repair-<i>.jsonl
    -~/.thirdshift/logs/<owner>/<repo>/commands/architect/architect-<stamp>~<token>-architecture-review.jsonl
    +

    The folder already names the repository, so no file name repeats it. audits/ and security-reviews/ hold vulnerability evidence, so keep the logs private (see Security runs). A Session trace records one session's events and timed work in two OTLP JSON Lines files: .logs.jsonl holds a log record for every Harness event as it arrives, including its raw output; .spans.jsonl holds the Session, model-call and tool-call spans as they end. Sessions write these files, Model evidence and any review reports; they no longer write the raw Session log. All of a Command's Session traces share its Trace identity, including child Runs:

    +
    ~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-implement.logs.jsonl
    +~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-implement.spans.jsonl
    +~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-repair-<i>.logs.jsonl
    +~/.thirdshift/logs/<owner>/<repo>/commands/architect/architect-<stamp>~<token>-architecture-review.logs.jsonl
     
    -

    Session logs share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in commands/pickup/, an Architect run's in commands/architect/, and a Security run's in commands/secure/. Tickets and nested Base fixes inherit that same ownership too.

    +

    Session traces share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in commands/pickup/, an Architect run's in commands/architect/, and a Security run's in commands/secure/. Tickets and nested Base fixes inherit that same ownership too.

    When a Command that produces a Run notification ends, its subject and body are saved beside its Command log as <Command log stem>.notification.txt, whether or not email is enabled. For example, 7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.log has 7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.notification.txt beside it. The file starts with Subject: <subject>, then a blank line and the exact plain-text email body. A Pickup run writes one for the issue it took, and a Spec run writes one covering its Tickets; dispatched Runs, Ticket Runs and Base fixes write none of their own. A skipped Pass writes none. A failed write produces a warning: line and leaves the outcome unchanged. These files are not included in thirdshift logs or the Console's log inventory.

    -

    A Resume is logged as its session's kind plus -resume, e.g. implement-resume.jsonl.

    -

    A Command log is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: thirdshift <Issue URL>, a Run or a Spec run, in issue/, thirdshift pickup in pickup/, named for the issue it took, and thirdshift architect in architect/. A Spec run's covers its Tickets' Runs, a Run's covers its Base fix, and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the Origin match, and setup, email-test, update, version and help never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it passed over before it took one, are written first, and once it is created a progress line says logging this command to <path>. A Command log that can't be written produces one warning: line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session log, the Run fails before starting that session.

    -

    The stamp is the local time the command started, with its UTC offset, as in 20261003T120000-0400, in the machine's time zone, or TZ's if set, so it agrees with date and ls -l. A command's Command log and all of its Session logs, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of the Session log of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a Security review whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session log. A Spec run whose Tickets are not done names the Session log of the one Ticket whose Run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing session log: line and the Run notification give the same path.

    -

    New Command and Session logs insert ~<token> immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by .command.json. Model sidecars (.models.json) and review reports (.standards and .spec) use the exact Session-log stem. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename.

    +

    A Resume is logged as its session's kind plus -resume, e.g. implement-resume.logs.jsonl.

    +

    A Command log is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: thirdshift <Issue URL>, a Run or a Spec run, in issue/, thirdshift pickup in pickup/, named for the issue it took, and thirdshift architect in architect/. A Spec run's covers its Tickets' Runs, a Run's covers its Base fix, and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the Origin match, and setup, email-test, update, version and help never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it passed over before it took one, are written first, and once it is created a progress line says logging this command to <path>. A Command log that can't be written produces one warning: line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Run fails before starting that session.

    +

    The stamp is the local time the command started, with its UTC offset, as in 20261003T120000-0400, in the machine's time zone, or TZ's if set, so it agrees with date and ls -l. A command's Command log and all of its Session traces, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of the Session trace of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a Security review whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session trace. A Spec run whose Tickets are not done names the Session trace of the one Ticket whose Run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing session trace: line and the Run notification give the same path.

    +

    Command logs and Session traces insert ~<token> immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by .command.json. Model sidecars (.models.json) and review reports (.standards and .spec) use the Session stem before .logs.jsonl, for example 7-<stamp>~<token>-implement.models.json. The Command record names the log-records file; Silent watches that file grow, and the Waterfall reads the trace spans. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Converted traces and older Command records remain readable without renaming; a record naming the former raw file resolves to its log-records file. Readers no longer accept the old Session-log format, and the one-time logs --convert and --remove-converted commands have been retired. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename.

    The Activity log, activity.log, is a short running record of what the factory did on the repository (ADR-0011). A Run, a Spec run or a Pass writes a line when it starts work, naming its Command log, and one when it ends, with its outcome; a Run you type by hand writes them too, and the run a Pass dispatched, a Spec run's Tickets and a Base fix write none of their own. A skipped Pass writes a line only when its reason differs from the last line of its own kind, so a repository that sits idle shows one line, not one per pass, and the first skip after a pass that did work always shows: that is when the repository went idle. Every line starts with the local date and time:

    2026-10-03 02:00:01 Pickup run skipped: no Ready issue on acme/widgets
     2026-10-03 09:30:01 Pickup run #41 started: commands/pickup/41-20261003T093001-0400~aB3cD4eF5gH6iJ7k.log
     2026-10-03 09:52:17 Pickup run #41 ended: PR https://github.com/acme/widgets/pull/42 is merged
     2026-10-03 10:00:01 Pickup run skipped: no Ready issue on acme/widgets
     
    -

    Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one warning: line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Existing logs, including Session logs in <owner>/<repo>/sessions/ and logs from before the per-repository layout, are not moved.

    +

    Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one warning: line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Existing logs, including Session traces in <owner>/<repo>/sessions/ and logs from before the per-repository layout, are not moved.

    With quiet_skips = true in the [activity] section of the User config, a skipped Pass prints nothing on stdout or stderr, its dated first line included, and leaves only its Activity log line. A pass that does work, or fails, prints as ever, so a scheduler's log file catches only what went wrong. Without it, a skipped pass prints as it always has, for a pass you type by hand.

    @@ -771,7 +749,7 @@ thirdshift: 03:12:40 Base check: test: https://github.com/acme/widgets/actions/runs/1/job/2 thirdshift: 03:12:40 Retry with: thirdshift https://github.com/acme/widgets/issues/7 base-fix thirdshift: 03:12:40 Or set: base.fix = true in ~/.thirdshift/config.toml, to allow a Base fix for every Run on this machine -thirdshift: 03:12:40 session log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….jsonl +thirdshift: 03:12:40 session trace: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….logs.jsonl thirdshift: 03:12:40 command log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….log

    A Run given no-base-fix gets the Base check: lines and no offer. A Run that had its one Base fix gets neither: its Base fix: line already says what happened. The cause itself, and so the failure commit's message, is the same in every case, and no other cause adds a line.

    @@ -793,7 +771,7 @@

    parallel followed by 0, a negative number or anything but a whole number, or given twice, is an argument error (exit 2). parallel on an issue with no sub-issues stops the Run before any work, since there are no Tickets to run at once. The Tickets' Runs share the Launch directory: they create their worktrees there one at a time, and a git command that finds a lock file held by another waits and tries again.

    When nothing is left to run and any Ticket is not done, the Spec run is a Failed spec run: it leaves the Spec PR a draft, its checklist showing what's missing, prints its URL on stdout (if any Ticket has landed, so there is one), exits 1, and lists on stderr each Ticket that landed, with its pull request, and each one not done, with why:

    -
    thirdshift: 03:12:40 #21 failed: claude exited 1 (session log: ~/.thirdshift/logs/acme/widgets/commands/issue/21-….jsonl)
    +
    thirdshift: 03:12:40 #21 failed: claude exited 1 (session trace: ~/.thirdshift/logs/acme/widgets/commands/issue/21-….logs.jsonl)
     thirdshift: 03:12:40 #22 blocked by #21
     thirdshift: 03:12:40 #23 landed with https://github.com/acme/widgets/pull/1
     thirdshift: 03:12:40 #24 unready: labelled needs-info
    @@ -830,16 +808,16 @@
     
  • Marks the plan ready, in one request: needs-triage is swapped for ready-for-agent, the plan is labelled architect-plan, the mark a later Architect run finds an open Architect plan by, and its other labels are kept. thirdshift adds that label, never the agent, and first creates it if the repository has none. A Spec's Tickets are left as the review labelled them, without architect-plan.
  • Dispatches the plan, unless given --plan-only, exactly as thirdshift <plan URL> would from the same clone on the Architect run's Base branch: a Spec run when the plan has sub-issues, a Run otherwise. Its Base branch is the Architect run's, so with base <branch> it is <branch>, not the branch checked out. merge and no-merge apply to the Spec PR or the Run's pull request, parallel <n> to the Spec run, and base-fix and no-base-fix to whether that run may start a Base fix, as if given to that command, and the User config sets what they leave unsaid: merge.always, spec.parallel, base.fix, launch.pull and logs.dir. The Architecture review itself never watches CI, so a Base fix can only happen in the dispatched run. It makes the Claim as that command would too: once its checks pass, the plan's ready-for-agent is swapped for in-progress, and the plan keeps architect-plan. The one difference is that it sends no Run notification of its own, whatever email.always says: the Architect run sends the one.
  • -

    The dispatched run's ending is the Architect run's: its exit code, its pull request's URL alone on stdout, and its last line on stderr, PR <url> is ready for review or PR <url> is merged. If it fails, the Architect run fails as that Failed run or Failed spec run does, with the cause, the session log and the Command log on stderr and the pull request's URL on stdout if it left one. The plan stays open and architect-plan, and in-progress unless the run left nothing on origin and so released its Claim, for thirdshift <plan URL> to take up again: no later Architect run retries it. parallel <n> on a plan that is a single Ticket fails the same way as it does for thirdshift <Issue URL> on an issue that isn't a Spec: parallel is only for a Spec, and #<n> has no sub-issues, exit 1, before any implementing and before the Claim, with the plan left ready-for-agent, to run without it.

    +

    The dispatched run's ending is the Architect run's: its exit code, its pull request's URL alone on stdout, and its last line on stderr, PR <url> is ready for review or PR <url> is merged. If it fails, the Architect run fails as that Failed run or Failed spec run does, with the cause, the session trace and the Command log on stderr and the pull request's URL on stdout if it left one. The plan stays open and architect-plan, and in-progress unless the run left nothing on origin and so released its Claim, for thirdshift <plan URL> to take up again: no later Architect run retries it. parallel <n> on a plan that is a single Ticket fails the same way as it does for thirdshift <Issue URL> on an issue that isn't a Spec: parallel is only for a Spec, and #<n> has no sub-issues, exit 1, before any implementing and before the Claim, with the plan left ready-for-agent, to run without it.

    With --plan-only, it instead exits 0 with the plan's URL alone on stdout, and plan <url> is ready for an agent as stderr's last line. Read or edit the plan, then run it with thirdshift <Issue URL>. The plan is labelled architect-plan here too, so until it is finished or closed the next Architect run is skipped.

    Progress lines on stderr say when the review starts, which plan it reported, when the labels are swapped, and when the plan is dispatched: dispatching the plan <url>, as thirdshift <url> would. The dispatched run's own progress lines follow. With no Strong candidate, the last line says which issue the Architect run ended on instead.

    -

    A review session that fails or is interrupted, a final message without one of the lines the prompt asks for, or a plan that fails a check ends the Architect run as a failure: exit 1, nothing on stdout, the cause on stderr and then the paths of the session log and the Command log. Nothing is dispatched and no label is changed, so a plan the review did publish stays needs-triage, without architect-plan, for you to finish or close, and doesn't skip the next Architect run. A review that finds no deepening opportunity at all has no issue to name, so it ends without one of those lines and the Architect run fails this way too; its session log says what it looked at.

    +

    A review session that fails or is interrupted, a final message without one of the lines the prompt asks for, or a plan that fails a check ends the Architect run as a failure: exit 1, nothing on stdout, the cause on stderr and then the paths of the session trace and the Command log. Nothing is dispatched and no label is changed, so a plan the review did publish stays needs-triage, without architect-plan, for you to finish or close, and doesn't skip the next Architect run. A review that finds no deepening opportunity at all has no issue to name, so it ends without one of those lines and the Architect run fails this way too; its session trace says what it looked at.

    One Run notification

    An Architect run asked for a Run notification, by email or by email.always = true in the User config without no-email, sends exactly one, whatever its outcome, with or without --plan-only, unless it is skipped. A skipped Architect run, however it is skipped, sends none, even when asked, so a schedule can start one every few minutes without flooding your inbox: its line is in the log its scheduler keeps. It makes a Run's checks before any other work all the same, an address and a Resend API key, and stops with exit 1 if either is missing, so a run that would have been skipped fails on them too. The email goes after the outcome is final and printed, and a failed send is only a warning: line on stderr: it changes neither the exit code nor stdout.

    • Subject: [thirdshift] <owner>/<repo> Architect run: <outcome>. With a dispatched run, the outcome is that run's: ready for review, merged, failed or interrupted. Without one, it is the review's: plan published (with --plan-only), idea filed, idea already filed, review failed (also for a plan that fails a check) or interrupted. The repository is left out if origin doesn't name one on GitHub.
    • -
    • Body, plain text: a Result: line with the same outcome as the subject; a Command: Architect run line and a Repository: line naming the repository, when known; a Review: line saying how the Architecture review ended, with the URL of the plan or idea issue it named (plan published: <url>, idea filed: <url>, idea already filed: <url>, failed or interrupted); a Description: line with that issue's title, when known, so you can see what the review proposed without opening the link; when the plan was dispatched, a Dispatched: line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the lines after it of a dispatched run that failed on an Inherited failure, a Base fix: line for a dispatched run that started or waited on a Base fix, the session log path (if any), the Command log: path (if any), the Sent by: line, the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does.
    • +
    • Body, plain text: a Result: line with the same outcome as the subject; a Command: Architect run line and a Repository: line naming the repository, when known; a Review: line saying how the Architecture review ended, with the URL of the plan or idea issue it named (plan published: <url>, idea filed: <url>, idea already filed: <url>, failed or interrupted); a Description: line with that issue's title, when known, so you can see what the review proposed without opening the link; when the plan was dispatched, a Dispatched: line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the lines after it of a dispatched run that failed on an Inherited failure, a Base fix: line for a dispatched run that started or waited on a Base fix, the session trace path (if any), the Command log: path (if any), the Sent by: line, the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does.

    The body also includes a Strength: line for a completed Architecture review: Strong, Worth exploring or Speculative. A published plan is always Strong; for an idea, including one already filed, the rating comes from this review's final message. If the review omits the rating or reports an unrecognized value, the line says not reported. Failed or interrupted reviews have no strength line.

    @@ -950,14 +928,14 @@

    Codex security sessions and their Resumes set agents.max_concurrent_threads_per_session=8 and ask for fresh sub-agents with fork_turns: "none", so the skill's verifiers stay independent.

    A Security run checks its gates in order: another Pass on the repository running on the machine, a Ready issue, a failed Security fix whose issue is still open, a Security finding waiting for the Day shift, then an unchanged Base branch since the last completed Security audit. An unreproduced draft advisory with no severity waits; closing it, publishing it or assigning severity ends that wait. A reproduced finding waits while fixing is disabled until its record is closed or published, or its fix Ticket is closed. On a private repository, an open finding issue with needs-triage waits until that label is removed or the issue is closed. The audited commit comes from the skill's own run-metadata.json under the audit root, with no separate state file. A skip exits 0, starts no session, keeps no Command log and sends no Run notification; its reason goes into the repository's Activity log only when it changes, with the usual activity.quiet_skips behavior. Before starting work, thirdshift checks the chosen Harness and that Node.js is on PATH, since the embedded skill's report validators require it.

    The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when launch.pull is set. Its Session prompt runs thirdshift-security-audit in full audit mode with the quick profile, auditing the whole repository except vendored and third-party code. It names a SECURITY.md or conventional threat-model document when present, reads compatible earlier runs and ends incomplete instead of asking for input.

    -

    Each audit keeps a new output directory under <logs.dir>/<owner>/<repo>/audits/, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in commands/secure/; Session logs are named secure-<stamp>~<token>-security-audit.jsonl; the Activity log records the start and ending as for other Passes.

    +

    Each audit keeps a new output directory under <logs.dir>/<owner>/<repo>/audits/, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session trace. Command logs and Session traces are in commands/secure/; Session traces are named secure-<stamp>~<token>-security-audit.logs.jsonl; the Activity log records the start and ending as for other Passes.

    A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the other ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled security-finding and needs-triage. It is never replaced by a public finding issue.

    Security review

    security-review, or review = true under [security], enables one guidance review after a Run's opening session or a Spec run's Spec review, before Delivery pushes and enters the Repair loop. A Spec run reviews every Ticket against its Spec branch and the whole Spec branch against its Base branch once. no-security-review overrides the setting. Both words, with or without dashes, are accepted on commands that start Runs and follow dispatched Runs and Base fixes. Setup asks about the setting; it is off by default.

    The review runs on [security] harness when that is set, with that Harness's own [harness.<name>] Model and Effort, wherever it runs: in a Run, each Ticket, on a Spec run's Spec PR or in a Base fix. The command's harness, model and effort words choose the Harness of every other session, and don't change the review's; there is no word for the review's Harness. With security.harness blank or missing, the review runs on the Command's Harness. Every other session, Repairs and Resumes after the review included, stays on the Command's Harness.

    Before any session, a command checks its review's Harness with the same PATH, Model, Effort and sign-in checks as its own. If a non-default review Harness can't be used, the review falls back to harness.default, or Claude Code when blank, with that Harness's own configured Model and Effort. A single warning: line names both Harnesses and the reason. If the review Harness is already the default, or the fallback also fails its check, the command fails at preflight. A Spec run checks before any Ticket starts and hands the settled choice to all Tickets and the Spec PR. With Security review off, no review Harness is checked. A Pass checks it only if it may dispatch a Run: a Security run that only audits, or architect --plan-only, does not.

    -

    If a non-default review Harness passes preflight but returns a Harness error before any turns or tokens, thirdshift warns in the same way and retries the review once on the default Harness. A failure on the default, a model refusal or a failure after work starts is not retried. Each attempt keeps its own Session log and .models.json; the retry uses security-review-fallback, also selected by logs --session security-review. Those records, the Command log and the Run notification name the Harness each attempt actually ran on; the Built with line still names the Command's. A Continuation reads security.harness again and applies these checks again.

    +

    If a non-default review Harness passes preflight but returns a Harness error before any turns or tokens, thirdshift warns in the same way and retries the review once on the default Harness. A failure on the default, a model refusal or a failure after work starts is not retried. Each attempt keeps its own Session trace and .models.json; the retry uses security-review-fallback, also selected by logs --session security-review. Those records, the Command log and the Run notification name the Harness each attempt actually ran on; the Built with line still names the Command's. A Continuation reads security.harness again and applies these checks again.

    The session reads the relevant attack-class guidance and supporting code without delegated auditors or the full audit workflow. It fixes only findings reproduced by a failing proof-of-concept test, keeps that test and commits the fix. Unaddressed introduced findings appear under Security in the PR's Unaddressed findings. A Merge run, including a Spec run asked to merge its Spec PR, leaves the PR ready for review and fails with the findings named if any remain, or if the review was refused or incomplete. A Run or Spec run not asked to merge records the outcome and continues. An old finding, whose proof-of-concept also fails at the pinned merge base, is reported in a private file under the repository's logs (security-reviews/). thirdshift records it as a draft advisory, or a security-finding/needs-triage issue on a private repository, reusing existing fingerprints and preserving the Day shift's grades. New records include scored reproduction and fix-size metadata so a later Security run can take the fix when allowed. Its details stay out of the PR and it does not hold Self-merge; fixing belongs to a separate Security run. The separate Security audit retains its full audit workflow.

    Fencing

    diff --git a/site/prompts/index.html b/site/prompts/index.html index 7e62596ee..95866b321 100644 --- a/site/prompts/index.html +++ b/site/prompts/index.html @@ -81,7 +81,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it.
    @@ -115,7 +115,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it.
    @@ -149,7 +149,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -183,7 +183,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -217,7 +217,7 @@

    Prompts and skills

    For source edits, prefer an available structured patch/edit tool that makes the intended context and changed regions explicit (for example, `apply_patch` on Codex). Use the Harness's equivalent when available. Normal language-specific code generation and narrowly justified scripts remain appropriate; scope scripted replacements to the intended region and check the diff for unrelated changes. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -251,7 +251,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -274,7 +274,7 @@

    Prompts and skills

    Write the skill's report artifacts and run-metadata.json; mark run_status complete only when all required artifacts are written. Run both skill validators before finishing. End your final message with exactly `Security audit: complete` after writing valid artifacts, or `Security audit: incomplete` when incomplete. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -297,7 +297,7 @@

    Prompts and skills

    Recorded finding: <recorded Security finding> -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -321,7 +321,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -362,7 +362,7 @@

    Prompts and skills

    Private record: <recorded Security finding> -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -386,7 +386,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -416,7 +416,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -446,7 +446,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -479,7 +479,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. @@ -496,7 +496,7 @@

    Prompts and skills

    Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again. -You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it. diff --git a/site/tests/docs.test.cjs b/site/tests/docs.test.cjs index c84214d09..d10d7bc0a 100644 --- a/site/tests/docs.test.cjs +++ b/site/tests/docs.test.cjs @@ -3,6 +3,13 @@ const assert = require("node:assert/strict"); const { readFile, stat } = require("node:fs/promises"); const { resolve, dirname } = require("node:path"); +test("Session trace documentation does not advertise retired conversion commands", async () => { + for (const file of ["../../docs/guide.md", "../docs/index.html"]) { + const source = await readFile(resolve(__dirname, file), "utf8"); + assert.doesNotMatch(source, /thirdshift logs --(?:convert|remove-converted)\b/, file); + } +}); + test("the README and manual's repository links reach files and section anchors", async () => { const { marked } = await import("marked"); const { default: GithubSlugger } = await import("github-slugger"); diff --git a/src/architect.rs b/src/architect.rs index 2241394e5..ccc03e6b4 100644 --- a/src/architect.rs +++ b/src/architect.rs @@ -342,7 +342,7 @@ struct ArchitectRun<'a> { /// [`gates`], a skip recorded with its reason if one skips; then the check of /// its Harness, failing before any work if it can't run; then the record /// that it started work, which keeps its Command log with its repository's -/// logs, where its Session logs go too; then the Launch directory's pull, if +/// logs, where its Session traces go too; then the Launch directory's pull, if /// asked for. Then, unless it was interrupted, the Architecture review /// session and its [`conclude`]; then, unless `plan_only`, the plan it /// marked ready dispatched on its Base branch. @@ -383,7 +383,7 @@ fn failed(run: FailedRun) -> Outcome { /// [`run_through`] past its gates, up to its dispatch: the Harness check, /// the start, the pull, and the Architecture review, with its conclusion, -/// failing with the review's Session log if it gets that far. +/// failing with the review's Session trace if it gets that far. fn up_to_the_dispatch( outside: &mut impl Outside, architect_run: &ArchitectRun, diff --git a/src/args.rs b/src/args.rs index 96a54b7f5..257515b71 100644 --- a/src/args.rs +++ b/src/args.rs @@ -18,9 +18,6 @@ use crate::run::Goal; /// What thirdshift was asked to do. pub enum Command { - LogsConvert { - remove: bool, - }, Help, Version, Update, @@ -61,6 +58,11 @@ pub enum Command { command: String, json: bool, }, + Spans { + command: String, + span: Option, + json: bool, + }, Stop { command: String, requeue: bool, @@ -398,6 +400,36 @@ pub fn parse(args: &[String]) -> Result { }); } Some("show") => { + let usage = "usage: thirdshift show [--spans | --span ] [--json]"; + if args.iter().any(|arg| arg == "--spans" || arg == "--span") { + let command = args + .get(1) + .filter(|arg| !arg.starts_with('-')) + .ok_or_else(|| anyhow::anyhow!(usage))?; + let (mut tree, mut span, mut json) = (false, None, false); + let mut flags = args[2..].iter(); + while let Some(flag) = flags.next() { + match flag.as_str() { + "--json" if !json => json = true, + "--spans" if !tree && span.is_none() => tree = true, + "--span" if !tree && span.is_none() => { + span = Some( + flags + .next() + .filter(|id| !id.starts_with('-')) + .ok_or_else(|| anyhow::anyhow!(usage))? + .clone(), + ); + } + _ => bail!(usage), + } + } + return Ok(Command::Spans { + command: command.clone(), + span, + json, + }); + } return match &args[1..] { [command] if !command.starts_with('-') => Ok(Command::Show { command: command.clone(), @@ -414,11 +446,6 @@ pub fn parse(args: &[String]) -> Result { } Some("logs") => { match &args[1..] { - [flag] if flag == "--convert" || flag == "--remove-converted" => { - return Ok(Command::LogsConvert { - remove: flag == "--remove-converted", - }); - } [] => { return Ok(Command::LogsList { json: false, diff --git a/src/base_fix.rs b/src/base_fix.rs index 7e83b9534..4d7e523aa 100644 --- a/src/base_fix.rs +++ b/src/base_fix.rs @@ -296,7 +296,7 @@ impl BaseFix { } => { taken.local_work = local_work; if let Some(log) = log { - outside.step(format!("Base fix #{number} session log: {log}")); + outside.step(format!("Base fix #{number} session trace: {log}")); } bail!("Base fix {} failed: {cause}", taken.issue.url) } @@ -1024,7 +1024,7 @@ mod tests { for (log, log_line) in [ ( Some("/logs/8.log".to_string()), - Some(line("Base fix #8 session log: /logs/8.log")), + Some(line("Base fix #8 session trace: /logs/8.log")), ), (None, None), ] { diff --git a/src/child_run.rs b/src/child_run.rs index 2a0cfe480..520fd2d1d 100644 --- a/src/child_run.rs +++ b/src/child_run.rs @@ -71,7 +71,7 @@ pub struct Given { /// The start stamp of the command that started it, which its Session /// logs take. pub stamp: String, - /// The owning command, whose folder its Session logs inherit. + /// The owning command, whose folder its Session traces inherit. pub command: logs::CommandKind, /// What it is asked about a Base fix. pub base_fix: BaseFixAsk, @@ -403,7 +403,7 @@ pub enum Ended { pr_url: Option, base_fix: Option, }, - /// Why, and its session log, as it reported them. + /// Why, and its session trace, as it reported them. Failed { cause: String, log: Option, @@ -433,7 +433,7 @@ pub struct Handle { } /// Start a Run of `kind` on `issue` in a child `thirdshift`, from the same -/// Launch directory, given this command's start stamp for its Session logs, +/// Launch directory, given this command's start stamp for its Session traces, /// asked `base_fix` about a Base fix, and handed `asks`. pub fn start( issue: &IssueUrl, diff --git a/src/child_run/execution_tests.rs b/src/child_run/execution_tests.rs index a54585371..0aa03944d 100644 --- a/src/child_run/execution_tests.rs +++ b/src/child_run/execution_tests.rs @@ -45,7 +45,7 @@ fn distinct_children_deliver_out_of_order_with_their_own_endings_and_accounts() "open(ROOT + '/ready', 'w').close()\nwhile not os.path.exists(ROOT + '/finish'): time.sleep(.01)\nos.write(1, b'{PR}\\n')\nos.write(2, b'thirdshift: Base fix: https://github.com/acme/widgets/issues/8 merged\\n')" )); let second = Fixture::new( - "os.write(2, b'thirdshift: checks failed\\nthirdshift: session log: /logs/22.jsonl\\n')\nraise SystemExit(7)", + "os.write(2, b'thirdshift: checks failed\\nthirdshift: session trace: /logs/22.jsonl\\n')\nraise SystemExit(7)", ); let mut runs = Runs::default(); first.start_in(&mut runs, 21).unwrap(); @@ -283,7 +283,7 @@ fn stdout_pipe_pressure_completes_with_the_last_line_as_the_pr() { #[test] fn malformed_diagnostics_preserve_later_failure_cause_and_final_session_log() { let fixture = Fixture::new( - "os.write(2, b'bad \\xff\\nthirdshift: inherited failure\\r\\nthirdshift: #9: descendant failure\\nthirdshift: session log: /logs/248.jsonl')\nraise SystemExit(7)", + "os.write(2, b'bad \\xff\\nthirdshift: inherited failure\\r\\nthirdshift: #9: descendant failure\\nthirdshift: session trace: /logs/248.jsonl')\nraise SystemExit(7)", ); let ended = fixture.start().unwrap().wait().unwrap(); assert!(matches!(ended, Ended::Failed { cause, log: Some(log), .. } diff --git a/src/console.rs b/src/console.rs index d5d1905b1..527362a10 100644 --- a/src/console.rs +++ b/src/console.rs @@ -66,6 +66,7 @@ enum View { CronJobs, Inbox, Inspect, + Spans, Log, Changes, Logs, @@ -172,12 +173,22 @@ const PAGES: &[PageRoute] = &[ PageRoute { path: "/waterfall", cli: &[ + "thirdshift show widgets#7 --spans --json", "thirdshift show widgets#7 --json", "thirdshift logs widgets#7", ], method: Method::GET, view: View::Inbox, }, + PageRoute { + path: "/spans", + cli: &[ + "thirdshift show widgets#7 --spans --json", + "thirdshift show widgets#7 --span id --json", + ], + method: Method::GET, + view: View::Spans, + }, PageRoute { path: "/lanes", cli: &["thirdshift status --all --json"], @@ -350,6 +361,7 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route (View::CronJobs, Method::GET) => get(cronjobs::show), (View::Inbox, Method::GET) => get(move |query| inbox(cli, path, query)), (View::Inspect, Method::GET) => get(inspect), + (View::Spans, Method::GET) => get(waterfall::data), (View::Log, Method::GET) => get(log), (View::Changes, Method::GET) => get(live::events), (View::Logs, Method::GET) => get(logs::index), diff --git a/src/console/assets/console.css b/src/console/assets/console.css index b30449181..d3c196fc8 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -116,12 +116,6 @@ header::after { content: ""; display: block; height: 4px; background: var(--acti .command-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; } .command-chips a { border: 1px solid var(--rule); border-radius: var(--radius); padding: 6px; overflow-wrap: anywhere; } .command-chips [aria-current] { outline: 2px solid var(--head); } -.waterfall-run { border-left: 2px solid var(--rule); padding-left: 8px; margin: 16px 0; overflow-wrap: anywhere; } -.waterfall-run[data-depth="1"] > .command { padding-left: 12px; } -.waterfall-run[data-depth="2"] > .command { padding-left: 24px; } -.waterfall-stages, .waterfall-stages ol { list-style: none; padding-left: 0; } -.waterfall-stage > h4, .waterfall-stage > p { margin-left: 12px; } -.waterfall-tool > a, .waterfall-tool > p { margin-left: 24px; } .stage-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 20px 0; } .stage-grid caption { text-align: left; font-weight: var(--head-weight); } .stage-grid th, .stage-grid td { border: 1px solid var(--rule); padding: 8px; text-align: left; vertical-align: top; overflow-wrap: anywhere; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 0ddff7d3f..3d3613db9 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -1,5 +1,7 @@ import { activity, loadLook, motionAllowed } from './look.js'; import { watchCommands } from './changes.js'; +import { initSpanTree } from './spans.js'; +let spanTree; // Browser preferences are conveniences; malformed or unavailable storage hides nothing. function stored(key, fallback) { @@ -119,11 +121,24 @@ function morphTimePage(html) { async function refresh() { if (observesCommands) { - const response = await fetch(timePage ? location.href : '/', { cache: "no-store" }); - if (!response.ok) throw new Error("Inbox unavailable"); - const html = await response.text(); - if (timePage) morphTimePage(html); - else morph(html); + if (timePage?.matches('[data-span-page]')) { + const response = await fetch(location.href, { cache: 'no-store' }); + if (!response.ok) throw new Error('Span tree unavailable'); + const html = await response.text(); + if (spanTree) spanTree.update(new DOMParser().parseFromString(html, 'text/html')); + else { + morphTimePage(html); + spanTree = initializeSpanTree(); + } + activity(); + loadLook(); + } else { + const response = await fetch(timePage ? location.href : '/', { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + const html = await response.text(); + if (timePage) morphTimePage(html); + else morph(html); + } } await refreshPeek(); } @@ -148,6 +163,20 @@ let selected = null; let peekCommand = null; let pendingPeek = null; +function initializeSpanTree() { return initSpanTree({ + openDetails() { + peekCommand = null; + pendingPeek?.abort(); + pendingPeek = null; + }, + openCommand: span => { + peekCommand = null; + togglePeek({ dataset: { command: span.command, peek: span.command_url } }); + } +}); } +spanTree = initializeSpanTree(); +activity(); + function select(element, { scroll = true } = {}) { selected?.classList.remove('is-selected'); selected?.removeAttribute('aria-current'); @@ -158,6 +187,7 @@ function select(element, { scroll = true } = {}) { } function closePeek() { + spanTree?.closeDetails(); stopPeekUpdates(); pendingPeek?.abort(); pendingPeek = null; diff --git a/src/console/assets/look.js b/src/console/assets/look.js index 7d15c6072..7c5717d63 100644 --- a/src/console/assets/look.js +++ b/src/console/assets/look.js @@ -38,7 +38,7 @@ reduced.addEventListener('change', motion); motion(); export function activity() { - root.dataset.live = String(!!document.querySelector('.command[data-live="true"], #log-content[data-live="true"]')); + root.dataset.live = String(!!document.querySelector('.command[data-live="true"], #log-content[data-live="true"], #span-tree[data-live="true"]')); } function apply(settings) { diff --git a/src/console/assets/spans.css b/src/console/assets/spans.css new file mode 100644 index 000000000..039891178 --- /dev/null +++ b/src/console/assets/spans.css @@ -0,0 +1,68 @@ +.span-controls, .span-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 10px 0; } +#span-axis, .span-row { display: grid; grid-template-columns: minmax(0, 2fr) 7ch minmax(0, 3fr); gap: 8px; align-items: center; } +#span-axis { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--rule); padding: 8px 0; font: 11px var(--font-mono); color: var(--dim); } +#span-ticks { position: relative; height: 16px; margin-right: 2px; } +#span-ticks span { position: absolute; white-space: nowrap; transform: translateX(-50%); } +#span-ticks span:first-child { transform: none; } +#span-ticks span:last-child { transform: translateX(-100%); } +#span-zoom { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 6px; border: 1px solid var(--look); color: var(--text); } +#span-zoom b { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.span-item { outline: none; } +.span-item:focus > .span-row { outline: 2px solid var(--head); outline-offset: -2px; } +.span-row { padding: 5px 0; border-bottom: 1px solid var(--rule); cursor: pointer; font-size: 13px; } +.span-row:hover { background: var(--panel); } +.span-label { display: flex; align-items: center; gap: 4px; min-width: 0; padding-left: calc(var(--span-depth) * 12px); white-space: nowrap; } +.span-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.span-kind, .span-duration, .span-rank, .span-fold-hint { font: 11px var(--font-mono); } +.span-kind { color: var(--dim); } +.span-twist { width: 16px; flex: none; text-align: center; } +.span-duration { text-align: right; } +.span-rank { border: 1px solid var(--look); padding: 0 3px; color: var(--look-text); border-radius: var(--radius); } +.span-fold-hint { color: var(--urgent-text); flex-shrink: 0; } +.span-status-error { color: var(--urgent-text); } +.span-status-running { color: var(--working-text); } +.span-track { display: block; position: relative; height: 10px; background: var(--panel-2); overflow: hidden; } +.span-bar { display: block; position: absolute; top: 0; bottom: 0; width: 0; background: var(--working); } +.span-bar.kind-model, .span-bar.kind-turn { background: var(--link); } +.span-bar.kind-agent { background: var(--look); } +.span-bar.kind-cmd, .span-bar.kind-run, .span-bar.kind-stage { background: var(--dim); } +.span-bar.span-error { background: var(--urgent); } +.span-bar.span-running { background-image: repeating-linear-gradient(120deg, transparent 0 6px, var(--bg) 6px 8px); } +.span-bar.span-estimate { border: 2px dotted var(--text); } +.span-bar.span-slow { box-shadow: inset 0 0 0 1px var(--look); } +.outside-window { opacity: .45; } +#slowest-calls { border: 1px solid var(--rule); padding: 4px 10px; margin-bottom: 12px; border-radius: var(--radius); background: var(--panel); } +#slowest-calls summary { font-size: 14px; } +#slowest-calls ol { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; } +#slowest-calls li + li { border-top: 1px solid var(--rule); } +#slowest-calls button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto 7ch; align-items: baseline; gap: 2px 8px; width: 100%; text-align: left; border: 0; padding: 6px 0; background: transparent; cursor: pointer; } +.slow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.slow-clock, .slow-duration { font: 11px var(--font-mono); } +.slow-duration { text-align: right; } +.slow-where { grid-column: 2 / -1; color: var(--dim); font-size: 12px; } +.span-path { font-size: 11px; gap: 2px 8px; margin: 4px 0; } +.span-path a + a::before { content: '› '; color: var(--dim); } +.span-card h2 { font-size: 18px; overflow-wrap: anywhere; } +.span-card pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 40dvh; overflow: auto; padding: 8px; background: var(--bg); border: 1px solid var(--rule); } +.span-card summary { font-size: 14px; } +.span-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 8px; margin: 10px 0; } +.span-tile { padding: 8px; border: 1px solid var(--rule); border-radius: var(--radius); background: var(--bg); } +.span-tile b, .span-tile span { display: block; } +.span-tile b { font-size: 18px; } +.span-tile span { color: var(--dim); } +.span-position { margin: 8px 0; } +.span-overview { width: 100%; table-layout: fixed; border-collapse: collapse; } +.span-overview th, .span-overview td { text-align: left; vertical-align: top; overflow-wrap: anywhere; padding: 4px; border-bottom: 1px solid var(--rule); } +@media (max-width: 760px) { + body.peek-open main[data-span-page] { padding-bottom: 65dvh; } + #span-axis, .span-row { grid-template-columns: minmax(0, 1fr) 7ch; gap: 4px 6px; } + #span-axis > span { display: none; } + #span-ticks { grid-column: 1; } + .span-label { grid-column: 1 / -1; padding-left: calc(var(--span-depth) * 8px); } + .span-row > .span-track { grid-row: 2; grid-column: 1; height: 6px; } + .span-duration { grid-row: 2; grid-column: 2; } + #slowest-calls button { grid-template-columns: auto minmax(0, 1fr) 7ch; } + .slow-name { grid-column: 2 / -1; } + .slow-clock { grid-column: 2; } + .slow-duration { grid-column: 3; } +} diff --git a/src/console/assets/spans.js b/src/console/assets/spans.js new file mode 100644 index 000000000..e89c3a9bf --- /dev/null +++ b/src/console/assets/spans.js @@ -0,0 +1,302 @@ +// The reader shared with `show --spans` owns trace interpretation and ancestry. +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} +function duration(ms) { + if (ms === null || !Number.isFinite(ms)) return 'Time unknown'; + if (ms < 1000) return `${Math.round(ms * 1000) / 1000} ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${Math.round(seconds * 100) / 100} s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`; +} +function clock(ms) { return ms === null ? 'unknown' : new Date(ms).toLocaleTimeString([], { hour12: false }); } +function pretty(text) { + if (text === null || text === undefined) return 'No text retained.'; + try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } +} +const quote = text => `'${text.replaceAll("'", "'\\''")}'`; +const kindName = { cmd: 'Command', run: 'Run', stage: 'stage', sess: 'session', model: 'model call', tool: 'tool call', turn: 'turn', agent: 'sub-agent' }; + +export function initSpanTree({ openCommand, openDetails }) { + const tree = document.getElementById('span-tree'); + if (!tree) return null; + let data = JSON.parse(document.getElementById('span-data').textContent); + let nodes, children, roots, axis, ranks, descendants; + const expanded = new Map(); + let selected = data.spans[0]?.id, detail = null, zoom = null; + const content = document.getElementById('peek-content'); + const peek = document.getElementById('peek'); + const header = document.getElementById('span-axis'); + const slow = document.querySelector('#slowest-calls ol'); + const status = document.getElementById('span-status'); + const end = span => span.status === 'running' ? Date.now() : span.end; + const elapsed = span => span.status === 'running' && span.start !== null ? Math.max(0, Date.now() - span.start) : span.duration_ms; + const durationText = span => `${duration(elapsed(span))}${span.estimated ? '*' : ''}`; + const rowFor = id => document.getElementById(`span-${id}`); + const isOpen = span => expanded.get(span.id) ?? ['cmd', 'run', 'stage'].includes(span.kind); + const inScope = span => span.start !== null && end(span) !== null && end(span) >= axis.start && span.start <= axis.end; + const button = (text, action) => { + const node = el('button', '', text); + node.type = 'button'; + node.addEventListener('click', action); + return node; + }; + function ancestors(span) { + const path = [], seen = new Set([span.id]); + while (nodes.has(span.parent_id) && !seen.has(span.parent_id)) { + span = nodes.get(span.parent_id); + seen.add(span.id); + path.unshift(span); + } + return path; + } + function index() { + tree.dataset.live = String(data.spans.some(span => span.status === 'running')); + nodes = new Map(data.spans.map(span => [span.id, span])); + children = new Map(); roots = []; + for (const span of data.spans) { + if (nodes.has(span.parent_id)) { + if (!children.has(span.parent_id)) children.set(span.parent_id, []); + children.get(span.parent_id).push(span); + } else roots.push(span); + } + for (const list of children.values()) list.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity)); + if (!nodes.has(selected)) selected = roots[0]?.id; + if (!nodes.has(detail)) detail = null; + if (!nodes.has(zoom)) zoom = null; + } + function wholeAxis() { + const starts = data.spans.map(span => span.start).filter(time => time !== null); + const ends = data.spans.map(span => end(span) ?? span.start).filter(time => time !== null); + const start = starts.length ? Math.min(...starts) : Date.now(); + return { start, end: Math.max(start + 1, ...ends) }; + } + function setAxis() { + axis = wholeAxis(); + const span = nodes.get(zoom); + if (!span || span.start === null) return; + const finish = end(span) ?? span.start; + if (children.has(span.id)) axis = { start: span.start, end: Math.max(finish, span.start + 1) }; + else { + const parent = nodes.get(span.parent_id), pad = Math.max(elapsed(span) ?? 0, 1000) * 1.5; + axis = { start: Math.max(parent?.start ?? axis.start, span.start - pad), end: Math.min(parent ? end(parent) ?? Date.now() : axis.end, finish + pad) }; + if (axis.end <= axis.start) axis = { start: span.start - pad, end: finish + pad }; + } + } + function rankCalls() { + const calls = data.spans.filter(span => ['tool', 'model'].includes(span.kind) && elapsed(span) !== null && inScope(span)) + .sort((a, b) => elapsed(b) - elapsed(a) || a.id.localeCompare(b.id)).slice(0, 10); + ranks = new Map(calls.map((span, i) => [span.id, i + 1])); + descendants = new Map(data.spans.map(span => [span.id, { errors: 0, ranks: [] }])); + for (const span of data.spans) for (const parent of ancestors(span)) { + const summary = descendants.get(parent.id); + if (span.error !== null) summary.errors++; + if (ranks.has(span.id)) summary.ranks.push(ranks.get(span.id)); + } + const fragment = document.createDocumentFragment(); + for (const span of calls) { + const li = el('li'), reveal = button('', () => revealSpan(span.id)); + reveal.dataset.revealSpan = span.id; + reveal.append(el('span', 'span-rank', `#${ranks.get(span.id)}`), el('span', 'slow-name', `${span.kind} · ${span.name}`), el('span', 'slow-clock', `${clock(span.start)} → ${span.status === 'running' ? 'now' : clock(span.end)}`), el('span', 'slow-duration', durationText(span))); + const parent = nodes.get(span.parent_id); + const fraction = parent && elapsed(parent) > 0 ? `${Math.round(elapsed(span) / elapsed(parent) * 100)}% of its ${kindName[parent.kind]}` : 'Parent duration unknown'; + reveal.append(el('span', 'slow-where', `${fraction}${span.run ? ` · #${span.run}` : ''}${span.session_kind ? ` · ${span.session_kind} session` : ''}`)); + li.append(reveal); fragment.append(li); + } + if (!calls.length) fragment.append(el('li', '', 'No timed tool or model calls in this window.')); + slow.replaceChildren(fragment); + } + function bar(span, range = axis) { + const track = el('span', 'span-track'); + const fill = el('i', `span-bar kind-${span.kind}${span.error !== null ? ' span-error' : ''}${span.status === 'running' ? ' span-running' : ''}${span.estimated ? ' span-estimate' : ''}${ranks.has(span.id) ? ' span-slow' : ''}`); + if (span.start !== null && end(span) !== null) { + const scale = 100 / Math.max(1, range.end - range.start); + const left = Math.min(100, Math.max(0, (span.start - range.start) * scale)); + const width = Math.min(100 - left, Math.max(0, (Math.min(end(span), range.end) - Math.max(span.start, range.start)) * scale)); + fill.style.left = `${left}%`; fill.style.width = `${width}%`; + } else fill.style.width = '0'; + track.append(fill); return track; + } + function renderTree() { + const focused = tree.contains(document.activeElement) ? document.activeElement.closest('[data-span-id]')?.dataset.spanId : null; + function item(span, depth, position, count, path) { + if (path.has(span.id)) return document.createDocumentFragment(); + const nextPath = new Set(path).add(span.id), nested = children.get(span.id) ?? [], open = isOpen(span); + const wrapper = el('div', 'span-item'); + wrapper.id = `span-${span.id}`; wrapper.dataset.spanId = span.id; wrapper.dataset.kind = span.kind; + wrapper.setAttribute('role', 'treeitem'); wrapper.setAttribute('aria-level', depth + 1); + wrapper.setAttribute('aria-labelledby', `span-label-${span.id}`); + wrapper.setAttribute('aria-posinset', position + 1); wrapper.setAttribute('aria-setsize', count); + wrapper.setAttribute('aria-selected', span.id === selected); wrapper.tabIndex = span.id === selected ? 0 : -1; + if (nested.length) wrapper.setAttribute('aria-expanded', open); + const row = el('div', `span-row${span.id === selected ? ' is-selected' : ''}${zoom && !inScope(span) ? ' outside-window' : ''}`); + const label = el('span', 'span-label'); label.id = `span-label-${span.id}`; label.style.setProperty('--span-depth', depth); + const twist = el('span', 'span-twist', nested.length ? open ? '▾' : '▸' : '·'); + twist.setAttribute('aria-hidden', 'true'); twist.dataset.discloseSpan = span.id; + label.append(twist, el('span', 'span-kind', span.kind)); + if (ranks.has(span.id)) label.append(el('span', 'span-rank', `#${ranks.get(span.id)}`)); + const name = el('span', 'span-name', span.name); name.title = span.name; label.append(name); + if (span.error !== null || span.status === 'running') { + const mark = el('span', span.error !== null ? 'span-status-error' : 'span-status-running', span.error !== null ? '✕' : '●'); + mark.title = span.error ?? 'running'; mark.setAttribute('aria-label', span.error ?? 'running'); label.append(mark); + } + if (!open && nested.length) { + const summary = descendants.get(span.id); + if (summary.errors) label.append(el('span', 'span-fold-hint', `${summary.errors} error${summary.errors === 1 ? '' : 's'}`)); + if (summary.ranks.length) label.append(el('span', 'span-fold-hint', summary.ranks.sort((a, b) => a - b).map(rank => `#${rank}`).join(' '))); + } + row.append(label, el('span', 'span-duration', durationText(span)), bar(span)); wrapper.append(row); + if (open && nested.length) { + const group = el('div', 'span-children'); group.setAttribute('role', 'group'); + nested.forEach((child, i) => group.append(item(child, depth + 1, i, nested.length, nextPath))); wrapper.append(group); + } + return wrapper; + } + tree.replaceChildren(...roots.map((span, i) => item(span, 0, i, roots.length, new Set()))); + if (focused) rowFor(focused)?.focus({ preventScroll: true }); + } + function renderAxis() { + document.getElementById('span-ticks').replaceChildren(...[0, 0.5, 1].map(fraction => { + const tick = el('span', '', `+${duration((axis.end - axis.start) * fraction)}`); tick.style.left = `${fraction * 100}%`; return tick; + })); + const notice = document.getElementById('span-zoom'); notice.hidden = !zoom; notice.replaceChildren(); + if (zoom) notice.append(el('b', '', `Timeline zoomed around ${nodes.get(zoom).name}: ${clock(axis.start)} → ${clock(axis.end)} (${duration(axis.end - axis.start)})`), button('Zoom out to whole Command', () => zoomTo(null))); + } + function render() { setAxis(); rankCalls(); renderAxis(); renderTree(); if (detail) renderDetails(); } + function centerRow(id) { + requestAnimationFrame(() => { + const row = rowFor(id)?.querySelector(':scope > .span-row'); if (!row) return; + const top = Math.max(0, header.getBoundingClientRect().bottom); + const bottom = innerWidth <= 760 && !peek.hidden ? peek.getBoundingClientRect().top : innerHeight; + const rect = row.getBoundingClientRect(); + window.scrollBy({ top: rect.top + rect.height / 2 - (top + bottom) / 2, behavior: 'instant' }); + }); + } + function choose(id, focus = true) { selected = id; renderTree(); if (focus) rowFor(id)?.focus({ preventScroll: true }); } + function revealSpan(id) { + const span = nodes.get(id); if (!span) return; + ancestors(span).forEach(parent => expanded.set(parent.id, true)); choose(id); detail = id; renderDetails(); centerRow(id); + } + function zoomTo(id) { zoom = id; render(); centerRow(selected); } + function renderDetails() { + const span = nodes.get(detail); if (!span) return; + openDetails(); + const folds = new Map([...content.querySelectorAll('details')].map(node => [node.dataset.detailSection, node.open])); + const sameSpan = content.dataset.detailSpanId === span.id, scroll = sameSpan ? peek.scrollTop : 0; + content.dataset.detailSpanId = span.id; + const card = el('article', 'span-card'), path = el('nav', 'span-path'); path.setAttribute('aria-label', 'Span ancestors'); + for (const parent of ancestors(span)) { + const link = el('a', '', parent.name); link.href = `#span-${parent.id}`; + link.addEventListener('click', event => { event.preventDefault(); revealSpan(parent.id); }); path.append(link); + } + card.append(path, el('h2', '', `${span.kind} · ${span.name}`), el('p', '', `${span.status}${span.error !== null ? ` · ${span.error}` : ''} · ${clock(span.start)} → ${span.status === 'running' ? 'now' : clock(span.end)}${span.estimated ? ' · estimated*' : ''}`)); + const tiles = el('div', 'span-tiles'); + const tile = (label, value) => { + if (value === null || value === undefined) return; + const node = el('div', 'span-tile'); node.append(el('b', '', String(value)), el('span', '', label)); tiles.append(node); + }; + tile('Duration', elapsed(span) === null ? null : durationText(span)); + const parent = nodes.get(span.parent_id); + if (parent && elapsed(parent) > 0 && elapsed(span) !== null) tile(`% of its ${kindName[parent.kind]}`, `${Math.round(elapsed(span) / elapsed(parent) * 100)}%`); + const attrs = span.attributes; + tile('Tokens in', attrs['gen_ai.usage.input_tokens']); tile('Tokens out', attrs['gen_ai.usage.output_tokens']); tile('Cost (USD)', attrs['thirdshift.cost.usd']); + const nested = data.spans.filter(child => ancestors(child).some(parent => parent.id === span.id)); + for (const [kind, label] of [['model', 'Model calls'], ['tool', 'Tool calls']]) { + const count = nested.filter(child => child.kind === kind).length; if (count) tile(label, count); + } + const errors = descendants.get(span.id).errors + (span.error !== null ? 1 : 0); if (errors) tile('Errors', errors); + card.append(tiles); + for (const [label, owner] of [['Within parent', parent], ['Within Command', roots[0]]]) { + if (owner && owner.start !== null && end(owner) !== null) { + const position = el('div', 'span-position', label); position.append(bar(span, { start: owner.start, end: end(owner) })); card.append(position); + } + } + const actions = el('div', 'span-actions'); + const zoomButton = button(zoom ? 'Zoom out to whole Command' : children.has(span.id) ? `Zoom timeline to this ${kindName[span.kind]}` : 'Zoom timeline around this call', () => zoomTo(zoom ? null : span.id)); + zoomButton.disabled = span.start === null; actions.append(zoomButton); + if (span.conversation_url) { const link = el('a', '', 'Open in conversation'); link.href = span.conversation_url; actions.append(link); } + const cli = `thirdshift show ${quote(data.command)} --span ${span.id} --json`; + const copy = button('Copy CLI', async () => { + try { await navigator.clipboard.writeText(cli); status.textContent = 'CLI copied.'; } catch { status.textContent = cli; } + }); + copy.dataset.copyCli = cli; actions.append(copy, button('Command details', () => { detail = null; openCommand(span); })); card.append(actions); + function section(label, body, open = false) { + const disclosure = el('details'); disclosure.dataset.detailSection = label; + disclosure.open = sameSpan ? folds.get(label) ?? open : open; + disclosure.append(el('summary', '', label), body); card.append(disclosure); + } + const noModelText = span.kind === 'model' || span.kind === 'turn'; + section('Input', el('pre', '', noModelText ? 'The text is in the conversation view.' : pretty(span.input)), true); + section('Output', el('pre', '', noModelText ? 'The text is in the conversation view.' : pretty(span.output))); + section('All attributes', el('pre', '', JSON.stringify(attrs, null, 2))); + const table = el('table', 'span-overview'), body = el('tbody'); + const overview = { Kind: kindName[span.kind], Status: span.status, Start: span.start === null ? 'unknown' : new Date(span.start).toISOString(), End: span.status === 'running' ? 'now' : span.end === null ? 'unknown' : new Date(span.end).toISOString(), Model: attrs['gen_ai.response.model'] ?? attrs['gen_ai.request.model'], Harness: attrs['thirdshift.harness'], 'Tokens in': attrs['gen_ai.usage.input_tokens'], 'Tokens out': attrs['gen_ai.usage.output_tokens'], 'Cache read': attrs['thirdshift.usage.cache_read_tokens'], 'Cache write': attrs['thirdshift.usage.cache_write_tokens'], 'Cost (USD)': attrs['thirdshift.cost.usd'], 'Span ID': span.id }; + for (const [key, value] of Object.entries(overview)) { + if (value === undefined || value === null) continue; + const row = el('tr'); row.append(el('th', '', key), el('td', '', String(value))); body.append(row); + } + table.append(body); section('Overview table', table); content.replaceChildren(card); + peek.hidden = false; document.body.classList.add('peek-open'); peek.scrollTop = scroll; + } + tree.addEventListener('click', event => { + const row = event.target.closest('.span-row'); if (!row) return; + const id = row.parentElement.dataset.spanId; + if (event.target.closest('[data-disclose-span]') && children.has(id)) { expanded.set(id, !isOpen(nodes.get(id))); choose(id); } + else revealSpan(id); + }); + tree.addEventListener('focusin', event => { + const id = event.target.closest('[data-span-id]')?.dataset.spanId; + if (id && selected !== id) { + selected = id; + for (const node of tree.querySelectorAll('[role="treeitem"]')) { + node.tabIndex = node.dataset.spanId === id ? 0 : -1; node.setAttribute('aria-selected', node.dataset.spanId === id); + node.querySelector(':scope > .span-row').classList.toggle('is-selected', node.dataset.spanId === id); + } + } + }); + tree.addEventListener('keydown', event => { + const span = nodes.get(selected); if (!span || event.altKey || event.ctrlKey || event.metaKey) return; + const visible = [...tree.querySelectorAll('[role="treeitem"]')].map(node => node.dataset.spanId), index = visible.indexOf(selected); + let target; + switch (event.key) { + case 'ArrowDown': case 'j': target = visible[Math.min(index + 1, visible.length - 1)]; break; + case 'ArrowUp': case 'k': target = visible[Math.max(0, index - 1)]; break; + case 'Home': target = visible[0]; break; + case 'End': target = visible.at(-1); break; + case 'ArrowRight': + if (children.has(selected)) { if (isOpen(span)) target = children.get(selected)[0].id; else { expanded.set(selected, true); renderTree(); } } + break; + case 'ArrowLeft': + if (children.has(selected) && isOpen(span)) { expanded.set(selected, false); renderTree(); } else target = span.parent_id; + break; + case 'Enter': case ' ': revealSpan(selected); break; + default: return; + } + event.preventDefault(); event.stopPropagation(); + if (target) { choose(target); centerRow(target); } + }); + for (const control of document.querySelectorAll('[data-expand]')) control.addEventListener('click', () => { + const level = control.dataset.expand; + for (const span of nodes.values()) expanded.set(span.id, level === 'all' || span.kind === 'cmd' || (['stage', 'sess'].includes(level) && span.kind === 'run') || (level === 'sess' && span.kind === 'stage')); + const folded = ancestors(nodes.get(selected)).find(parent => !isOpen(parent)); if (folded) selected = folded.id; + render(); + }); + index(); render(); + const timer = setInterval(() => { if (data.spans.some(span => span.status === 'running')) render(); }, 1000); + window.addEventListener('pagehide', () => clearInterval(timer), { once: true }); + return { + closeDetails() { detail = null; delete content.dataset.detailSpanId; }, + update(document) { + const next = JSON.parse(document.getElementById('span-data').textContent); + const chips = document.querySelector('.command-chips'); + tree.closest('main').querySelector('.command-chips').replaceChildren(...chips.childNodes); + const changed = JSON.stringify(next.spans) !== JSON.stringify(data.spans); + data = next; + if (changed) { index(); render(); } + } + }; +} diff --git a/src/console/conversation.rs b/src/console/conversation.rs index 08dccf9fe..fa219f899 100644 --- a/src/console/conversation.rs +++ b/src/console/conversation.rs @@ -1,4 +1,4 @@ -//! Render normalized Session-trace events, with legacy Claude/Codex support, +//! Render normalized Session-trace events, including raw Claude/Codex events, //! as one escaped conversation with each `https://` URL a link. use super::filters::{escaped, linked}; use crate::harness::interpretation::event::Event; @@ -85,7 +85,7 @@ pub(crate) fn render(lines: &str, kind: Kind) -> String { } fn record_parts(record: LogRecord) -> Vec { - let Some(AnyValue::String(raw)) = record.body else { + let Some(raw) = crate::logs::session::raw_event(record.body) else { return Vec::new(); }; // Backfilled Sessions retain the presentation of their original events. @@ -177,7 +177,7 @@ fn record_parts(record: LogRecord) -> Vec { } } if output.is_empty() { - // Thinking and other raw-only content still use the legacy renderer. + // Thinking and other raw-only content uses the Harness-event renderer. parts(&raw) } else { // Messages normalization has no thinking/unknown-block facts. Retain diff --git a/src/console/peek.html b/src/console/peek.html index 1ddcc6e4f..78c8d3ed0 100644 --- a/src/console/peek.html +++ b/src/console/peek.html @@ -6,6 +6,6 @@

    Command details

    {% for pr in pull_requests %}

    Pull request left: {{ pr|linkify }}

    {% endfor %} {{ cli }}

    Full Command log

    - {% for log in logs %}

    Session log: {{ log.name }}

    {% endfor %} + {% for log in logs %}

    Session trace: {{ log.name }}

    {% endfor %}
    {% for fact in facts %}{{ self.fact(fact)|safe }}{% endfor %}
    diff --git a/src/console/row.html b/src/console/row.html index c2fecb253..4d5a5e939 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,13 +1,13 @@
  • {% if row.live %}{% endif %}{{ row.state }} {% match row.subject_url %}{% when Some with (url) %}{{ row.subject }}{% when None %}{{ row.subject }}{% endmatch %} {{ row.title }}{{ row.age }}

    {{ row.now_line|linkify }}

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

    {{ row.harness }}

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

    {{ row.pull_request|linkify }}

    {% endif %} {% if row.count > 1 %}

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

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

    {{ row.cause|linkify }}

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

    Details · Full log

    {% if !row.findings.is_empty() %}
      {% for finding in row.findings %}
    • {{ finding.title }} · {% match finding.severity %}{% when Some with (severity) %}{{ severity }}{% when None %}ungraded{% endmatch %} · {{ finding.url|linkify }}
    • {% endfor %}
    {% endif %} - {% if !row.sessions.is_empty() %}{% endif %} + {% if !row.sessions.is_empty() %}{% endif %} {% if !row.children.is_empty() %}
      {% for child in row.children %}{{ self.child(child)|safe }}{% endfor %}
    {% endif %}
  • diff --git a/src/console/waterfall.html b/src/console/waterfall.html index e08e45ee7..b39655ddc 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -6,34 +6,27 @@ Waterfall · {{ page.hostname }} · thirdshift + {% include "console/header.html" %} {% include "console/nav.html" %} -
    -

    Waterfall

    thirdshift show <command> --json · thirdshift logs <command>
    +
    +

    Waterfall

    thirdshift show <command> --spans --json · thirdshift logs <command>
    - {% if view.runs.is_empty() %}

    No Commands yet.

    {% else %} -

    Shared time axis: {{ view.start }} to {{ view.end }}. Session traces use measured tool spans; running calls extend from their start record to now. Old Session logs estimate intervals until the next matched call or stage boundary, including waiting. Unknown durations have no bar.

    - {% for run in view.runs %} -
    -

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    - -

    {{ run.span.start }} · {{ run.span.text }}

    - {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %} -
      {% for stage in run.stages %} -
    1. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

      - -
        {% for tool in stage.tools %}
      1. {{ tool.label }}

        {{ tool.span.start }} · {{ tool.span.text }}

        - -
      2. {% endfor %}
      -
    2. - {% endfor %}
    -
    - {% endfor %} + {% if view.tree.spans.is_empty() %}

    No Commands yet.

    {% else %} +

    One shared time axis. * marks estimated times. Running calls extend to now.

    +
    Slowest calls
      +
      + Expand to +
      +
      SpanTime
      +
      +

      {% endif %} +
      {% include "console/chrome.html" %} diff --git a/src/console/waterfall.rs b/src/console/waterfall.rs index ac3a59480..c2b7558fd 100644 --- a/src/console/waterfall.rs +++ b/src/console/waterfall.rs @@ -1,28 +1,55 @@ //! One selected Command, using the same retained tree and logs as show/logs. use anyhow::Result; use askama::Template; -use axum::response::Response; +use axum::extract::RawQuery; +use axum::response::{IntoResponse, Response}; use crate::config::UserConfig; -use crate::logs::status::waterfall::{self, View}; +use crate::logs::spans::{self, View}; #[derive(Template)] #[template(path = "console/waterfall.html")] struct WaterfallPage { page: super::PageContext, view: View, + data: String, } pub(super) async fn show(query: Option) -> Response { super::page( tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; + let view = spans::load(&config, &super::query_value(&query, "command"))?; + // JSON is data, never executable markup, even inside a script tag. + let data = serde_json::to_string(&view.tree)? + .replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e"); Ok(WaterfallPage { page: super::PageContext::new(&config), - view: waterfall::load(&config, &super::query_value(&query, "command"))?, + view, + data, } .render()?) }) .await, ) } + +pub(super) async fn data(RawQuery(query): RawQuery) -> Response { + match tokio::task::spawn_blocking(move || -> Result { + let view = spans::load(&UserConfig::load()?, &super::query_value(&query, "command"))?; + let selected = super::query_value(&query, "span"); + Ok(if selected.is_empty() { + serde_json::to_value(view.tree)? + } else { + serde_json::to_value(view.tree.span(&selected)?)? + }) + }) + .await + { + Ok(Ok(value)) => axum::Json(value).into_response(), + Ok(Err(error)) => (axum::http::StatusCode::BAD_REQUEST, error.to_string()).into_response(), + Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/src/delivery.rs b/src/delivery.rs index ea3acaf42..02bcc1e0a 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -58,7 +58,7 @@ pub struct Delivery<'a> { /// The one Base fix the Delivery may start, or wait on, when its only red /// checks are Inherited failures. pub base_fix: &'a mut BaseFix, - /// Where its Session logs go. + /// Where its Session traces go. pub logs: &'a Logs, /// When the Run began: an untrusted edit to `issue` made since then /// starts no further session. @@ -352,8 +352,8 @@ fn retry_if_interrupted( /// head whose CI was watched. Problems along the way are reported, not /// raised, so the error is what the Run fails with. Worktree owns preservation /// and retains work that may not have reached origin. `log` is the most recent -/// Session log, if a session created one, which the Failed run reports unless -/// the error is a Security hold with its review's own Session log: a hold is +/// Session trace, if a session created one, which the Failed run reports unless +/// the error is a Security hold with its review's own Session trace: a hold is /// raised only after the Repair loop, whose sessions may have run since. /// `stopped_at` is the stage its recovery lines report; `worktree`, where work /// preserved only locally is. @@ -428,7 +428,7 @@ trait Outside { fn catch_up(&mut self) -> Result<()>; /// Run a session of `kind` given `prompt`. fn session(&mut self, kind: Kind, prompt: &str) -> Result<()>; - /// Run the Security review given `prompt`, with its Session log, its + /// Run the Security review given `prompt`, with its Session trace, its /// Resume's if it got one, if it started a session. fn security_review(&mut self, prompt: &str) -> (Result, Option); fn record_security_review(&mut self, review: &SecurityOutcome) -> Result<()>; @@ -1269,7 +1269,7 @@ mod tests { #[test] fn a_failure_after_a_repair_reports_the_most_recent_session_log() { // A failing Repair session, or a cause no session gave, as with - // Repairs exhausted, is reported with the most recent Session log, + // Repairs exhausted, is reported with the most recent Session trace, // whether or not a Security review ran before it. for reviews_security in [false, true] { let mut outside = Scripted { diff --git a/src/delivery/stage.rs b/src/delivery/stage.rs index 2e2d70adb..1de196228 100644 --- a/src/delivery/stage.rs +++ b/src/delivery/stage.rs @@ -28,7 +28,7 @@ pub(super) enum Stage { impl Stage { /// The stage a Failed run's recovery lines report, and the Command /// record stage, if the Delivery records one. Implementation and Spec - /// review have none: their Session log names its stage. + /// review have none: their Session trace names its stage. fn records(self) -> (recovery::Stage, Option) { match self { Self::Implementation => (recovery::Stage::Implementation, None), diff --git a/src/failed_run.rs b/src/failed_run.rs index 32955dcd4..7928ac113 100644 --- a/src/failed_run.rs +++ b/src/failed_run.rs @@ -14,7 +14,7 @@ pub struct FailedRun { pub error: anyhow::Error, /// The Issue branch's open PR, if it has one. pub pr_url: Option, - /// The Session log of the session the failure came from, else the most + /// The Session trace of the session the failure came from, else the most /// recent one, if a session was started. pub log: Option, /// Whether the Run was interrupted, as it was when the Run failed. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index cd0a2b5a9..bd3e0ea92 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -19,21 +19,6 @@ use crate::progress; pub struct Codex; pub const REGISTRATION: (Harness, &dyn Adapter) = (Harness::Codex, &Codex); -/// Replay a historical exec stream with local patch evidence restricted to -/// its Session interval, rather than the thread's most recent Resume. -pub(crate) fn historical( - interval: std::ops::RangeInclusive>, -) -> Interpretation { - let directory = Path::new(""); - Interpretation::new( - "codex", - Box::new(stream::CodexProgress::in_worktree(directory)), - ) - .with_retained(Box::new( - patches::Completion::new(directory).during(interval), - )) -} - const FRESH_SUB_AGENTS: &str = "Start fresh sub-agents with `fork_turns: \"none\"`, giving each only its own task and necessary evidence, so the skill's verifiers stay independent."; impl Adapter for Codex { diff --git a/src/harness/codex/patches.rs b/src/harness/codex/patches.rs index b12abe7e2..276cdf32d 100644 --- a/src/harness/codex/patches.rs +++ b/src/harness/codex/patches.rs @@ -6,11 +6,8 @@ //! whose changes already contain unified diffs, including adds and deletes. use std::collections::BTreeMap; use std::fs; -use std::ops::RangeInclusive; use std::path::{Path, PathBuf}; -use chrono::{DateTime, FixedOffset}; - use anyhow::Result; use serde_json::{Value, json}; @@ -20,7 +17,6 @@ use crate::harness::interpretation::{Facts, Retained}; pub(super) struct Completion { root: Option, worktree: PathBuf, - interval: Option>>, } impl Completion { @@ -32,15 +28,9 @@ impl Completion { std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex")) }), worktree: worktree.to_owned(), - interval: None, } } - pub(super) fn during(mut self, interval: RangeInclusive>) -> Self { - self.interval = Some(interval); - self - } - fn amendments(&self, facts: &Facts) -> Option> { let edits: Vec<_> = facts .report @@ -60,7 +50,7 @@ impl Completion { return None; } let path = find(&self.root.as_ref()?.join("sessions"), id, 3)?; - let native = read(&fs::read_to_string(path).ok()?, id, self.interval.as_ref())?; + let native = read(&fs::read_to_string(path).ok()?, id)?; let directory = if self.worktree.as_os_str().is_empty() { native.directory.as_deref().unwrap_or(&self.worktree) } else { @@ -161,11 +151,7 @@ struct Native { patches: Vec, } -fn read( - text: &str, - id: &str, - interval: Option<&RangeInclusive>>, -) -> Option { +fn read(text: &str, id: &str) -> Option { let mut records = text.lines().filter(|line| !line.trim().is_empty()); let meta: Value = serde_json::from_str(records.next()?).ok()?; if meta["type"] != "session_meta" || meta["payload"]["id"] != id { @@ -186,26 +172,7 @@ fn read( _ => {} } } - let patches = if let Some(interval) = interval { - // Historical files may contain later Resumes of the same thread. - // Recover only an unambiguous turn inside this Session's own interval. - let mut candidates = turns.into_iter().filter(|turn| { - !turn.is_empty() - && turn.iter().all(|patch| { - patch["timestamp"] - .as_str() - .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) - .is_some_and(|time| interval.contains(&time)) - }) - }); - let selected = candidates.next()?; - if candidates.next().is_some() { - return None; - } - selected - } else { - turns.pop()? - }; + let patches = turns.pop()?; Some(Native { directory: meta["payload"]["cwd"] .as_str() diff --git a/src/harness/interpretation.rs b/src/harness/interpretation.rs index 0e8851a3b..e7ce689b2 100644 --- a/src/harness/interpretation.rs +++ b/src/harness/interpretation.rs @@ -233,13 +233,6 @@ impl Interpretation { self } - /// Historical replay must not launch a Harness to export today's state. - /// Callers can still opt into a local retained reader (Codex patch files). - pub(crate) fn without_retained(mut self) -> Self { - self.retained = None; - self - } - /// Apply refusal and Model reporting rules independently of the session's log label. pub fn for_security(mut self, requested_model: Option<&str>) -> Self { self.security = Some(Security::new(requested_model)); diff --git a/src/harness/interpretation_tests.rs b/src/harness/interpretation_tests.rs index 7dcc29430..41ee12c40 100644 --- a/src/harness/interpretation_tests.rs +++ b/src/harness/interpretation_tests.rs @@ -352,11 +352,11 @@ fn a_transport_failure_preserves_recovered_usage_and_warnings_ahead_of_turn_fail .interpretation(Path::new("/work"), "/thirdshift-implement issue-url"); interpretation.condense(r#"{"stream":{"kind":"session","id":"s1"},"payload_type":"run.terminal.failed","payload":{"reason":"quota exceeded"}}"#); let completion = interpretation.finish(Err(anyhow::anyhow!( - "could not write the session log: Broken pipe" + "could not write the session trace: Broken pipe" ))); assert_eq!( completion.outcome.unwrap_err().to_string(), - "could not write the session log: Broken pipe" + "could not write the session trace: Broken pipe" ); let report = completion.report.unwrap(); assert_eq!( diff --git a/src/logs.rs b/src/logs.rs index 497faeaa5..41152a791 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -1,6 +1,6 @@ //! A command's record in its repository's logs: its stamp, its Command log //! and its lines in the Activity log, all under the root of the repository's -//! logs, `///`, with its Session logs. +//! logs, `///`, with its Session traces. //! //! A command says how it [`begin`]s, that it is [`configured`], what work it //! [`started`], what pass was [`skipped`], and how it [`ended`]; this module @@ -13,7 +13,6 @@ mod activity; mod command_record; -pub(crate) mod convert; mod effects; pub mod export; pub(crate) mod owner; @@ -22,6 +21,7 @@ mod reading; pub(crate) use reading::catalog; mod recording; pub(crate) mod session; +pub(crate) mod spans; pub mod status; pub mod stop; pub(crate) mod trace; @@ -42,7 +42,7 @@ pub(crate) use command_record::{TicketRecord, TicketState}; use effects::OnMachine; use recording::Record; -/// The command typed, which owns its Session logs and all child Runs' logs. +/// The command typed, which owns its Session traces and all child Runs' logs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandKind { Issue, @@ -210,7 +210,7 @@ pub fn stage(stage: Stage) { record().stage(stage, None); } -/// Keep the current Session log, including a Resume's own log, as +/// Keep the current Session trace, including a Resume's own log, as /// `session` enters its stage. pub fn session(session: Logged, path: &Path) { record().stage(session.stage(), Some(path)); @@ -252,7 +252,7 @@ pub fn show_held() { /// The root of `repo`'s logs: `///`, named for the /// GitHub repository, not the checkout, so every checkout of it logs to the -/// same place. Its Command logs, Session logs and Activity log are all under +/// same place. Its Command logs, Session traces and Activity log are all under /// it. /// /// # Panics @@ -262,12 +262,12 @@ pub fn root(repo: &Repo) -> PathBuf { record().root(repo) } -/// The command whose folder holds this process's Session logs. +/// The command whose folder holds this process's Session traces. pub fn command_kind() -> CommandKind { record().command_kind() } -/// Where this command and all its child Runs keep Session logs on `repo`. +/// Where this command and all its child Runs keep Session traces on `repo`. pub fn session_dir(repo: &Repo) -> PathBuf { let record = record(); record @@ -277,7 +277,7 @@ pub fn session_dir(repo: &Repo) -> PathBuf { } /// The command's start stamp, as in `20261003T120000-0400`, which names its -/// Command log and its Session logs. +/// Command log and its Session traces. pub fn stamp() -> String { record().stamp() } diff --git a/src/logs/convert.rs b/src/logs/convert.rs deleted file mode 100644 index 3d877bd11..000000000 --- a/src/logs/convert.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! One-time local conversion, with a durable round-trip report as the cleanup -//! receipt. Originals and existing traces are never overwritten by conversion. -mod readings; - -use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; -use std::path::{Component, Path, PathBuf}; - -use anyhow::{Context, Result, ensure}; -use chrono::Local; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use super::reading::{Command, Entry, Snapshot}; -use super::{owner, trace}; -use crate::config::UserConfig; -use crate::harness::{Choice, Harness}; -use crate::session::trace::Trace; - -const REPORT: &str = "session-trace-conversion.json"; - -#[derive(Default, Serialize, Deserialize)] -struct Report { - version: u32, - sessions: Vec, -} - -#[derive(Serialize, Deserialize)] -struct Session { - source: PathBuf, - passed: bool, - readers: BTreeMap, - codex_diffs: usize, - error: Option, - inputs: BTreeMap>, -} - -pub(crate) fn run(remove: bool) -> Result<()> { - let config = UserConfig::load()?; - fs::create_dir_all(&config.logs_dir)?; - let root = config.logs_dir.canonicalize()?; - let lock = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(root.join(".session-trace-conversion.lock"))?; - lock.lock()?; - let report_path = root.join(REPORT); - let mut report: Report = match fs::read(&report_path) { - Ok(bytes) => serde_json::from_slice(&bytes).context("invalid conversion report")?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound && !remove => Report { - version: 1, - ..Default::default() - }, - Err(error) => { - return Err(error).context("read the conversion report before removing originals"); - } - }; - ensure!(report.version == 1, "unsupported conversion report version"); - if remove { - let mut removed = 0; - for session in &report.sessions { - if !session.passed || !root.join(&session.source).exists() { - continue; - } - match unchanged(&root, &session.inputs) { - Ok(()) => { - fs::remove_file(confined(&root, &session.source)?)?; - removed += 1; - } - Err(error) => eprintln!("Kept {}: {error:#}", session.source.display()), - } - } - println!( - "Removed {removed} originals; report: {}", - report_path.display() - ); - return Ok(()); - } - let snapshot = Snapshot::load(&root, Local::now())?; - let mut sources = Vec::new(); - inventory(&root, &mut sources)?; - sources.sort(); - let mut converted = 0; - for source in sources { - let relative = source.strip_prefix(&root)?.to_owned(); - if report - .sessions - .iter() - .any(|session| session.source == relative && session.passed) - { - continue; - } - let mut session = Session { - source: relative.clone(), - passed: false, - readers: BTreeMap::new(), - codex_diffs: 0, - error: None, - inputs: BTreeMap::new(), - }; - let result = convert(&root, &source, &snapshot, &mut session); - match result { - Ok(()) => { - session.passed = true; - converted += 1; - } - Err(error) => session.error = Some(format!("{error:#}")), - } - report.sessions.retain(|old| old.source != relative); - report.sessions.push(session); - // A crash loses at most the current session's receipt, never an original. - save(&report_path, &report)?; - } - save(&report_path, &report)?; - println!( - "Converted {converted} Sessions; report: {}", - report_path.display() - ); - println!( - "{} passed; {} kept for inspection. Read the report, then run thirdshift logs --remove-converted.", - report.sessions.iter().filter(|s| s.passed).count(), - report.sessions.iter().filter(|s| !s.passed).count() - ); - Ok(()) -} - -fn inventory(folder: &Path, sources: &mut Vec) -> Result<()> { - for entry in fs::read_dir(folder)? { - let entry = entry?; - let kind = entry.file_type()?; - let path = entry.path(); - if kind.is_dir() { - inventory(&path, sources)?; - } else if kind.is_file() - && path.extension().is_some_and(|ext| ext == "jsonl") - && !path - .file_name() - .unwrap() - .as_encoded_bytes() - .ends_with(b".logs.jsonl") - && !path - .file_name() - .unwrap() - .as_encoded_bytes() - .ends_with(b".spans.jsonl") - && owner::decode(&path).is_some_and(|decoded| decoded.session_kind.is_some()) - { - sources.push(path); - } - } - Ok(()) -} - -fn command_for<'a>(commands: &'a [Entry], source: &Path) -> Option<(&'a Command, Vec)> { - fn find<'a>( - command: &'a Command, - source: &Path, - relays: Vec, - ) -> Option<(&'a Command, Vec)> { - for child in &command.children { - let mut nested = relays.clone(); - if let Some(issue) = &child.record.issue { - nested.push(issue.number); - } - if let Some(found) = find(child, source, nested) { - return Some(found); - } - } - for child in &command.dispatched { - if let Some(found) = find(child, source, relays.clone()) { - return Some(found); - } - } - if command - .record - .session_references() - .any(|path| path == source) - || owner::decode(source).is_some_and(|decoded| { - let subject = command - .record - .issue - .as_ref() - .map(|i| i.number.to_string()) - .unwrap_or_else(|| { - super::reading::pass_prefix(&command.record.kind) - .unwrap_or("") - .into() - }); - decoded.subject == subject && owner::session_matches(source, &command.command_log) - }) - { - Some((command, relays)) - } else { - None - } - } - commands.iter().find_map(|entry| match entry { - Entry::Record(command) => find(command, source, Vec::new()), - _ => None, - }) -} - -fn convert(root: &Path, source: &Path, snapshot: &Snapshot, session: &mut Session) -> Result<()> { - ensure!( - !source.with_extension("logs.jsonl").exists() - && !source.with_extension("spans.jsonl").exists(), - "Session already has a trace; left unchanged" - ); - let original = fs::read_to_string(source)?; - let decoded = owner::decode(source).context("invalid Session name")?; - let models_path = source.with_extension("models.json"); - let models: serde_json::Value = match fs::read(&models_path) { - Ok(bytes) => serde_json::from_slice(&bytes).context("invalid Model evidence")?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::Value::Null, - Err(error) => return Err(error.into()), - }; - let retained = command_for(snapshot.entries(), source); - ensure!( - retained - .as_ref() - .is_none_or(|(command, _)| command.state != "live"), - "Session belongs to a live Command" - ); - let harness = models["harness"] - .as_str() - .and_then(Harness::named) - .or_else(|| { - retained - .as_ref() - .and_then(|(c, _)| Harness::named(&c.record.harness)) - }) - .or_else(|| readings::harness(&original)) - .context("cannot identify Harness")?; - let fallback = readings::historical_command(source, harness, &models)?; - let has_record = retained.is_some(); - let (command, relays) = retained.unwrap_or((&fallback, Vec::new())); - let mut command_log = - super::status::waterfall::matching::Log::read(&command.command_log, decoded.time())?; - let event_end = original - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter_map(|event| crate::session::trace::event_time(&event)) - .max() - .and_then(|nanos| { - chrono::DateTime::from_timestamp( - (nanos / 1_000_000_000) as i64, - (nanos % 1_000_000_000) as u32, - ) - }) - .map(|time| time.fixed_offset()); - let ended = command - .record - .ended_at - .as_deref() - .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) - .or_else(|| command_log.last_time()) - .max(event_end) - .unwrap_or(decoded.time()); - for path in [ - source.to_owned(), - models_path, - command.command_log.clone(), - command.record_path.clone(), - ] { - // A missing sidecar/record is also a fact that cleanup must recheck. - let relative = path - .strip_prefix(root) - .context("Session dependency is outside the logs folder")? - .to_owned(); - session.inputs.insert(relative, fingerprint(&path)?); - } - let before = readings::read(source, source, command, &relays, harness, &models)?; - let temporary = tempfile::tempdir_in(source.parent().unwrap())?; - let output = temporary.path().join(source.file_name().unwrap()); - let choice = Choice { - harness, - model: models["requested"] - .as_str() - .map(str::to_owned) - .or(command.record.model.clone()), - effort: models["effort"] - .as_str() - .map(str::to_owned) - .or(command.record.effort.clone()), - ..Choice::default() - }; - let mut context = trace::Context::of_session(&command.command_log); - context.repository = command.record.repository.clone(); - if has_record { - // Recover the outer Command's identity, then this Run's stage ancestry. - context = trace::Context::of_session(&command.command_log).for_record(&command.record); - let kind = decoded.session_kind.unwrap(); - if let Some(stage) = command - .record - .stages - .iter() - .find(|stage| stage.session_log.as_deref() == Some(source) || stage.name == kind) - { - context.stage = context.stage_id(stage); - } - } - let mut trace = Trace::historical( - source, - &output, - decoded.session_kind.unwrap(), - &choice, - context, - readings::nanos(decoded.time()), - )?; - let timing = command - .record - .stage_times(None) - .find(|timing| timing.stage.session_log.as_deref() == Some(source)); - let began = timing - .as_ref() - .and_then(|t| t.began) - .unwrap_or(decoded.time()); - let until = timing - .and_then(|t| t.ended) - .unwrap_or(ended) - .max(event_end.unwrap_or(decoded.time())); - let mut stream = if harness == Harness::Codex { - crate::harness::codex::historical(began..=until) - } else { - harness - .adapter() - .interpretation(Path::new(""), "") - .without_retained() - }; - let mut cursor = 0; - let mut estimated = decoded.time(); - let mut seen = Vec::new(); - for raw in original.lines() { - let facts = stream.decode(raw); - let envelope: serde_json::Value = serde_json::from_str(raw).unwrap_or_default(); - for (index, label) in facts.progress.iter().enumerate() { - let file = envelope["item"]["changes"][index]["path"].as_str(); - if let Some(time) = command_log.estimate( - label, - file, - decoded.session_kind.unwrap(), - &relays, - (Some(decoded.time()), Some(ended)), - &mut cursor, - ) { - estimated = time; - } - } - trace.record_at(raw, &facts.events, readings::nanos(estimated))?; - seen.extend(facts.events); - } - let completion = stream.finish(Ok(std::os::unix::process::ExitStatusExt::from_raw(0))); - let mut amendments = String::new(); - if let Some(report) = completion.report { - for event in report.events.iter().filter(|event| { - !seen.contains(event) - && matches!(event, - crate::harness::interpretation::event::Event::ToolCallEnded { result, .. } - if result["source"] == "codex_session_file") - }) { - let raw = serde_json::to_string(event)?; - trace.record_at(&raw, std::slice::from_ref(event), readings::nanos(ended))?; - amendments.push_str(&format!("{raw}\n")); - session.codex_diffs += 1; - } - } - trace.finish_at(None, readings::nanos(ended))?; - drop(trace); - let after = readings::read( - &output.with_extension("logs.jsonl"), - source, - command, - &relays, - harness, - &models, - )?; - // Recovered Codex diffs are intentional additions. Compare against the - // original rendered with exactly those amendments, and disclose their count. - // `show` amendments produce no progress lines, but include them here so the - // expected latest event matches the trace's whole Session, as conversation does. - let mut expected = before; - if !amendments.is_empty() { - let amended = format!("{}\n{amendments}", original.trim_end()); - expected.conversation = - crate::console::conversation::render(&amended, super::catalog::Kind::Session); - let amended_events: Vec = super::session::events(&amended).collect(); - expected.show = super::reading::latest_session_event(&amended_events, harness); - } - session.readers = expected.compare(&after); - ensure!( - session.readers.values().all(|same| *same), - "round-trip reader difference" - ); - unchanged(root, &session.inputs)?; - for ext in ["logs.jsonl", "spans.jsonl"] { - let destination = source.with_extension(ext); - // link is an atomic no-clobber publication on the same filesystem. - fs::hard_link(output.with_extension(ext), &destination)?; - session.inputs.insert( - destination.strip_prefix(root)?.to_owned(), - fingerprint(&destination)?, - ); - } - Ok(()) -} - -fn fingerprint(path: &Path) -> Result> { - match fs::read(path) { - Ok(bytes) => Ok(Some(format!("{:x}", Sha256::digest(bytes)))), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error).with_context(|| format!("read {}", path.display())), - } -} - -fn confined(root: &Path, relative: &Path) -> Result { - ensure!( - relative - .components() - .all(|part| matches!(part, Component::Normal(_))), - "invalid report path" - ); - let path = root.join(relative); - let parent = path.parent().context("missing parent")?.canonicalize()?; - ensure!( - parent.starts_with(root), - "report path escaped the logs folder" - ); - ensure!( - !path - .symlink_metadata() - .is_ok_and(|m| m.file_type().is_symlink()), - "report path is a symlink" - ); - Ok(path) -} - -fn unchanged(root: &Path, inputs: &BTreeMap>) -> Result<()> { - ensure!(!inputs.is_empty(), "conversion has no receipt"); - for (relative, expected) in inputs { - let path = confined(root, relative)?; - ensure!( - fingerprint(&path)? == *expected, - "{} changed since conversion", - relative.display() - ); - } - Ok(()) -} - -fn save(path: &Path, report: &Report) -> Result<()> { - use std::io::Write; - let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; - temporary.write_all(&serde_json::to_vec_pretty(report)?)?; - temporary.persist(path)?; - Ok(()) -} diff --git a/src/logs/convert/readings.rs b/src/logs/convert/readings.rs deleted file mode 100644 index a70147fb7..000000000 --- a/src/logs/convert/readings.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Exercise the same reader functions as the Console, Waterfall and show. -use std::collections::BTreeMap; -use std::fs; -use std::path::Path; - -use anyhow::{Context, Result}; -use chrono::{DateTime, FixedOffset, Local}; -use serde_json::{Value, json}; - -use crate::harness::Harness; -use crate::logs::reading::Command; -use crate::logs::status::waterfall::{self, PlacedCall, matching}; -use crate::logs::{catalog, owner, session}; - -pub(super) struct Readings { - pub conversation: String, - waterfall: Vec, - pub(super) show: Option, - model_evidence: Value, -} - -impl Readings { - pub(super) fn compare(&self, other: &Self) -> BTreeMap { - BTreeMap::from([ - ( - "conversation".into(), - self.conversation == other.conversation, - ), - ("waterfall".into(), self.waterfall == other.waterfall), - ("show".into(), self.show == other.show), - ( - "model_evidence".into(), - self.model_evidence == other.model_evidence, - ), - ]) - } -} - -pub(super) fn read( - path: &Path, - identity: &Path, - command: &Command, - relays: &[u64], - harness: Harness, - models: &Value, -) -> Result { - let text = fs::read_to_string(path)?; - let start = DateTime::parse_from_rfc3339(&command.record.started_at)?; - let mut log = matching::Log::read(&command.command_log, start)?; - let mut stream = harness.adapter().interpretation(Path::new(""), ""); - for raw in session::events(&text) { - stream.condense(&raw); - } - let (calls, measured) = waterfall::session_calls(path, command, relays, &mut log)?; - // `show` in use reads only a live Command's latest event. The round-trip - // check measures what `show` would report for the Session, over the whole - // Session, without depending on the live 64 KiB tail window. The live tail - // behavior itself is unchanged. - let show_events: Vec = session::events(&text).collect(); - Ok(Readings { - conversation: crate::console::conversation::render(&text, catalog::Kind::Session), - waterfall: waterfall::place_calls( - identity, - command, - &calls, - measured, - Local::now().fixed_offset(), - ), - show: crate::logs::reading::latest_session_event(&show_events, harness), - model_evidence: json!({"sidecar":models,"stream_models":stream.observed_models()}), - }) -} - -pub(super) fn nanos(time: DateTime) -> u64 { - time.timestamp_nanos_opt() - .and_then(|n| n.try_into().ok()) - .unwrap_or_default() -} - -pub(super) fn historical_command( - source: &Path, - harness: Harness, - models: &Value, -) -> Result { - let decoded = owner::decode(source).context("invalid Session name")?; - let command_log = models["command_log"] - .as_str() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| decoded.command_log(source)); - let record = serde_json::from_value(json!({ - "version":1,"kind":"Historical Session","repository":"","name":decoded.subject, - "issue":null,"hostname":"","process_id":0,"process_start":"", - "harness":harness.name(),"model":null,"effort":null, - "started_at":decoded.time().to_rfc3339(),"ended_at":null,"stages":[], - "session_log":source,"outcome":null,"cause":null,"issue_branch":null,"pull_request":null - }))?; - let mut command = Command::new( - record, - command_log.with_extension("command.json"), - Local::now(), - ); - command.command_log = command_log; - Ok(command) -} - -/// Prefer durable Harness evidence. For very old logs lacking it, recognize -/// protocol envelopes rather than assuming that every Session ran on Claude. -pub(super) fn harness(text: &str) -> Option { - text.lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find_map(|event| { - if event["type"] == "thread.started" - || event["type"] - .as_str() - .is_some_and(|t| t.starts_with("item.")) - { - Some(Harness::Codex) - } else if event["type"] == "system" || event["type"] == "assistant" { - Some(Harness::Claude) - } else { - None - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn readings(show: Option<&str>) -> Readings { - Readings { - conversation: String::new(), - waterfall: Vec::new(), - show: show.map(str::to_owned), - model_evidence: Value::Null, - } - } - - #[test] - fn compare_still_catches_a_changed_latest_event() { - let readers = - readings(Some("Bash: cargo test")).compare(&readings(Some("Bash: cargo clippy"))); - assert!(!readers["show"]); - let readers = - readings(Some("Bash: cargo test")).compare(&readings(Some("Bash: cargo test"))); - assert!(readers["show"]); - } -} diff --git a/src/logs/export.rs b/src/logs/export.rs index 11af9edee..948cf3d2f 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -105,7 +105,7 @@ pub fn export(input: &str, directory: &Path) -> Result<()> { { ensure!( log.trace_id == Some(context.trace), - "Session log belongs to another Trace" + "Session trace belongs to another Trace" ); ancestry.references.extend(log.span_id); } @@ -171,19 +171,16 @@ fn record_spans( Observation::Record(command) .sessions()? .iter() - .map(|session| { - match session - .file_name() - .and_then(|name| name.to_str()) - .and_then(|name| name.strip_suffix(".logs.jsonl")) - { - Some(stem) => session.with_file_name(format!("{stem}.jsonl")), - None => session.clone(), - } - }), + .map(|session| super::session::original(session)), ); for child in command.dispatched.iter().chain(&command.children) { - record_spans(child, outer, context.run, spans, sessions)?; + record_spans( + child, + outer, + context.child_parent(&command.record, &child.record), + spans, + sessions, + )?; } Ok(()) } diff --git a/src/logs/owner.rs b/src/logs/owner.rs index 008e990d5..d6b8acdc0 100644 --- a/src/logs/owner.rs +++ b/src/logs/owner.rs @@ -46,7 +46,7 @@ impl Owner { } pub fn session_path(&self, dir: &Path, subject: &str, kind: &str) -> PathBuf { - dir.join(format!("{subject}-{}-{kind}.jsonl", self.identity())) + dir.join(format!("{subject}-{}-{kind}.logs.jsonl", self.identity())) } pub fn models_match(&self, path: &Path) -> bool { @@ -283,18 +283,12 @@ pub(super) fn decode(path: &Path) -> Option> { }) } -/// During dual recording the original Session log is the inventory entry. +/// Only the log-records file represents a Session trace in the inventory. pub(super) fn inventory_session(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return false; }; - if name.ends_with(".spans.jsonl") { - return false; - } - if let Some(stem) = name.strip_suffix(".logs.jsonl") { - return !path.with_file_name(format!("{stem}.jsonl")).exists(); - } - true + name.ends_with(".log") || name.ends_with(".logs.jsonl") } /// Preserve current legacy suffix stripping, including non-stamped fixture diff --git a/src/logs/reading.rs b/src/logs/reading.rs index e70f0efee..5dc78ee93 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -23,8 +23,6 @@ use super::{Pass, activity}; pub(super) use health::{Health, UsualRange}; pub(super) use history::Command as HistoricalCommand; pub(super) use inbox::{Group, InboxItem}; -pub(in crate::logs) use selection::latest_session_event; -pub(in crate::logs) use selection::pass_prefix; pub(super) use selection::{Detailed, Observation}; pub(super) struct Snapshot { diff --git a/src/logs/reading/catalog.rs b/src/logs/reading/catalog.rs index 3dc27604c..808ffbd42 100644 --- a/src/logs/reading/catalog.rs +++ b/src/logs/reading/catalog.rs @@ -16,7 +16,7 @@ use crate::logs::owner; pub(crate) enum Kind { #[serde(rename = "Command log")] Command, - #[serde(rename = "Session log")] + #[serde(rename = "Session trace")] Session, } @@ -24,7 +24,7 @@ impl std::fmt::Display for Kind { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { Self::Command => "Command log", - Self::Session => "Session log", + Self::Session => "Session trace", }) } } diff --git a/src/logs/reading/history.rs b/src/logs/reading/history.rs index d180a30b9..9af3929d0 100644 --- a/src/logs/reading/history.rs +++ b/src/logs/reading/history.rs @@ -201,8 +201,8 @@ pub(in crate::logs) fn read(repo: &Path, covered: &HashSet) -> Result Observation<'a> { Self::Record(command) => { let mut paths = Vec::new(); for path in command.record.session_references() { - let path = if path.exists() { - path.clone() - } else { - crate::logs::session::path(path) - }; + let path = crate::logs::session::path(path); if !paths.contains(&path) { paths.push(path); } @@ -191,7 +187,7 @@ impl<'a> Observation<'a> { let prefix = issue .map(|issue| issue.number.to_string()) .or_else(|| pass_prefix(kind).map(String::from)) - .context("cannot name Session logs for this Command kind")?; + .context("cannot name Session traces for this Command kind")?; for entry in super::entries(log.parent().context("Command log has no directory")?)? { let path = entry.path(); if entry.file_type()?.is_file() @@ -233,7 +229,7 @@ impl<'a> Observation<'a> { }; crate::session::kind::selects(kind, session) }) - .with_context(|| format!("no {kind} Session log for this Command")) + .with_context(|| format!("no {kind} Session trace for this Command")) } } diff --git a/src/logs/reading/tests.rs b/src/logs/reading/tests.rs index 3b243aebe..51a6df5c8 100644 --- a/src/logs/reading/tests.rs +++ b/src/logs/reading/tests.rs @@ -180,11 +180,11 @@ fn ticket_session_inventory_includes_stage_current_resume_and_numbered_repairs() &spec, ); let folder = path.parent().unwrap(); - let implement = folder.join("21-20261019T110000-0400~spec-implement.jsonl"); - let resume = folder.join("21-20261019T110000-0400~spec-implement-resume.jsonl"); - let repair = folder.join("21-20261019T110000-0400~spec-repair-2-resume.jsonl"); - let previous = folder.join("21-20261019T110000-0400~spec-repair-1.jsonl"); - let retained_reference = folder.join("retained-session.jsonl"); + let implement = folder.join("21-20261019T110000-0400~spec-implement.logs.jsonl"); + let resume = folder.join("21-20261019T110000-0400~spec-implement-resume.logs.jsonl"); + let repair = folder.join("21-20261019T110000-0400~spec-repair-2-resume.logs.jsonl"); + let previous = folder.join("21-20261019T110000-0400~spec-repair-1.logs.jsonl"); + let retained_reference = folder.join("retained-session.logs.jsonl"); for (index, session) in [&retained_reference, &implement, &resume, &previous, &repair] .into_iter() .enumerate() @@ -195,7 +195,7 @@ fn ticket_session_inventory_includes_stage_current_resume_and_numbered_repairs() .unwrap(); } fs::write( - folder.join("21-20261019T110000-0400~other-repair-9.jsonl"), + folder.join("21-20261019T110000-0400~other-repair-9.logs.jsonl"), "unrelated", ) .unwrap(); @@ -241,8 +241,8 @@ fn historical_selection_keeps_unknown_facts_and_has_no_process_or_stop_authority let dir = tempfile::tempdir().unwrap(); let folder = dir.path().join("acme/widgets/commands/issue"); fs::create_dir_all(&folder).unwrap(); - let session = folder.join("7-20261019T110000-0400~history-implement.jsonl"); - fs::write(&session, "not interpreted as a live event").unwrap(); + let session = folder.join("7-20261019T110000-0400~history-implement.logs.jsonl"); + fs::write(&session, serde_json::json!({"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"not interpreted as a live event"}}]}]}]}).to_string()).unwrap(); let snapshot = Snapshot::load(dir.path(), now()).unwrap(); assert!(snapshot.inbox(false, 7).is_empty()); assert_eq!(snapshot.inbox(true, 7).len(), 1); diff --git a/src/logs/session.rs b/src/logs/session.rs index b9b49d06a..235d584d4 100644 --- a/src/logs/session.rs +++ b/src/logs/session.rs @@ -3,19 +3,48 @@ use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use crate::otlp_jsonl::{AnyValue, LogRecord, Logs, Reader}; +use crate::otlp_jsonl::{AnyValue, LogRecord, Logs, Reader, Request}; -/// Command records may still name the old Session log during the transition. +/// A live append may expose only part of its last request. Completed malformed +/// lines remain errors; readers retry an incomplete final request on refresh. +pub(in crate::logs) fn read_requests(path: &Path) -> anyhow::Result>> { + use anyhow::Context; + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("can't read {}", path.display())), + }; + let mut reader = Reader::new(bytes.as_slice()).peekable(); + let mut requests = Vec::new(); + while let Some(result) = reader.next() { + match result { + Ok(request) => requests.push(request), + Err(error) + if !bytes.ends_with(b"\n") + && reader.peek().is_none() + && error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .is_some_and(serde_json::Error::is_eof) => + { + break; + } + Err(error) => { + return Err(error) + .with_context(|| format!("can't read Session trace {}", path.display())); + } + } + } + Ok(Some(requests)) +} + +/// Converted Command records can still name the former raw file. Resolve its +/// log-records file without ever falling back to the old Session-log format. pub(crate) fn path(log: &Path) -> PathBuf { if is_trace(log) { return log.to_owned(); } - let records = log.with_extension("logs.jsonl"); - if records.is_file() { - records - } else { - log.to_owned() - } + original(log).with_extension("logs.jsonl") } fn is_trace(path: &Path) -> bool { @@ -42,7 +71,6 @@ pub(crate) fn original(path: &Path) -> PathBuf { pub(crate) fn tail_events(log: &Path) -> io::Result> { const WINDOW: u64 = 64 * 1024; let path = path(log); - let trace = is_trace(&path); let mut file = File::open(path)?; let length = file.metadata()?.len(); let mut end = length; @@ -50,7 +78,7 @@ pub(crate) fn tail_events(log: &Path) -> io::Result> { let mut batches = Vec::new(); loop { let mut start = end.saturating_sub(WINDOW); - if trace { + { let mut cursor = start; while cursor > 0 { let previous = cursor.saturating_sub(WINDOW); @@ -69,20 +97,13 @@ pub(crate) fn tail_events(log: &Path) -> io::Result> { let mut bytes = Vec::new(); (&mut file).take(end - start).read_to_end(&mut bytes)?; let tail = String::from_utf8_lossy(&bytes); - // Old Session logs retain their bounded-tail behavior, including dropping - // a partial first line when the window begins in the middle of an event. - let tail = if !trace && start > 0 { - tail.split_once('\n').map_or("", |(_, tail)| tail) - } else { - &tail - }; - let recent: Vec<_> = events(tail).collect(); + let recent: Vec<_> = events(&tail).collect(); decoded_bytes += recent .iter() .map(|event| event.len() as u64 + 1) .sum::(); batches.push(recent); - if !trace || start == 0 || decoded_bytes >= WINDOW { + if start == 0 || decoded_bytes >= WINDOW { break; } end = start; @@ -91,31 +112,26 @@ pub(crate) fn tail_events(log: &Path) -> io::Result> { } /// A request can contain several resources, scopes and records. Keep their file -/// order, and preserve old logs and malformed diagnostics for the renderer. +/// order, preserving raw Harness events inside the OTLP bodies. pub(crate) fn events(lines: &str) -> impl Iterator + '_ { - records(lines).filter_map(|record| match record.body { + records(lines).filter_map(|record| raw_event(record.body)) +} + +/// Preserve byte bodies on disk, while displaying malformed Harness output as +/// text through the same reader used for ordinary string bodies. +pub(crate) fn raw_event(body: Option) -> Option { + match body { Some(AnyValue::String(raw)) => Some(raw), + Some(AnyValue::Bytes(raw)) => Some(String::from_utf8_lossy(&raw).into_owned()), _ => None, - }) + } } -/// Keep normalized attributes available to readers that understand them. Old -/// logs and malformed diagnostics become records containing only their raw body. +/// Keep normalized attributes available to readers that understand them. pub(crate) fn records(lines: &str) -> impl Iterator + '_ { - lines.lines().flat_map(|line| { - let is_trace = serde_json::from_str::(line) - .is_ok_and(|value| value.get("resourceLogs").is_some()); - if is_trace && let Some(Ok(request)) = Reader::<_, Logs>::new(line.as_bytes()).next() { - return request - .resource_logs - .into_iter() - .flat_map(|resource| resource.scope_logs) - .flat_map(|scope| scope.log_records) - .collect(); - } - vec![LogRecord { - body: Some(AnyValue::String(line.to_owned())), - ..Default::default() - }] - }) + Reader::<_, Logs>::new(lines.as_bytes()) + .filter_map(Result::ok) + .flat_map(|request| request.resource_logs) + .flat_map(|resource| resource.scope_logs) + .flat_map(|scope| scope.log_records) } diff --git a/src/logs/spans.rs b/src/logs/spans.rs new file mode 100644 index 000000000..40aeb12d3 --- /dev/null +++ b/src/logs/spans.rs @@ -0,0 +1,419 @@ +//! One retained span tree for the CLI and Console. Presentation never has to +//! recover record ancestry, match Harness events, or deduplicate Sessions. +mod session; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use anyhow::{Context as _, Result, bail}; +use chrono::{DateTime, Local}; +use serde::Serialize; +use serde_json::Value; + +use super::reading::{Command, Entry, Observation, Snapshot}; +use super::status::inspect; +use super::trace::{Context, SpanKind}; +use crate::config::UserConfig; + +#[derive(Clone, Serialize)] +pub(crate) struct Span { + pub id: String, + pub parent_id: Option, + pub kind: SpanKind, + pub name: String, + pub start: Option, + pub end: Option, + pub duration_ms: Option, + pub status: String, + pub error: Option, + pub estimated: bool, + pub attributes: BTreeMap, + pub input: Option, + pub output: Option, + pub command: String, + pub command_url: String, + pub conversation_url: Option, + pub run: Option, + pub session_kind: Option, +} + +#[derive(Serialize)] +pub(crate) struct Tree { + pub command: String, + pub spans: Vec, + pub now: f64, +} + +#[derive(Serialize)] +pub(crate) struct Choice { + pub subject: String, + pub state: String, + pub url: String, + pub selected: bool, +} + +pub(crate) struct View { + pub choices: Vec, + pub tree: Tree, +} + +pub(crate) fn show(input: &str, selected: Option<&str>, json: bool) -> Result<()> { + let view = load(&UserConfig::load()?, input)?; + if let Some(selected) = selected { + let span = view.tree.span(selected)?; + if json { + println!("{}", serde_json::to_string_pretty(span)?); + } else { + println!("{} {} · {}", span.kind, span.name, span.status); + println!("{}", serde_json::to_string_pretty(span)?); + } + } else if json { + println!("{}", serde_json::to_string_pretty(&view.tree)?); + } else { + for span in &view.tree.spans { + println!("{} {} {} · {}", span.id, span.kind, span.name, span.status); + } + } + Ok(()) +} + +impl Tree { + pub(crate) fn span(&self, selected: &str) -> Result<&Span> { + self.spans + .iter() + .find(|span| span.id == selected) + .with_context(|| format!("span {selected} is not in this Command")) + } + + /// A Session repeats its children's usage. Prefer a retained total at each + /// level, falling back to children only for measurements it does not carry. + fn roll_up(&mut self) { + let indices: BTreeMap<_, _> = self + .spans + .iter() + .enumerate() + .map(|(index, span)| (span.id.clone(), index)) + .collect(); + let mut children = vec![Vec::new(); self.spans.len()]; + for (index, span) in self.spans.iter().enumerate() { + if let Some(parent) = span.parent_id.as_ref().and_then(|id| indices.get(id)) { + children[*parent].push(index); + } + } + for field in [ + "gen_ai.usage.input_tokens", + "gen_ai.usage.output_tokens", + "thirdshift.usage.cache_read_tokens", + "thirdshift.usage.cache_write_tokens", + "thirdshift.cost.usd", + ] { + let mut totals = vec![None; self.spans.len()]; + let mut visited = BTreeSet::new(); + for index in 0..self.spans.len() { + metric( + index, + field, + &self.spans, + &children, + &mut totals, + &mut visited, + ); + } + for (span, total) in self.spans.iter_mut().zip(totals) { + if let Some(total) = total { + span.attributes.entry(field.into()).or_insert(total); + } + } + } + } +} + +fn metric( + index: usize, + field: &str, + spans: &[Span], + children: &[Vec], + totals: &mut [Option], + visited: &mut BTreeSet, +) -> Option { + if !visited.insert(index) { + return totals[index].clone(); + } + if let Some(value) = spans[index] + .attributes + .get(field) + .filter(|value| !value.is_null()) + { + totals[index] = Some(value.clone()); + return Some(value.clone()); + } + let values: Vec<_> = children[index] + .iter() + .filter_map(|child| metric(*child, field, spans, children, totals, visited)) + .collect(); + let total = if field == "thirdshift.cost.usd" { + values + .iter() + .filter_map(|value| { + value + .as_f64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .filter(|value| value.is_finite()) + .reduce(|a, b| a + b) + .map(Value::from) + } else { + values + .iter() + .filter_map(|value| { + value + .as_u64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .reduce(u64::saturating_add) + .map(Value::from) + }; + totals[index] = total.clone(); + total +} + +pub(crate) fn load(config: &UserConfig, input: &str) -> Result { + let now = Local::now(); + let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; + let mut commands: Vec<_> = snapshot + .entries() + .iter() + .filter_map(|entry| match entry { + Entry::Record(command) => Some(command.as_ref()), + _ => None, + }) + .collect(); + commands.sort_by_key(|command| { + ( + command.state != "live", + std::cmp::Reverse(time(&command.record.started_at)), + ) + }); + let (selected, owner) = if input.is_empty() { + (commands.first().copied(), commands.first().copied()) + } else { + let selection = snapshot.resolve(input)?; + match selection.observation { + Observation::Record(command) => (Some(command), selection.owner.or(Some(command))), + _ => bail!("span tree requires a Command record"), + } + }; + let selector = selected + .and_then(|command| Observation::Record(command).selector()) + .unwrap_or_default(); + let choices = commands + .iter() + .map(|command| { + let choice = Observation::Record(command).selector().unwrap_or_default(); + Choice { + subject: subject(command), + state: command + .record + .outcome + .clone() + .unwrap_or_else(|| command.state.into()), + url: inspect::url("/waterfall", &choice, None), + selected: choice == selector, + } + }) + .collect(); + let mut tree = Tree { + command: selector, + spans: Vec::new(), + now: now.timestamp_millis() as f64, + }; + if let (Some(command), Some(owner)) = (selected, owner) { + let context = Context::of_session(&owner.command_log); + let root = record_span( + command, + hex(context.command), + None, + SpanKind::Cmd, + &tree.command, + ); + tree.spans.push(root); + let mut builder = Builder { + context, + sessions: BTreeSet::new(), + spans: &mut tree.spans, + }; + builder.record(command, hex(builder.context.command))?; + } + tree.roll_up(); + Ok(View { choices, tree }) +} + +struct Builder<'a> { + context: Context, + sessions: BTreeSet, + spans: &'a mut Vec, +} + +impl Builder<'_> { + fn record(&mut self, command: &Command, parent: String) -> Result<()> { + let context = self.context.for_record(&command.record); + let selector = Observation::Record(command).selector().unwrap_or_default(); + let run_id = hex(context.run); + self.spans.push(record_span( + command, + run_id.clone(), + Some(parent), + SpanKind::Run, + &selector, + )); + let stages: Vec<_> = command.record.stage_times(None).collect(); + for (index, timing) in stages.iter().enumerate() { + let mut span = record_span( + command, + hex(context.stage_id(timing.stage)), + Some(run_id.clone()), + SpanKind::Stage, + &selector, + ); + span.name = timing.stage.name.clone(); + span.start = timing.began.map(|time| time.timestamp_millis() as f64); + span.end = timing.ended.map(|time| time.timestamp_millis() as f64); + span.status = if span.end.is_some() { + "ok" + } else if command.state == "live" { + "running" + } else { + "unknown" + } + .into(); + span.error = None; + if index + 1 == stages.len() && command.record.cause.is_some() { + span.error.clone_from(&command.record.cause); + span.status = "error".into(); + } + span.duration_ms = duration(span.start, span.end); + self.spans.push(span); + } + // Dispatchers can inventory their child's same-issue Sessions. Let + // descendants claim them first so each Session retains its actual Run. + for child in command.dispatched.iter().chain(&command.children) { + let parent = hex(context.child_parent(&command.record, &child.record)); + self.record(child, parent)?; + } + for path in Observation::Record(command).sessions()? { + let original = super::session::original(&path); + if !self.sessions.insert(original.clone()) { + continue; + } + let stage = stages.iter().find(|timing| { + timing + .stage + .session_log + .as_deref() + .is_some_and(|reference| super::session::original(reference) == original) + }); + let parent = stage.map_or_else( + || run_id.clone(), + |timing| hex(context.stage_id(timing.stage)), + ); + let mut template = record_span( + command, + String::new(), + Some(parent), + SpanKind::Sess, + &selector, + ); + template.start = stage + .and_then(|timing| timing.began) + .map(|time| time.timestamp_millis() as f64) + .or(template.start); + template.input = None; + template.conversation_url = Some(inspect::url("/log", &selector, Some(&path))); + if let Some(spans) = session::read(&path, &context, &template)? { + self.spans.extend(spans); + } + } + Ok(()) + } +} + +fn record_span( + command: &Command, + id: String, + parent_id: Option, + kind: SpanKind, + selector: &str, +) -> Span { + let start = time(&command.record.started_at).map(|time| time as f64); + let end = command + .record + .ended_at + .as_deref() + .and_then(time) + .map(|time| time as f64); + Span { + id, + parent_id, + kind, + name: format!( + "{} · {}{}", + command.record.kind, + subject(command), + command + .record + .issue + .as_ref() + .and_then(|issue| issue.title.as_ref()) + .map(|title| format!(" · {title}")) + .unwrap_or_default() + ), + start, + end, + duration_ms: duration(start, end), + status: state(command), + error: command.record.cause.clone(), + estimated: false, + attributes: BTreeMap::new(), + input: None, + output: None, + command: selector.into(), + command_url: inspect::url("/peek", selector, None), + conversation_url: None, + run: command.record.issue.as_ref().map(|issue| issue.number), + session_kind: None, + } +} + +fn subject(command: &Command) -> String { + command + .record + .issue + .as_ref() + .map(|issue| format!("{}#{}", command.record.repository, issue.number)) + .unwrap_or_else(|| command.record.name.clone()) +} + +fn state(command: &Command) -> String { + command.record.outcome.clone().unwrap_or_else(|| { + if command.state == "live" { + "running" + } else { + command.state + } + .into() + }) +} + +fn time(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|time| time.timestamp_millis()) +} +fn duration(start: Option, end: Option) -> Option { + start + .zip(end) + .and_then(|(start, end)| (end >= start).then_some(end - start)) +} +fn hex(id: crate::otlp_jsonl::SpanId) -> String { + id.0.iter().map(|byte| format!("{byte:02x}")).collect() +} diff --git a/src/logs/spans/session.rs b/src/logs/spans/session.rs new file mode 100644 index 000000000..4a3f65dfc --- /dev/null +++ b/src/logs/spans/session.rs @@ -0,0 +1,368 @@ +//! Merge ended OTLP spans with normalized start records for live Sessions. +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +use anyhow::{Context as _, Result}; +use serde_json::Value; + +use super::{Span, duration, hex}; +use crate::harness::interpretation::event::{Event, Outcome, UsageScope}; +use crate::logs::trace::{Context, SpanKind, id}; +use crate::otlp_jsonl::{AnyValue, KeyValue, Logs, Spans}; +use crate::session::trace::{Call, event_attributes}; + +pub(super) fn read(path: &Path, context: &Context, template: &Span) -> Result>> { + let original = crate::logs::session::original(path); + let requests = + crate::logs::session::read_requests::(&original.with_extension("spans.jsonl"))?; + let logs = crate::logs::session::read_requests::(&original.with_extension("logs.jsonl"))?; + if requests.is_none() && logs.is_none() { + return Ok(None); + } + let mut completed = Vec::new(); + let mut resource_attrs = BTreeMap::new(); + for request in requests.into_iter().flatten() { + for resource in request.resource_spans { + let attrs = resource + .resource + .map(|resource| attributes(&resource.attributes)) + .unwrap_or_default(); + resource_attrs.extend(attrs.clone()); + for raw in resource + .scope_spans + .into_iter() + .flat_map(|scope| scope.spans) + { + let mut attrs = attrs.clone(); + attrs.extend(attributes(&raw.attributes)); + let mut span = template.clone(); + span.id = hex(raw.span_id.unwrap_or_else(|| { + id(&[ + &context.run.0, + original.as_os_str().as_encoded_bytes(), + raw.name.as_bytes(), + &raw.start_time_unix_nano.to_be_bytes(), + ]) + })); + span.parent_id = raw.parent_span_id.map(hex); + span.kind = kind(&attrs); + span.name = raw.name; + span.start = millis(raw.start_time_unix_nano); + span.end = millis(raw.end_time_unix_nano); + span.duration_ms = raw + .end_time_unix_nano + .checked_sub(raw.start_time_unix_nano) + .filter(|_| raw.start_time_unix_nano != 0 && raw.end_time_unix_nano != 0) + .map(|nanos| nanos as f64 / 1_000_000.0); + span.error = raw + .status + .as_ref() + .filter(|status| status.code == 2) + .map(|status| status.message.clone()); + span.status = if span.error.is_some() { + "error" + } else if span.end.is_some() { + "ok" + } else if template.status == "running" { + "running" + } else { + "unknown" + } + .into(); + apply_attributes(&mut span, attrs); + completed.push(span); + } + } + } + let session_id = completed + .iter() + .find(|span| span.kind == SpanKind::Sess) + .map(|span| span.id.clone()) + .unwrap_or_else(|| { + hex(id(&[ + &context.trace.0, + b"session", + original.as_os_str().as_encoded_bytes(), + ])) + }); + let session_bytes: crate::otlp_jsonl::SpanId = + serde_json::from_value(Value::String(session_id.clone()))?; + let mut session = completed + .iter() + .find(|span| span.kind == SpanKind::Sess) + .cloned() + .unwrap_or_else(|| { + let mut session = template.clone(); + session.id = session_id.clone(); + session.name = "Session".into(); + session.error = None; + session + }); + // A shared Session belongs to the first stage naming it, even when an old + // trace points at the later pull-request stage that reused that Session. + session.parent_id.clone_from(&template.parent_id); + let mut calls = BTreeMap::new(); + let mut locations = HashMap::new(); + let identities: HashMap<_, _> = completed + .iter() + .filter_map(|span| { + let call = span + .attributes + .get("gen_ai.tool.call.id") + .or_else(|| span.attributes.get("thirdshift.id"))? + .as_str()?; + let parent = span + .attributes + .get("thirdshift.parent_id") + .and_then(Value::as_str); + Some(( + serde_json::to_string(&(&span.kind, parent, call)).unwrap(), + span.id.clone(), + )) + }) + .collect(); + let mut first_record = None::; + for request in logs.into_iter().flatten() { + for resource in request.resource_logs { + resource_attrs.extend( + resource + .resource + .map(|resource| attributes(&resource.attributes)) + .unwrap_or_default(), + ); + for record in resource + .scope_logs + .into_iter() + .flat_map(|scope| scope.log_records) + { + let attrs = attributes(&record.attributes); + for key in [ + "thirdshift.session.prompt", + "thirdshift.session.start_time_unix_nano", + ] { + if let Some(value) = attrs.get(key) { + session.attributes.insert(key.into(), value.clone()); + } + } + let start = millis(record.time_unix_nano) + .or_else(|| millis(record.observed_time_unix_nano)); + first_record = match (first_record, start) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + let Some(events) = attrs.get("thirdshift.events").and_then(Value::as_str) else { + continue; + }; + let events: Vec = + serde_json::from_str(events).context("invalid Session-trace events")?; + for event in events { + let Ok(event) = serde_json::from_value::(event) else { + continue; + }; + let excludes_cache = matches!( + resource_attrs + .get("thirdshift.harness") + .and_then(Value::as_str), + Some("claude" | "grok") + ); + let own_attrs = attributes(&event_attributes(&event, excludes_cache)); + let Some(fact) = Call::of(&event) else { + if matches!( + event, + Event::Usage { + scope: UsageScope::Session, + .. + } + ) { + session.attributes.extend(own_attrs); + } + continue; + }; + let (call_kind, call, parent, completion) = + (fact.kind, fact.id, fact.parent, fact.completion); + let key = fact.key(); + let call_id = if call_kind == SpanKind::Agent && completion.is_some() { + locations + .get(call) + .cloned() + .unwrap_or_else(|| hex(id(&[&session_bytes.0, key.as_bytes()]))) + } else { + identities + .get(&key) + .cloned() + .unwrap_or_else(|| hex(id(&[&session_bytes.0, key.as_bytes()]))) + }; + let parent_id = parent + .and_then(|parent| locations.get(parent).cloned()) + .unwrap_or_else(|| session_id.clone()); + locations.insert(call.to_owned(), call_id.clone()); + let span = calls.entry(call_id.clone()).or_insert_with(|| { + let mut span = template.clone(); + span.id = call_id; + span.parent_id = Some(parent_id); + span.kind = call_kind; + span.name = format!("{call_kind} {call}"); + span.start = start; + span.end = None; + span.duration_ms = None; + span.error = None; + span.status = if template.status == "running" { + "running" + } else { + "unknown" + } + .into(); + span.attributes.clear(); + span + }); + let mut call_attrs = resource_attrs.clone(); + call_attrs.extend(own_attrs); + if attrs.get("thirdshift.time.estimated") == Some(&Value::Bool(true)) { + call_attrs.insert("thirdshift.time.estimated".into(), Value::Bool(true)); + } + span.attributes.extend(call_attrs); + apply_attributes(span, span.attributes.clone()); + if let Some(outcome) = completion { + span.end = span.end.or(start); + span.duration_ms = duration(span.start, span.end); + if outcome != Outcome::Succeeded { + span.status = "error".into(); + span.error = Some(format!("{outcome:?}")); + } else { + span.status = "ok".into(); + } + } + } + } + } + } + if !completed.iter().any(|span| span.kind == SpanKind::Sess) { + session.start = session + .attributes + .get("thirdshift.session.start_time_unix_nano") + .and_then(Value::as_str) + .and_then(|value| value.parse().ok()) + .and_then(millis) + .or(first_record) + .or(session.start); + session.duration_ms = duration(session.start, session.end); + resource_attrs.extend(session.attributes.clone()); + apply_attributes(&mut session, resource_attrs); + } + for mut span in completed + .into_iter() + .filter(|span| span.kind != SpanKind::Sess) + { + if span.parent_id.is_none() { + span.parent_id = Some(session_id.clone()); + } + calls.insert(span.id.clone(), span); + } + let mut spans = vec![session]; + for mut span in calls.into_values() { + if span.session_kind.is_none() { + span.session_kind.clone_from(&spans[0].session_kind); + } + // Broken or missing causal references should not hide retained evidence. + if span + .parent_id + .as_ref() + .is_none_or(|parent| parent == &span.id) + { + span.parent_id = Some(session_id.clone()); + } + spans.push(span); + } + let ids: std::collections::HashSet<_> = spans.iter().map(|span| span.id.clone()).collect(); + for span in spans.iter_mut().skip(1) { + if span + .parent_id + .as_ref() + .is_none_or(|parent| !ids.contains(parent)) + { + span.parent_id = Some(session_id.clone()); + } + } + Ok(Some(spans)) +} + +fn kind(attrs: &BTreeMap) -> SpanKind { + match attrs.get("gen_ai.operation.name").and_then(Value::as_str) { + Some("invoke_agent") => SpanKind::Sess, + Some("execute_tool") => SpanKind::Tool, + Some("chat") => SpanKind::Model, + _ => match attrs + .get("thirdshift.type") + .and_then(Value::as_str) + .unwrap_or_default() + { + value if value.starts_with("subagent_") => SpanKind::Agent, + value if value.starts_with("turn_") => SpanKind::Turn, + _ => SpanKind::Model, + }, + } +} + +fn apply_attributes(span: &mut Span, attrs: BTreeMap) { + span.estimated = attrs + .get("thirdshift.time.estimated") + .is_some_and(|value| value == &Value::Bool(true) || value == "true"); + span.session_kind = text(&attrs, "thirdshift.session.kind").or(span.session_kind.clone()); + span.input = text(&attrs, "gen_ai.tool.call.arguments") + .or_else(|| text(&attrs, "thirdshift.session.prompt")) + .or_else(|| text(&attrs, "thirdshift.description")); + span.output = + text(&attrs, "gen_ai.tool.call.result").or_else(|| text(&attrs, "thirdshift.result")); + if span.kind == SpanKind::Tool + && let Some(tool) = text(&attrs, "gen_ai.tool.name") + { + span.name = format!( + "{tool}{}", + span.input + .as_ref() + .map(|input| format!(" {input}")) + .unwrap_or_default() + ); + } + span.attributes = attrs; +} + +fn text(attrs: &BTreeMap, key: &str) -> Option { + attrs + .get(key) + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) + }) +} + +fn millis(nanos: u64) -> Option { + (nanos != 0).then_some((nanos / 1_000_000) as f64 + (nanos % 1_000_000) as f64 / 1_000_000.0) +} + +fn attributes(attrs: &[KeyValue]) -> BTreeMap { + attrs + .iter() + .filter_map(|attr| { + attr.value + .as_ref() + .map(|value| (attr.key.clone(), value_json(value))) + }) + .collect() +} + +fn value_json(value: &AnyValue) -> Value { + match value { + AnyValue::Empty => Value::Null, + AnyValue::String(value) => Value::String(value.clone()), + AnyValue::Bool(value) => Value::Bool(*value), + AnyValue::Int(value) => Value::from(*value), + AnyValue::Double(value) => Value::from(*value), + AnyValue::Array(values) => Value::Array(values.iter().map(value_json).collect()), + AnyValue::Map(values) => Value::Object(attributes(values).into_iter().collect()), + AnyValue::Bytes(value) => Value::from(value.clone()), + } +} diff --git a/src/logs/status.rs b/src/logs/status.rs index 24b906720..af7530761 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -3,7 +3,6 @@ mod inbox; pub mod inspect; pub(crate) mod time; -pub(crate) mod waterfall; pub(crate) use inbox::{Archive, ArchivePage, archive}; pub(crate) use inbox::{Inbox, Row, inbox}; diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs index 48bbaf5d3..21d9255d7 100644 --- a/src/logs/status/inspect.rs +++ b/src/logs/status/inspect.rs @@ -50,7 +50,7 @@ fn print(command: Observation<'_>) -> Result<()> { } if matches!(command, Observation::History(_)) { for path in command.sessions()? { - println!("Session log: {}", path.display()); + println!("Session trace: {}", path.display()); } } Ok(()) @@ -78,7 +78,7 @@ fn print_details(command: &Command, indent: &str) -> Result<()> { println!("{indent} {} · {}{usual}", stage.name, stage.began_at); } for path in command.sessions()? { - println!("{indent}Session log: {}", path.display()); + println!("{indent}Session trace: {}", path.display()); } if let Some(event) = command.latest_event() { println!("{indent}{event}"); diff --git a/src/logs/status/waterfall.rs b/src/logs/status/waterfall.rs deleted file mode 100644 index 7b482830a..000000000 --- a/src/logs/status/waterfall.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! A Command tree on one time axis, derived from show facts and retained logs. -pub(in crate::logs) mod matching; -mod trace; - -use anyhow::Result; -use chrono::{DateTime, FixedOffset, Local}; - -use crate::config::UserConfig; -use crate::logs::reading::{Command, Entry, Observation, Snapshot}; - -type Time = DateTime; - -pub(crate) struct View { - pub choices: Vec, - pub runs: Vec, - pub start: String, - pub end: String, -} - -pub(crate) struct Choice { - pub subject: String, - pub state: String, - pub url: String, - pub selected: bool, -} - -pub(crate) struct Run { - pub subject: String, - pub kind: String, - pub title: String, - pub state: String, - pub selector: String, - pub peek_url: String, - pub log_url: String, - pub depth: usize, - pub span: Span, - pub stages: Vec, -} - -pub(crate) struct Stage { - pub name: String, - pub span: Span, - pub tools: Vec, -} - -pub(crate) struct Tool { - pub label: String, - pub span: Span, - pub url: String, -} - -#[derive(PartialEq)] -pub(in crate::logs) struct Call { - pub(in crate::logs) label: String, - pub(in crate::logs) start: Option
      ") - .unwrap() - .0 - .to_owned() - }; - let old = conversation(reader()); - fs::write( - path.with_extension("logs.jsonl"), - support::session_trace::records(fixture), - ) - .unwrap(); - fs::write(&path, "Old log must not be rendered\n").unwrap(); - assert_eq!(conversation(reader()), old, "{kind}"); - fs::remove_file(&path).unwrap(); - assert_eq!(conversation(reader()), old, "trace without old log: {kind}"); - } + let link = console.log_link("-implement.logs.jsonl"); + let html = console.get(&link).text().unwrap(); + assert!(html.contains("bad � <unsafe>"), "{html}"); + assert!(!html.contains("")); } #[test] @@ -1418,8 +1253,8 @@ fn child_runs_nest_and_each_request_reads_current_records() { write_record(home.path(), 8, &child); let child_log = home .path() - .join(".thirdshift/logs/acme/widgets/commands/issue/8-fixture-implement.jsonl"); - fs::write(&child_log, "Ticket Session output\n").unwrap(); + .join(".thirdshift/logs/acme/widgets/commands/issue/8-fixture-implement.logs.jsonl"); + support::session_trace::write(&child_log, "Ticket Session output\n"); child["session_log"] = json!(child_log); write_record(home.path(), 8, &child); let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); @@ -1428,12 +1263,12 @@ fn child_runs_nest_and_each_request_reads_current_records() { .split("href=\"") .skip(1) .filter_map(|part| part.split_once('"')) - .find(|(_, label)| label.starts_with(">Run and Session logs")) + .find(|(_, label)| label.starts_with(">Command logs and Session traces")) .unwrap() .0; let parent_logs = console.get(parent_link).text().unwrap(); assert!( - parent_logs.contains("8-fixture-implement.jsonl"), + parent_logs.contains("8-fixture-implement.logs.jsonl"), "{parent_logs}" ); let nested = html.split("class=\"children\"").nth(1).expect(&html); @@ -1525,8 +1360,8 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { live["process_start"] = json!(format!("{}:{ticks}", boot.trim())); let log = home .path() - .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture-implement.jsonl"); - fs::write(&log, "initial line\n").unwrap(); + .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture-implement.logs.jsonl"); + support::session_trace::write(&log, "initial line\n"); live["session_log"] = json!(log); write_record(home.path(), 7, &live); let inventory = console.get("/logs").text().unwrap(); diff --git a/tests/console/codex_patches.rs b/tests/console/codex_patches.rs index 0ea92d87f..e55a5c2d0 100644 --- a/tests/console/codex_patches.rs +++ b/tests/console/codex_patches.rs @@ -125,7 +125,7 @@ fn page(scenario: &support::Scenario) -> String { .as_array() .unwrap() .iter() - .find(|log| log["kind"] == "Session log") + .find(|log| log["kind"] == "Session trace") .unwrap()["id"] .as_str() .unwrap(); diff --git a/tests/console/spans.rs b/tests/console/spans.rs new file mode 100644 index 000000000..c19061529 --- /dev/null +++ b/tests/console/spans.rs @@ -0,0 +1,96 @@ +use super::*; + +fn cli(home: &Path, args: &[&str]) -> Value { + let output = Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(args) + .env("HOME", home) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn span_tree_and_details_pair_cli_with_routes_and_nest_child_runs() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let mut parent = record(home.path(), 7, Some("merged"), 0); + parent["started_at"] = json!("2026-10-01T12:00:00Z"); + parent["ended_at"] = json!("2026-10-01T12:01:00Z"); + parent["stages"] = json!([{ "name": "Tickets", "began_at": parent["started_at"] }]); + write_record(home.path(), 7, &parent); + let dir = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + let session = dir.join("99-20261001T120000+0000-implement.logs.jsonl"); + let mut child = parent.clone(); + child["kind"] = json!("Run"); + child["name"] = json!("ticket-99"); + child["issue"]["number"] = json!(99); + child["parent_record"] = json!(dir.join("7-fixture.command.json")); + child["session_log"] = json!(session); + child["stages"] = json!([ + {"name":"implement","began_at":"2026-10-01T12:00:10Z","session_log":session}, + {"name":"pull request","began_at":"2026-10-01T12:00:50Z","session_log":session} + ]); + fs::write( + dir.join("7-fixture.issue-99.child.json"), + serde_json::to_vec(&child).unwrap(), + ) + .unwrap(); + fs::write(support::session_trace::artifact(&session, "logs.jsonl"), "").unwrap(); + let attr = |key: &str, value: &str| json!({"key":key,"value":{"stringValue":value}}); + fs::write(support::session_trace::artifact(&session, "spans.jsonl"), serde_json::to_vec(&json!({ + "resourceSpans":[{"scopeSpans":[{"spans":[ + {"spanId":"1111111111111111","name":"invoke_agent implement","startTimeUnixNano":"1790856010000000000","endTimeUnixNano":"1790856050000000000","attributes":[attr("gen_ai.operation.name","invoke_agent"),attr("thirdshift.session.prompt","Build widgets")]}, + {"spanId":"2222222222222222","parentSpanId":"1111111111111111","name":"agent reviewer","startTimeUnixNano":"1790856015000000000","endTimeUnixNano":"1790856040000000000","attributes":[attr("thirdshift.type","subagent_started"),attr("thirdshift.description","Review widgets")]}, + {"spanId":"3333333333333333","parentSpanId":"2222222222222222","name":"execute_tool Bash","startTimeUnixNano":"1790856016250000000","endTimeUnixNano":"1790856019750000000","attributes":[attr("gen_ai.operation.name","execute_tool"),attr("gen_ai.tool.name","Bash"),attr("gen_ai.tool.call.arguments","{\"command\":\"cargo test \"}"),attr("gen_ai.tool.call.result","passed")],"status":{"code":2,"message":"test failed"}} + ]}]}] + })).unwrap()).unwrap(); + let tree = cli(home.path(), &["show", "widgets#7", "--spans", "--json"]); + let nodes = tree["spans"].as_array().unwrap(); + let of_kind = |kind| { + nodes + .iter() + .filter(|node| node["kind"] == kind) + .collect::>() + }; + assert_eq!(of_kind("cmd").len(), 1); + assert_eq!(of_kind("run").len(), 2); + assert_eq!(of_kind("sess").len(), 1, "a reused session appears once"); + let tickets = nodes.iter().find(|node| node["name"] == "Tickets").unwrap(); + let run = nodes + .iter() + .find(|node| node["name"].as_str().unwrap().contains("#99")) + .unwrap(); + assert_eq!(run["parent_id"], tickets["id"]); + let implement = nodes + .iter() + .find(|node| node["name"] == "implement") + .unwrap(); + assert_eq!(of_kind("sess")[0]["parent_id"], implement["id"]); + let tool = of_kind("tool")[0]; + assert_eq!(tool["parent_id"], "2222222222222222"); + assert_eq!(tool["duration_ms"], 3500.0); + assert_eq!(tool["error"], "test failed"); + let detail = cli( + home.path(), + &["show", "widgets#7", "--span", "3333333333333333", "--json"], + ); + assert_eq!(&detail, tool); + assert_eq!(detail["input"], "{\"command\":\"cargo test \"}"); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let route: Value = console.get("/spans?command=widgets%237").json().unwrap(); + assert_eq!(route["spans"], tree["spans"]); + let route: Value = console + .get("/spans?command=widgets%237&span=3333333333333333") + .json() + .unwrap(); + assert_eq!(route, detail); + let html = console + .get("/waterfall?command=widgets%237") + .text() + .unwrap(); + assert!(html.contains("id=\"span-tree\"")); + assert!(!html.contains("cargo test ")); +} diff --git a/tests/console/waterfall.rs b/tests/console/waterfall.rs index cde129724..6f589897a 100644 --- a/tests/console/waterfall.rs +++ b/tests/console/waterfall.rs @@ -16,57 +16,16 @@ fn start_console(home: &Path, value: &Value) -> Console { Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home) } -#[test] -fn converted_session_keeps_its_waterfall_stage_and_duration_after_cleanup() { - let home = tempfile::tempdir().unwrap(); - let dir = home - .path() - .join(".thirdshift/logs/acme/widgets/commands/issue"); - let session = dir.join("7-20261001T120000+0000-implement.jsonl"); - let mut value = retained_command(home.path(), &session); - value["harness"] = json!("codex"); - value["stages"] = json!([ - {"name":"implement","began_at":value["started_at"],"session_log":session}, - {"name":"review","began_at":"2026-10-01T12:00:05.500Z","session_log":null} - ]); - fs::write(&session, "{\"type\":\"item.completed\",\"item\":{\"id\":\"c1\",\"type\":\"command_execution\",\"command\":\"cargo test\",\"status\":\"completed\",\"exit_code\":0}}\n").unwrap(); - fs::write(dir.join("7-fixture.log"), "thirdshift: 12:00:00 starting\nthirdshift: 12:00:05 implement: $ cargo test\nthirdshift: 12:01:00 finished\n").unwrap(); - let console = start_console(home.path(), &value); - let before = console - .get("/waterfall?command=widgets%237") - .text() - .unwrap(); - assert!( - before.contains("data-start=\"2026-10-01T12:00:05+00:00\" data-seconds=\"0.5\""), - "{before}" - ); - for flag in ["--convert", "--remove-converted"] { - let output = Command::new(env!("CARGO_BIN_EXE_thirdshift")) - .args(["logs", flag]) - .env("HOME", home.path()) - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - } - assert!(!session.exists()); - let after = console - .get("/waterfall?command=widgets%237") - .text() - .unwrap(); - assert_eq!(after.replace(".logs.jsonl", ".jsonl"), before); -} - #[test] fn waterfall_reads_start_records_without_spans_and_ignores_an_unfinished_tail() { let home = tempfile::tempdir().unwrap(); let dir = home .path() .join(".thirdshift/logs/acme/widgets/commands/issue"); - let session = dir.join("7-20261001T120000+0000-implement.jsonl"); + let session = dir.join("7-20261001T120000+0000-implement.logs.jsonl"); let value = retained_command(home.path(), &session); - fs::write(&session, "{\"type\":\"item.completed\",\"item\":{\"id\":\"legacy\",\"type\":\"command_execution\",\"command\":\"wrong\",\"status\":\"completed\"}}\n").unwrap(); fs::write( - session.with_extension("logs.jsonl"), + &session, format!( "{}{{\"resourceLogs\":", include_str!("../fixtures/waterfall-logs.jsonl") @@ -74,21 +33,19 @@ fn waterfall_reads_start_records_without_spans_and_ignores_an_unfinished_tail() ) .unwrap(); let console = start_console(home.path(), &value); - let response = console.get("/waterfall?command=widgets%237"); + let response = console.get("/spans?command=widgets%237"); assert_eq!(response.status(), 200); - let html = response.text().unwrap(); + let tree: Value = response.json().unwrap(); + let tools = tool_spans(&tree); + assert_eq!(tools.len(), 4); assert_eq!( - html.matches("class=\"waterfall-tool\"").count(), - 4, - "{html}" - ); - assert_eq!( - html.matches("data-start=\"2026-10-01T12:00:15+00:00\" data-seconds=\"\"") + tools + .iter() + .filter(|span| span["start"] == 1790856015000.0 && span["duration_ms"].is_null()) .count(), - 2, - "{html}" + 2 ); - assert!(!html.contains("wrong"), "{html}"); + assert!(!tree.to_string().contains("wrong")); } #[test] @@ -111,28 +68,32 @@ fn waterfall_running_tools_use_start_records_and_keep_scopes_distinct() { { "name": "repair", "began_at": "2026-10-01T12:00:20.100+00:00", "session_log": session } ]); let console = start_console(home.path(), &value); - let html = console - .get("/waterfall?command=widgets%237") - .text() + let tree = span_tree(&console, "widgets%237"); + let tools = tool_spans(&tree); + assert_eq!(tools.len(), 4); + for start in [1790856020125.0, 1790856021250.0] { + let tool = tools.iter().find(|span| span["start"] == start).unwrap(); + assert!(tool["duration_ms"].is_null()); + } + let tool = tools + .iter() + .find(|span| span["start"] == 1790856015250.0) .unwrap(); - assert_eq!( - html.matches("class=\"waterfall-tool\"").count(), - 4, - "{html}" - ); - let repair = html.split("

      repair

      ").nth(1).unwrap(); - assert!( - repair.contains("data-start=\"2026-10-01T12:00:20.125+00:00\" data-seconds=\"\""), - "{html}" - ); - assert!( - repair.contains("data-start=\"2026-10-01T12:00:21.250+00:00\" data-seconds=\"\""), - "{html}" - ); - assert!( - html.contains("data-start=\"2026-10-01T12:00:15.250+00:00\" data-seconds=\"3.5\""), - "{html}" - ); + assert_eq!(tool["duration_ms"], 3500.0); + // The reused Session and all calls stay under the first naming stage. + let session = tree["spans"] + .as_array() + .unwrap() + .iter() + .find(|span| span["kind"] == "sess") + .unwrap(); + let stage = tree["spans"] + .as_array() + .unwrap() + .iter() + .find(|span| span["name"] == "implement") + .unwrap(); + assert_eq!(session["parent_id"], stage["id"]); } #[test] @@ -141,35 +102,33 @@ fn waterfall_uses_session_trace_spans_instead_of_command_log_guesses() { let dir = home .path() .join(".thirdshift/logs/acme/widgets/commands/issue"); - let session = dir.join("7-20261001T120000+0000-implement.jsonl"); + let session = dir.join("7-20261001T120000+0000-implement.logs.jsonl"); let mut value = retained_command(home.path(), &session); fs::write(&session, "").unwrap(); fs::write( - session.with_extension("spans.jsonl"), + session.with_file_name("7-20261001T120000+0000-implement.spans.jsonl"), include_str!("../fixtures/waterfall-spans.jsonl"), ) .unwrap(); fs::write(dir.join("7-fixture.log"), "thirdshift: 12:00:00 starting\nthirdshift: 12:00:15 implement: $ first \nthirdshift: 12:00:30 implement: $ second\nthirdshift: 12:01:00 finished\n").unwrap(); value["harness"] = json!("muse"); let console = start_console(home.path(), &value); - let html = console - .get("/waterfall?command=widgets%237") - .text() + let tree = span_tree(&console, "widgets%237"); + let tools = tool_spans(&tree); + assert_eq!(tools.len(), 2); + let first = tools + .iter() + .find(|span| span["id"] == "0000000000000001") .unwrap(); - assert_eq!( - html.matches("class=\"waterfall-tool\"").count(), - 2, - "{html}" - ); - assert!( - html.contains("data-start=\"2026-10-01T12:00:15.250+00:00\" data-seconds=\"3.5\""), - "{html}" - ); - assert!( - html.contains("data-start=\"2026-10-01T12:00:16.500+00:00\" data-seconds=\"0.625\""), - "{html}" - ); - assert!(html.contains("first <unsafe>"), "{html}"); + let second = tools + .iter() + .find(|span| span["id"] == "0000000000000002") + .unwrap(); + assert_eq!(first["start"], 1790856015250.0); + assert_eq!(first["duration_ms"], 3500.0); + assert_eq!(second["start"], 1790856016500.0); + assert_eq!(second["duration_ms"], 625.0); + assert!(first["name"].as_str().unwrap().contains("first ")); // A sub-millisecond span must retain its duration rather than round to zero. let mut spans: Value = @@ -177,16 +136,15 @@ fn waterfall_uses_session_trace_spans_instead_of_command_log_guesses() { spans["resourceSpans"][0]["scopeSpans"][0]["spans"][1]["endTimeUnixNano"] = json!("1790856016500500000"); fs::write( - session.with_extension("spans.jsonl"), + session.with_file_name("7-20261001T120000+0000-implement.spans.jsonl"), serde_json::to_vec(&spans).unwrap(), ) .unwrap(); - let html = console - .get("/waterfall?command=widgets%237") - .text() + let tree = span_tree(&console, "widgets%237"); + let tools = tool_spans(&tree); + let second = tools + .iter() + .find(|span| span["id"] == "0000000000000002") .unwrap(); - assert!( - html.contains("data-start=\"2026-10-01T12:00:16.500+00:00\" data-seconds=\"0.0005\""), - "{html}" - ); + assert_eq!(second["duration_ms"], 0.5); } diff --git a/tests/continuation.rs b/tests/continuation.rs index d5f569e97..f0d25cfcc 100644 --- a/tests/continuation.rs +++ b/tests/continuation.rs @@ -103,7 +103,7 @@ fn assert_nothing_created(scenario: &Scenario, local_branch: &str) { scenario .log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl") .is_empty(), - "a session log was created" + "a session trace was created" ); assert_ne!(scenario.launch_git(&["branch", "--list", local_branch]), ""); } diff --git a/tests/convert_logs.rs b/tests/convert_logs.rs deleted file mode 100644 index 53684acb4..000000000 --- a/tests/convert_logs.rs +++ /dev/null @@ -1,531 +0,0 @@ -mod support; - -use std::fs; -use std::path::{Path, PathBuf}; - -use serde_json::{Value, json}; -use support::Scenario; - -const STAMP: &str = "20261001T120000+0000"; - -fn fixture(scenario: &Scenario, harness: &str, raw: &str) -> PathBuf { - let folder = scenario.path(&format!( - "home/.thirdshift/logs/acme/{harness}/commands/secure" - )); - fs::create_dir_all(&folder).unwrap(); - let session = folder.join(format!("secure-{STAMP}-security-audit.jsonl")); - let command = folder.join(format!("{STAMP}.log")); - fs::write(&session, raw).unwrap(); - fs::write(&command, "thirdshift: 12:00:00 starting\nthirdshift: 12:00:05 security-audit: $ cargo test\nthirdshift: 12:01:00 finished\n").unwrap(); - fs::write( - session.with_extension("models.json"), - serde_json::to_vec(&json!({ - "command_log":command,"kind":"security-audit","harness":harness, - "requested":null,"effort":null,"observed":[] - })) - .unwrap(), - ) - .unwrap(); - session -} - -fn report(scenario: &Scenario) -> Value { - serde_json::from_slice( - &fs::read(scenario.path("home/.thirdshift/logs/session-trace-conversion.json")).unwrap(), - ) - .unwrap() -} - -#[test] -fn show_passes_a_codex_session_whose_recovered_diffs_overflow_the_tail_window() { - let scenario = Scenario::new(); - let session = fixture( - &scenario, - "codex", - include_str!("fixtures/console-codex.jsonl"), - ); - let native = scenario - .path("home/.codex/sessions/2026/10/01/rollout-2026-10-01T12-00-00-made-up-thread.jsonl"); - fs::create_dir_all(native.parent().unwrap()).unwrap(); - let directory = scenario.path("work/widgets"); - // One recovered diff larger than the 64 KiB live tail: a tail-window - // `show` check would see only the amendment, while the whole Session - // still ends in the original's latest event. - let diff = format!( - "--- a/widgets.rs\n+++ b/widgets.rs\n@@ -1 +1 @@\n-old\n+new-{}\n", - "x".repeat(70_000) - ); - let changes = json!({ - directory.join("widgets.rs").to_str().unwrap():{"type":"update","unified_diff":diff,"move_path":null} - }); - fs::write( - &native, - format!( - "{}\n{}\n", - json!({"type":"session_meta","payload":{"id":"made-up-thread","cwd":directory}}), - json!({"timestamp":"2026-10-01T12:00:40Z","type":"event_msg","payload":{"type":"patch_apply_end","success":true,"changes":changes}}) - ), - ) - .unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"][0]["passed"], true, "{report}"); - assert_eq!(report["sessions"][0]["readers"]["show"], true, "{report}"); - assert_eq!(report["sessions"][0]["codex_diffs"], 1, "{report}"); - assert!(session.exists()); -} - -#[test] -fn show_passes_a_claude_session_ending_in_a_large_tool_call() { - let scenario = Scenario::new(); - // The 64 KiB raw tail starts inside the tool call and drops that partial - // line, leaving only the progress-free result; the trace keeps whole - // records. The round-trip `show` check must compare the whole Session. - let tool = json!({"type": "assistant", "message": {"content": [{ - "type": "tool_use", "id": "call-1", "name": "Bash", - "input": {"command": format!("cargo test {}", "x".repeat(40_000))} - }]}}); - let final_result = - json!({"type": "result", "subtype": "success", "result": "y".repeat(30_000)}); - let raw = format!( - "{}\n{tool}\n{final_result}\n", - json!({"type": "system", "subtype": "init"}), - ); - let session = fixture(&scenario, "claude", &raw); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"][0]["passed"], true, "{report}"); - assert_eq!(report["sessions"][0]["readers"]["show"], true, "{report}"); - assert_eq!(fs::read_to_string(session).unwrap(), raw); -} - -fn records(path: &Path) -> Vec { - fs::read_to_string(path) - .unwrap() - .lines() - .flat_map(|line| { - let request: Value = serde_json::from_str(line).unwrap(); - request["resourceLogs"][0]["scopeLogs"][0]["logRecords"] - .as_array() - .unwrap() - .clone() - }) - .collect() -} - -#[test] -fn converts_all_six_harnesses_with_round_trip_report_and_no_duplicates() { - let scenario = Scenario::new(); - let fixtures = [ - ("claude", include_str!("fixtures/console-claude.jsonl")), - ("codex", include_str!("fixtures/console-codex.jsonl")), - ("agy", include_str!("fixtures/normalized-agy.jsonl")), - ("grok", include_str!("fixtures/normalized-grok.jsonl")), - ("muse", include_str!("fixtures/normalized-muse.jsonl")), - ( - "opencode", - include_str!("fixtures/normalized-opencode.jsonl"), - ), - ]; - let paths: Vec<_> = fixtures - .iter() - .map(|(harness, raw)| fixture(&scenario, harness, raw)) - .collect(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"].as_array().unwrap().len(), 6, "{report}"); - for entry in report["sessions"].as_array().unwrap() { - assert_eq!(entry["passed"], true, "{entry}"); - for reader in ["conversation", "waterfall", "show", "model_evidence"] { - assert_eq!(entry["readers"][reader], true, "{entry}"); - } - } - for ((_, raw), path) in fixtures.iter().zip(&paths) { - assert_eq!(fs::read_to_string(path).unwrap(), *raw); - let logs = records(&path.with_extension("logs.jsonl")); - let bodies: Vec<_> = logs - .iter() - .map(|r| r["body"]["stringValue"].as_str().unwrap()) - .collect(); - assert_eq!(bodies, raw.lines().collect::>()); - for (raw, record) in raw.lines().zip(&logs) { - let raw: Value = serde_json::from_str(raw).unwrap_or_default(); - let estimated = ["timestamp", "recorded_at", "time"] - .iter() - .all(|key| raw.get(key).is_none()); - assert!( - record["attributes"].as_array().unwrap().iter().any(|a| { - a["key"] == "thirdshift.time.estimated" && a["value"]["boolValue"] == estimated - }), - "{record}" - ); - } - assert!(path.with_extension("spans.jsonl").is_file()); - } - let before: Vec<_> = paths - .iter() - .map(|p| fs::read(p.with_extension("logs.jsonl")).unwrap()) - .collect(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(result.stdout.contains("Converted 0"), "{}", result.stdout); - for (path, before) in paths.iter().zip(before) { - assert_eq!(fs::read(path.with_extension("logs.jsonl")).unwrap(), before); - } -} - -#[test] -fn conversion_retries_a_kept_session_with_large_trailing_results() { - let scenario = Scenario::new(); - let earlier = json!({"type": "result", "subtype": "success", "result": "x".repeat(100_000)}); - let tool = json!({"type": "assistant", "message": {"content": [{ - "type": "tool_use", "id": "call-1", "name": "Bash", "input": {"command": "cargo test"} - }]}}); - let raw = format!( - "{earlier}\n{tool}\n{}", - support::session_trace::large_result_tail() - ); - let session = fixture(&scenario, "claude", &raw); - let root = scenario.path("home/.thirdshift/logs"); - // A failed conversion keeps its original and receipt, without publishing - // the temporary trace. A later run must retry it with the fixed readers. - fs::write( - root.join("session-trace-conversion.json"), - serde_json::to_vec(&json!({"version": 1, "sessions": [{ - "source": session.strip_prefix(&root).unwrap(), "passed": false, - "readers": {"show": false}, "codex_diffs": 0, - "error": "round-trip reader difference", "inputs": {} - }]})) - .unwrap(), - ) - .unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"].as_array().unwrap().len(), 1, "{report}"); - let entry = &report["sessions"][0]; - assert_eq!(entry["passed"], true, "{entry}"); - for reader in ["conversation", "waterfall", "show", "model_evidence"] { - assert_eq!(entry["readers"][reader], true, "{entry}"); - } - assert_eq!(fs::read_to_string(session).unwrap(), raw); -} - -#[test] -fn cleanup_removes_only_passed_unchanged_originals() { - let scenario = Scenario::new(); - let raw = include_str!("fixtures/console-claude.jsonl"); - let good = fixture(&scenario, "claude", raw); - let changed = fixture(&scenario, "codex", raw); - let damaged = fixture(&scenario, "grok", raw); - let failed = fixture(&scenario, "unknown", "unrecognized old output\n"); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - let sessions = report["sessions"].as_array().unwrap(); - assert_eq!( - sessions.iter().filter(|s| s["passed"] == true).count(), - 3, - "{report}" - ); - assert_eq!( - sessions.iter().filter(|s| s["passed"] == false).count(), - 1, - "{report}" - ); - fs::write(&changed, "new original\n").unwrap(); - fs::write(damaged.with_extension("spans.jsonl"), "broken trace\n").unwrap(); - let result = scenario.run(&["logs", "--remove-converted"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(result.stdout.contains("Removed 1"), "{}", result.stdout); - assert!(!good.exists()); - assert!(good.with_extension("logs.jsonl").is_file()); - assert!(good.with_extension("models.json").is_file()); - assert!(changed.is_file()); - assert!(damaged.is_file()); - assert!(failed.is_file()); - let result = scenario.run(&["logs", "--remove-converted"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(result.stdout.contains("Removed 0"), "{}", result.stdout); -} - -fn command_record(session: &Path, harness: &str) -> PathBuf { - let command = session.parent().unwrap().join(format!("{STAMP}.log")); - let record = command.with_extension("command.json"); - fs::write(&record, serde_json::to_vec(&json!({ - "version":1,"kind":"Security run","repository":format!("acme/{harness}"), - "name":format!("secure-{STAMP}"),"issue":null,"hostname":"factory", - "process_id":0,"process_start":"gone","harness":harness,"model":null,"effort":null, - "started_at":"2026-10-01T12:00:00Z","ended_at":"2026-10-01T12:01:00Z", - "stages":[{"name":"security-audit","began_at":"2026-10-01T12:00:00Z","session_log":session}], - "session_log":session,"outcome":"done","cause":null,"issue_branch":null,"pull_request":null - })).unwrap()).unwrap(); - command -} - -#[test] -fn show_and_logs_find_one_session_after_removing_a_recorded_original() { - let scenario = Scenario::new(); - let raw = "{\"type\":\"item.completed\",\"item\":{\"id\":\"test\",\"type\":\"command_execution\",\"command\":\"cargo test\",\"status\":\"completed\",\"exit_code\":0}}\n"; - let session = fixture(&scenario, "codex", raw); - let command = command_record(&session, "codex"); - let command = command.to_str().unwrap(); - for args in [["logs", "--convert"], ["logs", "--remove-converted"]] { - let result = scenario.run(&args); - assert_eq!(result.code, Some(0), "{}", result.stderr); - } - assert!(!session.exists(), "{}", report(&scenario)); - let result = scenario.run(&["show", command, "--json"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let shown: Value = serde_json::from_str(&result.stdout).unwrap(); - assert_eq!( - shown["command"]["session_logs"], - json!([session.with_extension("logs.jsonl")]) - ); - let result = scenario.run(&["logs", command, "--session", "security-audit"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(result.stdout.contains("resourceLogs"), "{}", result.stdout); - let logs = records(&session.with_extension("logs.jsonl")); - assert_eq!(logs[0]["timeUnixNano"], "1790856005000000000"); -} - -#[test] -fn recovers_codex_diffs_from_both_native_session_formats() { - for current_format in [false, true] { - let scenario = Scenario::new(); - let session = fixture( - &scenario, - "codex", - include_str!("fixtures/console-codex.jsonl"), - ); - let native = scenario.path( - "home/.codex/sessions/2026/10/01/rollout-2026-10-01T12-00-00-made-up-thread.jsonl", - ); - fs::create_dir_all(native.parent().unwrap()).unwrap(); - let directory = scenario.path("work/widgets"); - let diff = "--- a/widgets.rs\n+++ b/widgets.rs\n@@ -1 +1 @@\n-old\n+new\n"; - let changes = json!({ - directory.join("widgets.rs").to_str().unwrap():{"type":"update","unified_diff":diff,"move_path":null} - }); - let payload = if current_format { - json!({"type":"item_completed","item":{"type":"FileChange","id":"native-edit","status":"completed","changes":changes}}) - } else { - json!({"type":"patch_apply_end","success":true,"changes":changes}) - }; - fs::write( - &native, - format!( - "{}\n{}\n", - json!({"type":"session_meta","payload":{"id":"made-up-thread","cwd":directory}}), - json!({"timestamp":"2026-10-01T12:00:40Z","type":"event_msg","payload":payload}) - ), - ) - .unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"][0]["passed"], true, "{report}"); - assert_eq!(report["sessions"][0]["codex_diffs"], 1, "{report}"); - let logs = records(&session.with_extension("logs.jsonl")); - let amendment: Value = logs - .iter() - .filter_map(|record| { - serde_json::from_str::(record["body"]["stringValue"].as_str()?).ok() - }) - .find(|event| event["result"]["source"] == "codex_session_file") - .unwrap(); - assert_eq!(amendment["id"], "f1"); - assert_eq!(amendment["result"]["changes"][0]["diff"], diff); - assert!(session.exists()); - } -} - -#[test] -fn empty_sessions_convert_but_existing_trace_files_are_never_overwritten() { - let scenario = Scenario::new(); - let empty = fixture(&scenario, "claude", ""); - let existing = fixture( - &scenario, - "codex", - include_str!("fixtures/console-codex.jsonl"), - ); - fs::write(existing.with_extension("spans.jsonl"), "existing trace\n").unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - let entries = report["sessions"].as_array().unwrap(); - assert_eq!( - entries - .iter() - .find(|e| e["source"].as_str().unwrap().contains("/claude/")) - .unwrap()["passed"], - true, - "{report}" - ); - assert_eq!( - fs::read_to_string(existing.with_extension("spans.jsonl")).unwrap(), - "existing trace\n" - ); - let result = scenario.run(&["logs", "--remove-converted"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(!empty.exists()); - assert!(existing.exists()); -} - -#[test] -fn removed_tokenless_original_keeps_its_evidenced_command_owner() { - let scenario = Scenario::new(); - let session = fixture( - &scenario, - "claude", - include_str!("fixtures/console-claude.jsonl"), - ); - let legacy_command = session.parent().unwrap().join(format!("{STAMP}.log")); - let owned_command = legacy_command.with_file_name(format!("{STAMP}~owner.log")); - fs::rename(&legacy_command, &owned_command).unwrap(); - let models_path = session.with_extension("models.json"); - let mut models: Value = serde_json::from_slice(&fs::read(&models_path).unwrap()).unwrap(); - models["command_log"] = json!(owned_command); - fs::write(&models_path, serde_json::to_vec(&models).unwrap()).unwrap(); - for args in [["logs", "--convert"], ["logs", "--remove-converted"]] { - let result = scenario.run(&args); - assert_eq!(result.code, Some(0), "{}", result.stderr); - } - assert!(!session.exists(), "{}", report(&scenario)); - let result = scenario.run(&["status", "--all", "--json"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let status: Value = serde_json::from_str(&result.stdout).unwrap(); - let commands = status["commands"].as_array().unwrap(); - assert!( - commands - .iter() - .any(|c| c["command_log"] == json!(owned_command)), - "{status}" - ); - assert!( - !commands - .iter() - .any(|c| c["command_log"] == json!(legacy_command)), - "{status}" - ); -} - -#[test] -fn historical_codex_diff_belongs_to_the_original_turn_even_after_a_resume() { - let scenario = Scenario::new(); - let session = fixture( - &scenario, - "codex", - include_str!("fixtures/console-codex.jsonl"), - ); - let native = scenario - .path("home/.codex/sessions/2026/10/01/rollout-2026-10-01T12-00-00-made-up-thread.jsonl"); - fs::create_dir_all(native.parent().unwrap()).unwrap(); - let original = "--- a/widgets.rs\n+++ b/widgets.rs\n@@ -1 +1 @@\n-old\n+first-turn\n"; - let later = "--- a/widgets.rs\n+++ b/widgets.rs\n@@ -1 +1 @@\n-first-turn\n+later-resume\n"; - let mut rows = vec![ - json!({"type":"session_meta","payload":{"id":"made-up-thread","cwd":"/work/widgets"}}), - ]; - for (time, diff) in [ - ("2026-10-01T12:00:30Z", original), - ("2026-10-02T12:00:30Z", later), - ] { - rows.push(json!({"timestamp":time,"type":"event_msg","payload":{"type":"turn_started"}})); - rows.push(json!({"timestamp":time,"type":"event_msg","payload":{"type":"item_completed","item":{"type":"FileChange","id":"native-edit","status":"completed","changes":{ - "/work/widgets/widgets.rs":{"type":"update","unified_diff":diff,"move_path":null} - }}}})); - } - fs::write( - native, - rows.iter().map(|r| format!("{r}\n")).collect::(), - ) - .unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"][0]["passed"], true, "{report}"); - let logs = records(&session.with_extension("logs.jsonl")); - let diffs: Vec<_> = logs - .iter() - .filter_map(|r| serde_json::from_str::(r["body"]["stringValue"].as_str()?).ok()) - .filter(|e| e["result"]["source"] == "codex_session_file") - .map(|e| e["result"]["changes"][0]["diff"].clone()) - .collect(); - assert_eq!(diffs, vec![json!(original)]); -} - -#[test] -fn converts_the_pre_repository_session_layout_and_filename() { - let scenario = Scenario::new(); - let source = scenario - .path("home/.thirdshift/logs/sessions/widgets-4-20260801T120000-0400-implement.jsonl"); - fs::create_dir_all(source.parent().unwrap()).unwrap(); - fs::write(&source, "{\"type\":\"system\",\"subtype\":\"init\"}\n").unwrap(); - fs::write(source.with_extension("models.json"), serde_json::to_vec(&json!({"harness":"claude","kind":"implement","requested":null,"effort":null,"observed":[]})).unwrap()).unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!( - source.with_extension("logs.jsonl").exists(), - "{}", - report(&scenario) - ); - assert_eq!(report(&scenario)["sessions"][0]["passed"], true); - assert!(source.exists()); - let result = scenario.run(&["logs", "--remove-converted"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert!(!source.exists()); -} - -#[test] -fn historical_session_end_includes_its_timed_events_when_the_command_log_is_missing() { - let scenario = Scenario::new(); - let raw = concat!( - "{\"timestamp\":\"2026-10-01T12:00:10Z\",\"type\":\"item.started\",\"item\":{\"id\":\"c1\",\"type\":\"command_execution\",\"command\":\"cargo test\",\"status\":\"in_progress\"}}\n", - "{\"timestamp\":\"2026-10-01T12:00:20Z\",\"type\":\"item.completed\",\"item\":{\"id\":\"c1\",\"type\":\"command_execution\",\"command\":\"cargo test\",\"status\":\"completed\",\"exit_code\":0}}\n" - ); - let session = fixture(&scenario, "codex", raw); - fs::remove_file(session.parent().unwrap().join(format!("{STAMP}.log"))).unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - assert_eq!(report(&scenario)["sessions"][0]["passed"], true); - let spans: Vec = fs::read_to_string(session.with_extension("spans.jsonl")).unwrap().lines() - .flat_map(|line| serde_json::from_str::(line).unwrap()["resourceSpans"][0]["scopeSpans"][0]["spans"].as_array().unwrap().clone()).collect(); - let session_span = spans - .iter() - .find(|s| s["name"] == "invoke_agent security-audit") - .unwrap(); - assert_eq!(session_span["endTimeUnixNano"], "1790856020000000000"); -} - -#[test] -fn timestamped_codex_patches_are_recovered_without_command_evidence() { - let scenario = Scenario::new(); - let raw = concat!( - "{\"timestamp\":\"2026-10-01T12:00:00Z\",\"type\":\"thread.started\",\"thread_id\":\"made-up-thread\"}\n", - "{\"timestamp\":\"2026-10-01T12:00:40Z\",\"type\":\"item.completed\",\"item\":{\"id\":\"f1\",\"type\":\"file_change\",\"changes\":[{\"path\":\"widgets.rs\",\"kind\":\"update\"}],\"status\":\"completed\"}}\n" - ); - let session = fixture(&scenario, "codex", raw); - fs::remove_file(session.with_extension("models.json")).unwrap(); - fs::remove_file(session.parent().unwrap().join(format!("{STAMP}.log"))).unwrap(); - let native = scenario - .path("home/.codex/sessions/2026/10/01/rollout-2026-10-01T12-00-00-made-up-thread.jsonl"); - fs::create_dir_all(native.parent().unwrap()).unwrap(); - let diff = "--- a/widgets.rs\n+++ b/widgets.rs\n@@ -1 +1 @@\n-old\n+new\n"; - fs::write(native, format!("{}\n{}\n", json!({"type":"session_meta","payload":{"id":"made-up-thread","cwd":"/work/widgets"}}), - json!({"timestamp":"2026-10-01T12:00:40Z","type":"event_msg","payload":{"type":"patch_apply_end","success":true,"changes":{ - "/work/widgets/widgets.rs":{"type":"update","unified_diff":diff,"move_path":null} - }}}))).unwrap(); - let result = scenario.run(&["logs", "--convert"]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let report = report(&scenario); - assert_eq!(report["sessions"][0]["passed"], true, "{report}"); - assert_eq!(report["sessions"][0]["codex_diffs"], 1, "{report}"); - assert!( - fs::read_to_string(session.with_extension("logs.jsonl")) - .unwrap() - .contains("codex_session_file") - ); -} diff --git a/tests/export_trace.rs b/tests/export_trace.rs index e12a502de..88887abc7 100644 --- a/tests/export_trace.rs +++ b/tests/export_trace.rs @@ -133,10 +133,7 @@ fn export_decodes_with_official_types_and_keeps_record_times_and_all_session_log .as_array() .unwrap() .iter() - .map(|path| { - fs::read_to_string(Path::new(path.as_str().unwrap()).with_extension("logs.jsonl")) - .unwrap() - }) + .map(|path| fs::read_to_string(path.as_str().unwrap()).unwrap()) .collect::(); assert!(!logs.is_empty()); assert_eq!(logs, source); @@ -152,9 +149,22 @@ fn trace_only_sessions_export_once_even_when_the_record_names_the_old_log() { assert_eq!(run.code, Some(0), "{}", run.stderr); let shown = scenario.run(&["show", "widgets#7", "--json"]); let shown: Value = serde_json::from_str(&shown.stdout).unwrap(); - for session in shown["command"]["session_logs"].as_array().unwrap() { - fs::remove_file(session.as_str().unwrap()).unwrap(); + let record_path = + Path::new(shown["command"]["command_log"].as_str().unwrap()).with_extension("command.json"); + let mut record: Value = serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap(); + // Converted records retain the former raw filename, while only the trace + // files exist. Exercise that reference without deleting the records file. + record["session_log"] = record["session_log"] + .as_str() + .unwrap() + .replace(".logs.jsonl", ".jsonl") + .into(); + for stage in record["stages"].as_array_mut().unwrap() { + if let Some(path) = stage["session_log"].as_str() { + stage["session_log"] = path.replace(".logs.jsonl", ".jsonl").into(); + } } + fs::write(record_path, serde_json::to_vec(&record).unwrap()).unwrap(); let dir = scenario.path("export"); let result = scenario.run(&["logs", "widgets#7", "--export", dir.to_str().unwrap()]); assert_eq!(result.code, Some(0), "{}", result.stderr); @@ -216,4 +226,37 @@ fn a_pickup_commands_dispatched_spec_and_tickets_export_as_one_whole_trace() { source_lines.iter().map(String::as_str).collect() ); assert_logs_belong_to_trace(&logs, &spans); + let tree = scenario.run(&[ + "show", + outer["command_log"].as_str().unwrap(), + "--spans", + "--json", + ]); + assert_eq!(tree.code, Some(0), "{}", tree.stderr); + let tree: Value = serde_json::from_str(&tree.stdout).unwrap(); + for node in tree["spans"].as_array().unwrap() { + let exported = spans + .iter() + .find(|span| { + let id: String = span + .span_id + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + node["id"] == id + }) + .unwrap(); + let parent: String = exported + .parent_span_id + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + assert_eq!( + node["parent_id"].as_str().unwrap_or_default(), + parent, + "export ancestry differs from the CLI/Console tree for {} {}", + node["kind"], + node["name"] + ); + } } diff --git a/tests/failed_run.rs b/tests/failed_run.rs index c0ee00d05..13dd28683 100644 --- a/tests/failed_run.rs +++ b/tests/failed_run.rs @@ -48,7 +48,7 @@ fn a_failing_session_pushes_a_failure_commit_with_the_uncommitted_work_saying_wh } /// What every Failed run shares: a non-zero exit, `stdout` (empty unless a PR -/// exists), the session log path on stderr, and nothing left behind. +/// exists), the session trace path on stderr, and nothing left behind. fn assert_failed(scenario: &Scenario, result: &RunResult, stdout: &str) { assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); assert_eq!(result.stdout, stdout); diff --git a/tests/fixtures/console-claude.jsonl b/tests/fixtures/console-claude.jsonl index 5aeb3e1a6..3e97b4cac 100644 --- a/tests/fixtures/console-claude.jsonl +++ b/tests/fixtures/console-claude.jsonl @@ -1,9 +1,9 @@ -{"type":"system","subtype":"init","session_id":"made-up-session"} -{"type":"user","message":{"role":"user","content":"Make the widgets ."}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Consider the widget shape."},{"type":"text","text":"I will inspect the widgets."}]}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"read_1","name":"Read","input":{"file_path":"/project/widgets.rs"}}]}} -{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"old widget"}]}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"grep_1","name":"Grep","input":{"pattern":"widget","path":"/project"}},{"type":"tool_use","id":"edit_1","name":"Edit","input":{"file_path":"widgets.rs","old_string":"old widget","new_string":"new widget"}},{"type":"tool_use","id":"bash_1","name":"Bash","input":{"command":"cargo test widgets"}},{"type":"tool_use","id":"other_1","name":"NewTool","input":{"future":"visible"}}]}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Widgets are ready."},{"type":"future_block","detail":"unknown block stays visible"}]}} -{"type":"future_event","detail":"unknown event stays visible "} -not JSON: retained diagnostic +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"made-up-session\"}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"Make the widgets .\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"Consider the widget shape.\"},{\"type\":\"text\",\"text\":\"I will inspect the widgets.\"}]}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"read_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"/project/widgets.rs\"}}]}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"read_1\",\"content\":\"old widget\"}]}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"grep_1\",\"name\":\"Grep\",\"input\":{\"pattern\":\"widget\",\"path\":\"/project\"}},{\"type\":\"tool_use\",\"id\":\"edit_1\",\"name\":\"Edit\",\"input\":{\"file_path\":\"widgets.rs\",\"old_string\":\"old widget\",\"new_string\":\"new widget\"}},{\"type\":\"tool_use\",\"id\":\"bash_1\",\"name\":\"Bash\",\"input\":{\"command\":\"cargo test widgets\"}},{\"type\":\"tool_use\",\"id\":\"other_1\",\"name\":\"NewTool\",\"input\":{\"future\":\"visible\"}}]}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Widgets are ready.\"},{\"type\":\"future_block\",\"detail\":\"unknown block stays visible\"}]}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"future_event\",\"detail\":\"unknown event stays visible \"}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"not JSON: retained diagnostic"}}]}]}]} diff --git a/tests/fixtures/console-codex.jsonl b/tests/fixtures/console-codex.jsonl index f093e123b..8b66efdda 100644 --- a/tests/fixtures/console-codex.jsonl +++ b/tests/fixtures/console-codex.jsonl @@ -1,9 +1,9 @@ -{"type":"thread.started","thread_id":"made-up-thread"} -{"type":"item.completed","item":{"id":"r1","type":"reasoning","text":"Consider a safe edit."}} -{"type":"item.completed","item":{"id":"a1","type":"agent_message","text":"I will inspect the widgets."}} -{"type":"item.started","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"","status":"in_progress"}} -{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"widget output ","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"f1","type":"file_change","changes":[{"path":"widgets.rs","kind":"update"}],"status":"completed"}} -{"type":"item.completed","item":{"id":"m1","type":"mcp_tool_call","server":"files","tool":"Read","arguments":{"file_path":"widgets.rs"},"result":{"content":[{"type":"text","text":"new widget"}]},"status":"completed"}} -{"type":"item.completed","item":{"id":"z1","type":"future_item","detail":"unknown Codex item visible"}} -{"type":"item.completed","item":{"id":"a2","type":"agent_message","text":"Widgets are ready."}} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"thread.started\",\"thread_id\":\"made-up-thread\"}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"r1\",\"type\":\"reasoning\",\"text\":\"Consider a safe edit.\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"a1\",\"type\":\"agent_message\",\"text\":\"I will inspect the widgets.\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.started\",\"item\":{\"id\":\"c1\",\"type\":\"command_execution\",\"command\":\"cat widgets.rs\",\"aggregated_output\":\"\",\"status\":\"in_progress\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"c1\",\"type\":\"command_execution\",\"command\":\"cat widgets.rs\",\"aggregated_output\":\"widget output \",\"exit_code\":0,\"status\":\"completed\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"f1\",\"type\":\"file_change\",\"changes\":[{\"path\":\"widgets.rs\",\"kind\":\"update\"}],\"status\":\"completed\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"m1\",\"type\":\"mcp_tool_call\",\"server\":\"files\",\"tool\":\"Read\",\"arguments\":{\"file_path\":\"widgets.rs\"},\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"new widget\"}]},\"status\":\"completed\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"z1\",\"type\":\"future_item\",\"detail\":\"unknown Codex item visible\"}}"}}]}]}]} +{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"body":{"stringValue":"{\"type\":\"item.completed\",\"item\":{\"id\":\"a2\",\"type\":\"agent_message\",\"text\":\"Widgets are ready.\"}}"}}]}]}]} diff --git a/tests/fresh_run.rs b/tests/fresh_run.rs index 229f9921a..26e4630e0 100644 --- a/tests/fresh_run.rs +++ b/tests/fresh_run.rs @@ -234,7 +234,7 @@ fn gives_the_agent_the_fresh_prompt() { \n\ Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again.\n\ \n\ - You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on.\n" + You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it.\n" ); } @@ -389,15 +389,14 @@ fn logs_the_session_stream_under_the_home_directory() { assert_eq!(logs.len(), 1, "logs: {logs:?}"); let name = &logs[0]; assert!( - name.starts_with("7-") && name.ends_with("-implement.jsonl"), + name.starts_with("7-") && name.ends_with("-implement.logs.jsonl"), "log: {name}" ); - let log = std::fs::read_to_string( + let log = support::session_trace::events( scenario .path("home/.thirdshift/logs/acme/widgets/commands/issue") .join(name), - ) - .unwrap(); + ); assert!( log.contains(r#""type": "system""#) && log.contains(r#""type": "result""#), "log: {log}" diff --git a/tests/grok_sessions.rs b/tests/grok_sessions.rs index 909fd8f94..238ebf9f0 100644 --- a/tests/grok_sessions.rs +++ b/tests/grok_sessions.rs @@ -134,11 +134,10 @@ echo '{{"type":"assistant","message":{{"content":[{{"type":"tool_use","name":"ru } scenario.assert_every_grok_session_found_the_factory_skills(); let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); - let log = std::fs::read_to_string(scenario.path(&format!( + let log = support::session_trace::events(scenario.path(&format!( "home/.thirdshift/logs/acme/widgets/commands/issue/{}", logs[0] - ))) - .unwrap(); + ))); assert!(log.contains("fake-grok-1")); assert!(log.contains("\"type\": \"result\"")); assert!( @@ -174,11 +173,10 @@ fn groks_error_is_in_the_failure_cause_even_when_the_cli_exits_zero() { assert_eq!(result.code, Some(1), "{}", result.stderr); assert!(result.stderr.contains(cause), "{}", result.stderr); let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); - let log = std::fs::read_to_string(scenario.path(&format!( + let log = support::session_trace::events(scenario.path(&format!( "home/.thirdshift/logs/acme/widgets/commands/issue/{}", logs[0] - ))) - .unwrap(); + ))); let results = log .lines() .filter_map(|line| serde_json::from_str::(line).ok()) diff --git a/tests/merge_run.rs b/tests/merge_run.rs index bf3ce0ffb..af2735441 100644 --- a/tests/merge_run.rs +++ b/tests/merge_run.rs @@ -1061,7 +1061,7 @@ fn a_review_repair_whose_background_work_was_killed_gets_a_resume() { let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); assert!( logs.iter() - .any(|log| log.ends_with("-repair-1-resume.jsonl")), + .any(|log| log.ends_with("-repair-1-resume.logs.jsonl")), "logs: {logs:?}" ); } diff --git a/tests/mergeable.rs b/tests/mergeable.rs index 24d6812b6..e3dbbde13 100644 --- a/tests/mergeable.rs +++ b/tests/mergeable.rs @@ -121,7 +121,7 @@ fn hands_a_conflicting_merge_to_a_conflict_repair_and_pushes_its_resolution() { \n\ Before additional validation, each push, or any PR edit, check whether this branch's PR remains actionable, using the supplied PR URL when present. If the PR is merged or closed, stop and report its actual state; do not push, recreate its branch, or edit its body. During a Repair, if the PR has become draft, stop and report that gate; do not mark it ready again. If a Repair changes no files, create no commit, including no `--allow-empty` commit, and do not push merely to satisfy this prompt. Push when needed to publish real changes or required upstream reconciliation, after checking the PR again.\n\ \n\ - You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on.\n" + You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on. thirdshift records this session as a Session trace, with Model evidence and any review reports beside it.\n" ); assert_eq!(repair["merging"], true); assert_eq!(repair["branch"], "issue-7"); @@ -155,12 +155,12 @@ fn logs_the_conflict_repair_with_the_runs_shared_timestamp() { assert_eq!(logs.len(), 2, "logs: {logs:?}"); let implement = logs .iter() - .find(|name| name.ends_with("-implement.jsonl")) + .find(|name| name.ends_with("-implement.logs.jsonl")) .expect("no implement log"); - let prefix = implement.strip_suffix("implement.jsonl").unwrap(); + let prefix = implement.strip_suffix("implement.logs.jsonl").unwrap(); assert!(prefix.starts_with("7-"), "log: {implement}"); assert!( - logs.contains(&format!("{prefix}repair-1.jsonl")), + logs.contains(&format!("{prefix}repair-1.logs.jsonl")), "logs: {logs:?}" ); } diff --git a/tests/pickup_run.rs b/tests/pickup_run.rs index 2f228d3b4..ee0e1a349 100644 --- a/tests/pickup_run.rs +++ b/tests/pickup_run.rs @@ -1069,7 +1069,7 @@ fn a_dispatched_run_that_fails_fails_the_pickup_run_as_a_failed_run_does() { assert_eq!(result.stdout, ""); let last = *before_command_log(&result.stderr).last().unwrap(); assert!( - last.starts_with("thirdshift: session log: ") && last.ends_with("-implement.jsonl"), + last.starts_with("thirdshift: session trace: ") && last.ends_with("-implement.logs.jsonl"), "stderr: {}", result.stderr ); @@ -1490,7 +1490,7 @@ fn the_notification_of_a_run_left_ready_for_review_is_that_runs() { Title: Sharpen the widgets\n\ Command: Pickup run, which dispatched a Run\n\ Pull request: {}\n\ - Session log: {}\n\ + Session trace: {}\n\ Command log: {}\n\ Built with claude · default model · default effort (configured)\n\ Sent by: thirdshift\n\ @@ -1548,7 +1548,7 @@ fn the_notification_of_a_failed_run_has_its_pull_request_and_its_cause() { Command: Pickup run, which dispatched a Run\n\ Pull request: {}\n\ Cause: claude exited 3\n\ - Session log: ", + Session trace: ", pr_from(&scenario, "issue-7")["url"].as_str().unwrap() )), "{text}" diff --git a/tests/progress_lines.rs b/tests/progress_lines.rs index b9300a7f6..900a7aa4a 100644 --- a/tests/progress_lines.rs +++ b/tests/progress_lines.rs @@ -247,26 +247,52 @@ fn still_logs_the_whole_stream() { assert_eq!(result.code, Some(0), "{}", result.stderr); let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); - let log = std::fs::read( + let log = std::fs::read_to_string( scenario .path("home/.thirdshift/logs/acme/widgets/commands/issue") .join(&logs[0]), ) .unwrap(); - let raw = b"not json at all\n\xffunknown\0\n{\"type\":\n"; - assert!(log.windows(raw.len()).any(|bytes| bytes == raw), "{log:?}"); - let result_event = br#""type": "result""#; + let bodies: Vec = log + .lines() + .flat_map(|line| { + let request: serde_json::Value = serde_json::from_str(line).unwrap(); + request["resourceLogs"][0]["scopeLogs"][0]["logRecords"] + .as_array() + .unwrap() + .iter() + .map(|record| record["body"].clone()) + .collect::>() + }) + .collect(); + assert!( + bodies + .iter() + .any(|body| body["stringValue"] == "not json at all") + ); + // The original invalid UTF-8 and NUL survive exactly in an OTLP byte body. assert!( - log.windows(result_event.len()) - .any(|bytes| bytes == result_event), - "{log:?}" + bodies + .iter() + .any(|body| body["bytesValue"] == "/3Vua25vd24ACg=="), + "{bodies:?}" + ); + assert!( + bodies + .iter() + .any(|body| body["stringValue"] == "{\"type\":") ); + assert!(bodies.iter().any(|body| { + body["stringValue"] + .as_str() + .is_some_and(|raw| raw.contains(r#""type": "result""#)) + })); } #[test] fn a_log_failure_still_recovers_and_reports_warnings_and_usage() { let scenario = Scenario::new(); - let pipe = scenario.path("session-log-pipe"); + let pipe = scenario.path("session-trace-pipe"); assert!( Command::new("mkfifo") .arg(&pipe) @@ -288,7 +314,7 @@ for log in "{logs}/commands/issue/"*.log; do identity_fragment=${{identity_fragment%.log}} done mkdir -p "{logs}/commands/issue" -ln -s "{pipe}" "{logs}/commands/issue/7-$identity_fragment-implement.jsonl" +ln -s "{pipe}" "{logs}/commands/issue/7-$identity_fragment-implement.logs.jsonl" touch "{ready}" "#, logs = logs.display(), @@ -331,7 +357,7 @@ exec sleep 5 let mut line = String::new(); loop { line.clear(); - if reader.read_line(&mut line).unwrap() == 0 || line == "ready-to-fail\n" { + if reader.read_line(&mut line).unwrap() == 0 || line.contains("ready-to-fail") { break; } } diff --git a/tests/run_health.rs b/tests/run_health.rs index ea9008c7f..b3dd11fbb 100644 --- a/tests/run_health.rs +++ b/tests/run_health.rs @@ -67,11 +67,7 @@ impl LiveRun { command, ), ( - { - let log = PathBuf::from(self.record["session_log"].as_str().unwrap()); - let records = log.with_extension("logs.jsonl"); - if records.is_file() { records } else { log } - }, + PathBuf::from(self.record["session_log"].as_str().unwrap()), session, ), ] { @@ -171,14 +167,14 @@ fn silent_requires_both_logs_and_escalates_in_status_and_show() { } #[test] -fn silent_watches_session_trace_records_and_falls_back_to_old_session_logs() { +fn silent_watches_only_session_trace_records() { use std::io::Write; let mut run = LiveRun::new(); run.stage_age(60); run.log_ages(20, 20); let session = PathBuf::from(run.record["session_log"].as_str().unwrap()); - let records = session.with_extension("logs.jsonl"); + let records = session.clone(); let event = support::session_trace::records(r#"{"type":"assistant","message":{"content":"Working"}}"#); fs::write(&records, &event).unwrap(); @@ -190,7 +186,6 @@ fn silent_watches_session_trace_records_and_falls_back_to_old_session_logs() { .unwrap() .set_times(FileTimes::new().set_modified(SystemTime::now() - Duration::from_secs(20 * 60))) .unwrap(); - fs::write(&session, "Old log grew, but the records did not\n").unwrap(); assert_eq!(run.status()["health"]["state"], "silent"); File::options() .append(true) @@ -199,11 +194,8 @@ fn silent_watches_session_trace_records_and_falls_back_to_old_session_logs() { .write_all(event.as_bytes()) .unwrap(); assert_eq!(run.status()["health"]["state"], "ok"); - fs::remove_file(&session).unwrap(); - assert_eq!(run.status()["health"]["state"], "ok"); fs::remove_file(&records).unwrap(); - fs::write(&session, "Old Session log is active\n").unwrap(); - assert_eq!(run.status()["health"]["state"], "ok"); + assert_eq!(run.status()["health"]["state"], "silent"); } #[test] diff --git a/tests/run_notification.rs b/tests/run_notification.rs index dac9c6a6d..3de0ed650 100644 --- a/tests/run_notification.rs +++ b/tests/run_notification.rs @@ -57,7 +57,7 @@ fn text(request: &Request) -> &str { request.body["text"].as_str().unwrap() } -/// The path of the one session log the Run wrote. +/// The path of the one session trace the Run wrote. fn the_one_log(scenario: &Scenario) -> String { let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); assert_eq!(logs.len(), 1, "logs: {logs:?}"); diff --git a/tests/security_review.rs b/tests/security_review.rs index cafac8998..1f9f5a902 100644 --- a/tests/security_review.rs +++ b/tests/security_review.rs @@ -111,7 +111,7 @@ fi let state = scenario.gh_state(); let body = state["prs"][0]["body"].as_str().unwrap(); assert!( - body.contains("- Security review on grok failed; see Session log\n"), + body.contains("- Security review on grok failed; see Session trace\n"), "{body}" ); assert!(!body.contains("Not signed in."), "{body}"); @@ -120,7 +120,7 @@ fi let logs = scenario.log_files("home/.thirdshift/logs/acme/widgets/commands/issue", "jsonl"); let review_log = logs .iter() - .find(|name| name.ends_with("-security-review.jsonl")) + .find(|name| name.ends_with("-security-review.logs.jsonl")) .unwrap(); let transcript = std::fs::read_to_string(scenario.path(&format!( "home/.thirdshift/logs/acme/widgets/commands/issue/{review_log}" @@ -284,10 +284,9 @@ fi assert_eq!(result.stderr.matches("warning: Security review").count(), 1); assert_eq!(scenario.codex_calls().len(), 2); assert!( - result - .stderr - .lines() - .any(|line| line.contains("session log:") && line.contains("security-review-fallback")), + result.stderr.lines().any( + |line| line.contains("session trace:") && line.contains("security-review-fallback") + ), "{}", result.stderr ); @@ -306,7 +305,7 @@ fn security_review_failures_without_a_diagnostic_keep_the_generic_cause() { scenario.agent_does_in_session(2, script); let result = scenario.run(&[&scenario.issue_url(7), "security-review", "merge"]); assert_eq!(result.code, Some(1), "{}", result.stderr); - let cause = "Security review incomplete: session failed, ended early or omitted a valid final line; see Session log"; + let cause = "Security review incomplete: session failed, ended early or omitted a valid final line; see Session trace"; assert!( result.stderr.contains(&format!("thirdshift: {cause}\n")), "{}", @@ -414,7 +413,7 @@ fn explicit_incomplete_reviews_still_validate_the_entire_private_report() { let state = scenario.gh_state(); assert!(state["advisories"].is_null()); let body = state["prs"][0]["body"].as_str().unwrap(); - let generic = "Security review incomplete: session failed, ended early or omitted a valid final line; see Session log"; + let generic = "Security review incomplete: session failed, ended early or omitted a valid final line; see Session trace"; assert!(body.contains(generic), "{body}"); assert!(result.stderr.contains(generic), "{}", result.stderr); assert_old_finding_stays_private(body, &result); @@ -709,7 +708,7 @@ fn invalid_or_missing_final_lines_hold_self_merge_but_report_only_runs_continue( result.stderr ); assert!( - result.stderr.contains("Security review incomplete: session failed, ended early or omitted a valid final line; see Session log"), + result.stderr.contains("Security review incomplete: session failed, ended early or omitted a valid final line; see Session trace"), "{}", result.stderr ); @@ -720,7 +719,7 @@ fn invalid_or_missing_final_lines_hold_self_merge_but_report_only_runs_continue( pr["body"] .as_str() .unwrap() - .contains("Security review incomplete: session failed, ended early or omitted a valid final line; see Session log") + .contains("Security review incomplete: session failed, ended early or omitted a valid final line; see Session trace") ); } } @@ -952,17 +951,17 @@ gh fake checks "$(git rev-parse HEAD)" '[{"name":"test","conclusion":"success"}] ); // The Repair ran after the review, so it is the most recent session. assert!( - result.stderr.contains("-repair-1.jsonl"), + result.stderr.contains("-repair-1.logs.jsonl"), "{}", result.stderr ); let reported = result .stderr .lines() - .find_map(|line| line.split_once("session log: ").map(|(_, log)| log)) - .unwrap_or_else(|| panic!("no session log line: {}", result.stderr)); + .find_map(|line| line.split_once("session trace: ").map(|(_, log)| log)) + .unwrap_or_else(|| panic!("no session trace line: {}", result.stderr)); assert!( - reported.ends_with("-security-review.jsonl"), + reported.ends_with("-security-review.logs.jsonl"), "{}", result.stderr ); @@ -970,7 +969,7 @@ gh fake checks "$(git rev-parse HEAD)" '[{"name":"test","conclusion":"success"}] assert_eq!(requests.len(), 1, "{requests:?}"); let text = requests[0].body["text"].as_str().unwrap(); assert!( - text.contains(&format!("Session log: {reported}\n")), + text.contains(&format!("Session trace: {reported}\n")), "{text}" ); } @@ -1008,14 +1007,18 @@ gh fake checks "$(git rev-parse origin/main)" '[{"name":"test","conclusion":"suc let reported = result .stderr .lines() - .find_map(|line| line.split_once("session log: ").map(|(_, log)| log)) - .unwrap_or_else(|| panic!("no session log line: {}", result.stderr)); - assert!(reported.ends_with("-repair-1.jsonl"), "{}", result.stderr); + .find_map(|line| line.split_once("session trace: ").map(|(_, log)| log)) + .unwrap_or_else(|| panic!("no session trace line: {}", result.stderr)); + assert!( + reported.ends_with("-repair-1.logs.jsonl"), + "{}", + result.stderr + ); let requests = resend.requests(); assert_eq!(requests.len(), 1, "{requests:?}"); let text = requests[0].body["text"].as_str().unwrap(); assert!( - text.contains(&format!("Session log: {reported}\n")), + text.contains(&format!("Session trace: {reported}\n")), "{text}" ); } diff --git a/tests/security_run.rs b/tests/security_run.rs index 8a2fe5eb9..c0d16e6e2 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -202,12 +202,12 @@ printf '%s\n' '{{"type":"assistant","message":{{"model":"claude-opus-4-8","conte assert!( session_logs .iter() - .any(|name| name.ends_with("-security-audit.jsonl")) + .any(|name| name.ends_with("-security-audit.logs.jsonl")) ); assert!( session_logs .iter() - .any(|name| name.ends_with("-security-reproduction-1.jsonl")) + .any(|name| name.ends_with("-security-reproduction-1.logs.jsonl")) ); assert!( !scenario @@ -291,8 +291,8 @@ fn refused_security_reproductions_keep_the_record_and_stop_before_the_next_findi "Result: reproduction failed", "Audit: audit complete", "Reproduction: 1 refused", - "Session log:", - "security-reproduction-1.jsonl", + "Session trace:", + "security-reproduction-1.logs.jsonl", "Command log:", ] { assert!(text.contains(line), "missing {line}: {text}"); @@ -1939,7 +1939,7 @@ fn incomplete_reproductions_preserve_the_record_and_stop_before_the_next_session assert_eq!(result.code, Some(1), "{}", result.stderr); assert!(result.stderr.contains(cause), "{}", result.stderr); assert!( - result.stderr.contains("security-reproduction-1.jsonl"), + result.stderr.contains("security-reproduction-1.logs.jsonl"), "{}", result.stderr ); @@ -2834,7 +2834,7 @@ fn missing_final_line_or_invalid_reports_fail_without_recording() { "expected {cause}: {}", result.stderr ); - assert!(result.stderr.contains("session log:")); + assert!(result.stderr.contains("session trace:")); assert!(scenario.gh_state()["advisories"].is_null()); assert_eq!(scenario.entries("work"), vec![REPO]); let log = @@ -3528,7 +3528,7 @@ fn pre_audit_github_failures_report_a_safe_cause_without_claiming_an_audit_ran() ); } for absent in [ - "Session log:", + "Session trace:", "Command log:", "Reproduction:", "Session models:", @@ -3811,8 +3811,8 @@ fn a_reproduction_failure_notifies_recorded_metadata_without_private_evidence() "Result: reproduction failed", "Audit: audit complete", "Reproduction: 2 failed", - "Session log:", - "security-reproduction-2.jsonl", + "Session trace:", + "security-reproduction-2.logs.jsonl", "Command log:", ] { assert!(text.contains(line), "missing {line}: {text}"); diff --git a/tests/session_traces.rs b/tests/session_traces.rs index 7b76f3bec..2ed0e3c21 100644 --- a/tests/session_traces.rs +++ b/tests/session_traces.rs @@ -179,8 +179,17 @@ fn all_six_harnesses_leave_one_inventory_entry_and_both_otlp_files() { assert_eq!(request.resource_logs[0].scope_logs[0].log_records.len(), 1); } let stem = logs[0].strip_suffix(".logs.jsonl").unwrap(); - assert!(folder.join(format!("{stem}.jsonl")).exists()); + assert!(!folder.join(format!("{stem}.jsonl")).exists()); assert!(folder.join(format!("{stem}.models.json")).exists()); + let record = scenario.command_record(LOGS); + assert_eq!(record["session_log"], serde_json::json!(path)); + assert!( + record["stages"] + .as_array() + .unwrap() + .iter() + .any(|stage| { stage["session_log"] == serde_json::json!(path) }) + ); assert!( spans(&folder.join(format!("{stem}.spans.jsonl"))) .iter() @@ -251,6 +260,29 @@ while ! test -f "$HOME/release"; do sleep 0.01; done assert!(std::time::Instant::now() < deadline, "live log never grew"); std::thread::sleep(std::time::Duration::from_millis(10)); }; + let shown = scenario.run(&["show", "widgets#7", "--spans", "--json"]); + assert_eq!(shown.code, Some(0), "{}", shown.stderr); + let tree: Value = serde_json::from_str(&shown.stdout).unwrap(); + let session = tree["spans"] + .as_array() + .unwrap() + .iter() + .find(|span| span["kind"] == "sess") + .unwrap(); + assert!( + session["input"] + .as_str() + .is_some_and(|prompt| prompt.contains("Closes #7")), + "live Session must retain its prompt: {session}" + ); + let model = tree["spans"] + .as_array() + .unwrap() + .iter() + .find(|span| span["kind"] == "model") + .unwrap(); + assert_eq!(model["status"], "running"); + let live_id = model["id"].clone(); if signal == "KILL" { run.kill(); // A hard-killed owner cannot clean up its child. Let this exact @@ -273,6 +305,10 @@ while ! test -f "$HOME/release"; do sleep 0.01; done let spans = spans(&path); assert_eq!(spans.len(), 2, "{spans:?}"); assert!(spans.iter().all(|span| span["status"]["code"] == 2)); + assert!( + spans.iter().any(|span| span["spanId"] == live_id), + "the live reader and recorder must share call identity" + ); } } } diff --git a/tests/show_logs.rs b/tests/show_logs.rs index 93db49919..d254fab7b 100644 --- a/tests/show_logs.rs +++ b/tests/show_logs.rs @@ -21,8 +21,8 @@ fn same_stamp_session_history_keeps_legacy_and_owned_inventories_disjoint() { .unwrap(); for kind in ["implement", "implement-resume", "repair-1", "repair-2"] { fs::write( - folder.join(format!("{stem}-{kind}.jsonl")), - format!("{owner} {kind}\n"), + folder.join(format!("{stem}-{kind}.logs.jsonl")), + support::session_trace::records(&format!("{owner} {kind}\n")), ) .unwrap(); } @@ -40,7 +40,7 @@ fn same_stamp_session_history_keeps_legacy_and_owned_inventories_disjoint() { let sessions = command["session_logs"].as_array().unwrap(); assert_eq!(sessions.len(), 4, "{command}"); for kind in ["implement", "implement-resume", "repair-1", "repair-2"] { - let path = folder.join(format!("{stem}-{kind}.jsonl")); + let path = folder.join(format!("{stem}-{kind}.logs.jsonl")); assert!(sessions.contains(&serde_json::json!(path)), "{command}"); } } @@ -73,9 +73,9 @@ fn owned_and_legacy_commands_keep_exact_paths_and_record_precedence() { ), ] { let log = folder.join(format!("{stem}.log")); - let session = folder.join(format!("{stem}-implement.jsonl")); + let session = folder.join(format!("{stem}-implement.logs.jsonl")); fs::write(&log, format!("{marker} Command output\n")).unwrap(); - fs::write(&session, format!("{marker} Session output\n")).unwrap(); + support::session_trace::write(&session, format!("{marker} Session output\n")); let record = serde_json::json!({ "version": 1, "kind": "Run", "repository": "acme/widgets", "name": "acme/widgets#7", "issue": {"number": 7, "url": scenario.issue_url(7), "title": marker}, @@ -124,7 +124,7 @@ fn owned_and_legacy_commands_keep_exact_paths_and_record_precedence() { ); assert_eq!( shown["command"]["session_logs"], - serde_json::json!([folder.join("7-20261003T120000-0400~ownerB-implement.jsonl")]) + serde_json::json!([folder.join("7-20261003T120000-0400~ownerB-implement.logs.jsonl")]) ); for args in [ vec!["logs", "widgets#7"], @@ -136,7 +136,10 @@ fn owned_and_legacy_commands_keep_exact_paths_and_record_precedence() { } let session = scenario.run(&["logs", "widgets#7", "--session", "implement"]); assert_eq!(session.code, Some(0), "{}", session.stderr); - assert_eq!(session.stdout, "second Session output\n"); + assert_eq!( + session.stdout, + support::session_trace::records("second Session output\n") + ); } #[test] @@ -165,7 +168,7 @@ fn show_activity_history_lists_its_session_logs() { .unwrap() .iter() .any(|path| path.as_str() == Some(session.as_str())), - "missing Session log: {result}" + "missing Session trace: {result}" ); } @@ -187,8 +190,8 @@ printf 'Architecture review plan: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE" let name = status["commands"][0]["name"].as_str().unwrap(); let current = std::path::Path::new(status["commands"][0]["session_log"].as_str().unwrap()); let original = std::path::PathBuf::from(current.to_string_lossy().replace( - "-architecture-review-resume.jsonl", - "-architecture-review.jsonl", + "-architecture-review-resume.logs.jsonl", + "-architecture-review.logs.jsonl", )); assert_ne!(current, original.as_path()); assert!(original.is_file(), "{}", original.display()); @@ -198,12 +201,12 @@ printf 'Architecture review plan: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE" assert_eq!(json.code, Some(0), "{}", json.stderr); assert!( text.stdout.contains(original.to_str().unwrap()), - "original Session log missing: {}", + "original Session trace missing: {}", text.stdout ); assert!( json.stdout.contains(original.to_str().unwrap()), - "original Session log missing: {}", + "original Session trace missing: {}", json.stdout ); } @@ -488,7 +491,7 @@ fn logs_session_repair_selects_the_latest_numbered_repair() { .unwrap() .iter() .filter_map(|stage| stage["session_log"].as_str()) - .find(|path| path.ends_with("-repair-2.jsonl")) + .find(|path| path.ends_with("-repair-2.logs.jsonl")) .unwrap(); let logs = scenario.run(&["logs", "widgets#7", "--session", "repair"]); assert_eq!(logs.code, Some(0), "{}", logs.stderr); @@ -509,7 +512,7 @@ fn logs_session_selects_the_latest_implement_including_its_resume() { .unwrap() .iter() .filter_map(|stage| stage["session_log"].as_str()) - .find(|path| path.ends_with("-implement-resume.jsonl")) + .find(|path| path.ends_with("-implement-resume.logs.jsonl")) .unwrap(); let expected = fs::read_to_string(session).unwrap(); let result = scenario.run(&["logs", "widgets#7", "--session", "implement"]); @@ -518,7 +521,7 @@ fn logs_session_selects_the_latest_implement_including_its_resume() { let missing = scenario.run(&["logs", "widgets#7", "--session", "spec-review"]); assert_ne!(missing.code, Some(0)); assert!( - missing.stderr.contains("no spec-review Session log"), + missing.stderr.contains("no spec-review Session trace"), "{}", missing.stderr ); @@ -559,7 +562,7 @@ fn logs_can_read_sessions_from_activity_and_session_only_history() { #[test] fn show_reads_the_latest_live_event_from_claude_and_codex_session_tails() { - for trace in [false, true] { + { for (harness, event) in [ ( "claude", @@ -590,15 +593,7 @@ fn show_reads_the_latest_live_event_from_claude_and_codex_session_tails() { "{}\n{event}\n{{\"type\":\"future-event\"}}\nnot json\n{{\"partial\":", "x".repeat(100_000) ); - let records = std::path::Path::new(session).with_extension("logs.jsonl"); - if trace { - fs::write(&records, support::session_trace::records(&contents)).unwrap(); - // Stale dual-recorded output must not win over the Session trace. - fs::write(session, event.replace("cargo test", "stale command")).unwrap(); - } else { - fs::remove_file(records).unwrap(); - fs::write(session, contents).unwrap(); - } + fs::write(session, support::session_trace::records(&contents)).unwrap(); let result = scenario.run(&["show", "widgets#7", "--json"]); let text = scenario.run(&["show", "widgets#7"]); fs::write(scenario.path("release"), "").unwrap(); @@ -640,14 +635,13 @@ fn show_keeps_the_latest_event_when_otlp_escaping_expands_a_record_past_64_kib() "aggregated_output": "\"".repeat(quotes), "status": "completed", "exit_code": 0 }}) .to_string(); - fs::write(session, format!("{event}\n")).unwrap(); let records = support::session_trace::records(&event); if quotes == 20_000 { assert!(event.len() < 64 * 1024); } assert!(records.len() > 64 * 1024); let earlier = support::session_trace::records("Earlier diagnostic\n"); - fs::write(session.with_extension("logs.jsonl"), earlier + &records).unwrap(); + fs::write(session, earlier + &records).unwrap(); let result = scenario.run(&["show", "widgets#7", "--json"]); let text = scenario.run(&["show", "widgets#7"]); fs::write(scenario.path("release"), "").unwrap(); @@ -672,8 +666,6 @@ fn show_keeps_the_last_tool_call_after_large_session_trace_results() { let status = scenario.run(&["status", "--json"]); let status: Value = serde_json::from_str(&status.stdout).unwrap(); let session = std::path::Path::new(status["commands"][0]["session_log"].as_str().unwrap()); - let records = session.with_extension("logs.jsonl"); - fs::remove_file(&records).unwrap(); let tool = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test"}}]}}"#; // Enough trailing results to fill the encoded window, while the tool call // remains well inside the old raw window after a long Session's history. @@ -682,19 +674,15 @@ fn show_keeps_the_last_tool_call_after_large_session_trace_results() { "older diagnostic\n".repeat(20_000), support::session_trace::large_result_tail() ); - fs::write(session, &contents).unwrap(); - let raw = scenario.run(&["show", "widgets#7", "--json"]); - fs::write(&records, support::session_trace::records(&contents)).unwrap(); + support::session_trace::write(session, &contents); let trace = scenario.run(&["show", "widgets#7", "--json"]); let text = scenario.run(&["show", "widgets#7"]); fs::write(scenario.path("release"), "").unwrap(); let ended = held.finish(); assert_eq!(ended.code, Some(0), "{}", ended.stderr); - for output in [raw, trace] { - assert_eq!(output.code, Some(0), "{}", output.stderr); - let shown: Value = serde_json::from_str(&output.stdout).unwrap(); - assert_eq!(shown["command"]["latest_event"], "Bash: cargo test"); - } + assert_eq!(trace.code, Some(0), "{}", trace.stderr); + let shown: Value = serde_json::from_str(&trace.stdout).unwrap(); + assert_eq!(shown["command"]["latest_event"], "Bash: cargo test"); assert_eq!(text.code, Some(0), "{}", text.stderr); assert!(text.stdout.contains("Bash: cargo test"), "{}", text.stdout); } diff --git a/tests/spec_run.rs b/tests/spec_run.rs index 6a4fb08bf..da1c6fc7a 100644 --- a/tests/spec_run.rs +++ b/tests/spec_run.rs @@ -268,7 +268,7 @@ fn status_retains_merged_children_during_spec_review() { spec["session_log"] .as_str() .unwrap() - .ends_with("-spec-review.jsonl") + .ends_with("-spec-review.logs.jsonl") ); assert!( spec["tickets"] @@ -1348,19 +1348,17 @@ fn with_base_fix_a_tickets_inherited_failure_gets_a_base_fix_into_the_spec_branc assert_eq!( sessions, [ - format!("20-{owner}-spec-review.jsonl"), - format!("21-{owner}-implement.jsonl"), - format!("22-{owner}-implement.jsonl") + format!("20-{owner}-spec-review.logs.jsonl"), + format!("21-{owner}-implement.logs.jsonl"), + format!("22-{owner}-implement.logs.jsonl") ] ); for session in sessions { let evidence: serde_json::Value = serde_json::from_slice( - &std::fs::read( - scenario - .path(folder) - .join(session) - .with_extension("models.json"), - ) + &std::fs::read(support::session_trace::artifact( + &scenario.path(folder).join(session), + "models.json", + )) .unwrap(), ) .unwrap(); @@ -1387,7 +1385,7 @@ fn a_tickets_run_offers_the_spec_runs_command_with_a_base_fix_and_the_spec_run_r assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); let cause = inherited_failure(&scenario, "issue-20"); // The Ticket's Run says it on its own stderr, between its cause and its - // session log. + // session trace. assert_contains( &result.stderr, &format!( @@ -1396,11 +1394,11 @@ fn a_tickets_run_offers_the_spec_runs_command_with_a_base_fix_and_the_spec_run_r thirdshift: #21: Retry with: thirdshift {spec_url} --email me@example.com parallel 1 base-fix\n\ thirdshift: #21: Or set: base.fix = true in ~/.thirdshift/config.toml, \ to allow a Base fix for every Run on this machine\n\ - thirdshift: #21: session log: " + thirdshift: #21: session trace: " ), ); - // What the Spec run reads back is the cause and the session log, as ever. - let failed = format!("#21 failed: {cause} (session log: "); + // What the Spec run reads back is the cause and the session trace, as ever. + let failed = format!("#21 failed: {cause} (session trace: "); assert_contains(&result.stderr, &format!("thirdshift: {failed}")); let (_, text) = the_one_notification(&resend); assert_contains(&text, &failed); @@ -1813,7 +1811,7 @@ fn the_spec_review_is_logged_under_the_specs_name() { let logged = result .stderr .lines() - .find(|line| line.ends_with("-spec-review.jsonl")) + .find(|line| line.ends_with("-spec-review.logs.jsonl")) .unwrap_or_else(|| panic!("no Spec review log in: {}", result.stderr)); assert_contains(logged, "/20-"); } @@ -1836,7 +1834,7 @@ fn a_failed_spec_review_ends_the_spec_run_without_marking_the_spec_pr_ready() { "stderr: {}", result.stderr ); - assert_contains(&result.stderr, "thirdshift: session log: "); + assert_contains(&result.stderr, "thirdshift: session trace: "); } #[test] @@ -2273,10 +2271,10 @@ fn a_failed_ticket_stops_only_its_dependents_and_is_not_started_again() { .rfind(|line| line.starts_with("thirdshift: #21 failed: ")) .unwrap_or_else(|| panic!("no failed line for #21 in: {}", result.stderr)); assert!( - failed.starts_with("thirdshift: #21 failed: claude exited 1 (session log: "), + failed.starts_with("thirdshift: #21 failed: claude exited 1 (session trace: "), "{failed}" ); - let log = failed.rsplit("session log: ").next().unwrap(); + let log = failed.rsplit("session trace: ").next().unwrap(); assert!( std::path::Path::new(log.trim_end_matches(')')).exists(), "{failed}" @@ -2657,7 +2655,7 @@ fn a_failed_spec_run_sends_one_notification_with_each_tickets_outcome() { ); for part in [ "Cause: Tickets not done: #21, #22\n", - "#21 failed: claude exited 1 (session log: ", + "#21 failed: claude exited 1 (session trace: ", "#22 blocked by #21\n", "#23 landed with https://github.com/acme/widgets/pull/1\n", ] { @@ -3078,7 +3076,7 @@ fn failed_spec_review_reports_preserved_progress_and_the_same_recovery_in_notifi assert_eq!(spec_pr(&scenario)["isDraft"], true); assert_contains(notification, "#21 landed"); assert_contains(notification, "#22 landed"); - assert_contains(notification, "Session log:"); + assert_contains(notification, "Session trace:"); assert_contains(notification, "Command log:"); } @@ -3368,7 +3366,7 @@ fn failed_spec_review_reports_the_same_recovery_in_terminal_and_notification_and "Cause:", "claude exited 1", "Pull request:", - "Session log:", + "Session trace:", "Command log:", "#21 landed with", "#22 landed with", diff --git a/tests/status.rs b/tests/status.rs index e51c0f97f..82c9cc558 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -146,7 +146,7 @@ gh pr create --base main --head issue-7 --title 'Make widgets' --body 'Closes #7 command["session_log"] .as_str() .unwrap() - .ends_with("-implement.jsonl") + .ends_with("-implement.logs.jsonl") ); assert!(text.stdout.contains("Make widgets"), "{}", text.stdout); assert!(text.stdout.contains("implement ·"), "{}", text.stdout); @@ -321,7 +321,7 @@ fn a_merge_run_retains_delivery_stages_and_their_start_times() { stage["session_log"] .as_str() .unwrap() - .ends_with("-implement.jsonl") + .ends_with("-implement.logs.jsonl") ); } assert_eq!(record["outcome"], "merged"); @@ -737,8 +737,8 @@ fn tokenless_sessions_require_explicit_evidence_to_belong_to_an_owned_command() fs::rename(folder.join("7-fixture.command.json"), &record).unwrap(); let log = folder.join("7-20261003T120000-0400~ownerA.log"); fs::write(&log, "owned Command output\n").unwrap(); - let session = folder.join("7-20261003T120000-0400-implement.jsonl"); - fs::write(&session, "tokenless Session\n").unwrap(); + let session = folder.join("7-20261003T120000-0400-implement.logs.jsonl"); + support::session_trace::write(&session, "tokenless Session\n"); match evidence { "record" => { let mut facts: Value = serde_json::from_slice(&fs::read(&record).unwrap()).unwrap(); @@ -746,7 +746,7 @@ fn tokenless_sessions_require_explicit_evidence_to_belong_to_an_owned_command() fs::write(&record, serde_json::to_vec(&facts).unwrap()).unwrap(); } "models" => fs::write( - session.with_extension("models.json"), + session.with_file_name("7-20261003T120000-0400-implement.models.json"), serde_json::json!({"command_log": log}).to_string(), ) .unwrap(), @@ -883,7 +883,11 @@ fn fixture_log(scenario: &Scenario, relative: &str, contents: &str) { .path("home/.thirdshift/logs/acme/widgets") .join(relative); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, contents).unwrap(); + if relative.ends_with(".logs.jsonl") { + support::session_trace::write(path, contents); + } else { + fs::write(path, contents).unwrap(); + } } #[test] @@ -904,7 +908,7 @@ fn status_reads_activity_starts_from_before_harness_metadata() { ); fixture_log( &scenario, - "commands/issue/7-20261004T120000-0400-implement.jsonl", + "commands/issue/7-20261004T120000-0400-implement.logs.jsonl", "{}\n", ); let result = scenario.run(&["status", "--all", "--json"]); @@ -1089,13 +1093,13 @@ fn status_fills_gaps_by_source_and_groups_only_current_layout_sessions() { ), ); for name in [ - "7-20261001T120000-0400-implement.jsonl", - "8-20261002T120000-0400-spec-review.jsonl", - "10-20261002T120000-0400-implement.jsonl", // Ticket belongs to Spec #8. - "9-20260901T120000-0400-implement.jsonl", - "9-20260901T120000-0400-implement-resume.jsonl", - "9-20260901T120000-0400-repair-1.jsonl", - "9-20260902T120000-0400-implement.jsonl", + "7-20261001T120000-0400-implement.logs.jsonl", + "8-20261002T120000-0400-spec-review.logs.jsonl", + "10-20261002T120000-0400-implement.logs.jsonl", // Ticket belongs to Spec #8. + "9-20260901T120000-0400-implement.logs.jsonl", + "9-20260901T120000-0400-implement-resume.logs.jsonl", + "9-20260901T120000-0400-repair-1.logs.jsonl", + "9-20260902T120000-0400-implement.logs.jsonl", ] { fixture_log(&scenario, &format!("commands/issue/{name}"), "{}\n"); } @@ -1106,8 +1110,8 @@ fn status_fills_gaps_by_source_and_groups_only_current_layout_sessions() { ); fixture_log(&scenario, "commands/issue/bad-name.jsonl", "{}"); for relative in [ - "sessions/widgets-4-20260801T120000-0400-implement.jsonl", - "commands/issue/5-20260801T120000-0400-implement.jsonl", + "sessions/widgets-4-20260801T120000-0400-implement.logs.jsonl", + "commands/issue/5-20260801T120000-0400-implement.logs.jsonl", ] { let path = scenario.path("home/.thirdshift/logs").join(relative); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -1423,9 +1427,9 @@ fn repair_resume_and_security_review_keep_their_own_session_logs() { let record = scenario.command_record(LOGS); let stages = record["stages"].as_array().unwrap(); for (name, log) in [ - ("Resume", "-implement-resume.jsonl"), - ("Security review", "-security-review.jsonl"), - ("Repair", "-repair-1.jsonl"), + ("Resume", "-implement-resume.logs.jsonl"), + ("Security review", "-security-review.logs.jsonl"), + ("Repair", "-repair-1.logs.jsonl"), ] { assert!( stages.iter().any(|stage| stage["name"] == name diff --git a/tests/support/mod.rs b/tests/support/mod.rs index dbe052d00..57c95958d 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1358,12 +1358,8 @@ test -f {root}/{COPY_REPLACED} self.entries(relative) .into_iter() .filter(|name| { - // Existing transcript assertions select Session logs. Trace - // files are checked explicitly in session_traces.rs. - if extension == "jsonl" - && (name.ends_with(".logs.jsonl") || name.ends_with(".spans.jsonl")) - { - return false; + if extension == "jsonl" { + return name.ends_with(".logs.jsonl"); } Path::new(name) .extension() diff --git a/tests/support/session_trace.rs b/tests/support/session_trace.rs index 2e1b9f3e0..a38a7a9df 100644 --- a/tests/support/session_trace.rs +++ b/tests/support/session_trace.rs @@ -1,6 +1,56 @@ //! Session-trace fixtures at the on-disk OTLP seam. use serde_json::json; +pub fn artifact(path: &std::path::Path, extension: &str) -> std::path::PathBuf { + let name = path.file_name().unwrap().to_str().unwrap(); + let stem = name + .strip_suffix(".logs.jsonl") + .or_else(|| name.strip_suffix(".spans.jsonl")); + match stem { + Some(stem) => path.with_file_name(format!("{stem}.{extension}")), + None => path.with_extension(extension), + } +} + +/// Write raw Harness fixture events only inside OTLP log records. +pub fn write(path: impl AsRef, lines: impl AsRef) { + std::fs::write(path, records(lines.as_ref())).unwrap(); +} + +/// Recover events for assertions about preserved Harness output. +pub fn events(path: impl AsRef) -> String { + std::fs::read_to_string(path) + .unwrap() + .lines() + .flat_map(|line| { + let request: serde_json::Value = serde_json::from_str(line).unwrap(); + request["resourceLogs"] + .as_array() + .unwrap() + .iter() + .flat_map(|resource| { + resource["scopeLogs"] + .as_array() + .unwrap() + .iter() + .flat_map(|scope| { + scope["logRecords"] + .as_array() + .unwrap() + .iter() + .map(|record| { + record["body"]["stringValue"].as_str().unwrap().to_owned() + }) + .collect::>() + }) + .collect::>() + }) + .collect::>() + }) + .collect::>() + .join("\n") +} + /// Results that fit in the old raw tail but overflow 64 KiB once OTLP escapes them. pub fn large_result_tail() -> String { let result = json!({ diff --git a/tests/user_config.rs b/tests/user_config.rs index 07614f421..66abb2a37 100644 --- a/tests/user_config.rs +++ b/tests/user_config.rs @@ -184,7 +184,7 @@ fn help_and_version_succeed_with_a_broken_config() { const AGENT_EXITS_3: &str = "exit 3\n"; /// Assert the Run failed after logging its one session in `commands/issue/` under -/// `acme/widgets/` under `dir`, a directory under the scenario, and that the "session log:" line +/// `acme/widgets/` under `dir`, a directory under the scenario, and that the "session trace:" line /// names that log. fn assert_logged_in(scenario: &Scenario, result: &RunResult, dir: &str) { assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); @@ -195,7 +195,7 @@ fn assert_logged_in(scenario: &Scenario, result: &RunResult, dir: &str) { assert!( result .stderr - .contains(&format!("session log: {}", log.display())), + .contains(&format!("session trace: {}", log.display())), "stderr: {}", result.stderr );