From 4caeedfbacbcee6d0fa4dd667a1c63e907ea5430 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:57:09 +0300 Subject: [PATCH 1/5] Share prima's envelope tail and return artifact paths by value successEnvelope/failureEnvelope/reportEnvelope each repeated instance/status/artifacts; collapse them into one envelopeTail() helper. writeSnapshot/saveStatus now return the artifact paths they wrote instead of stashing them on a mutable this.artifacts field that every builder had to read in the right order. Also swap requestStore().getRequests() for the surviving getMadeRequests() alias ahead of PR #173 deleting the old name. Steps 1 and 2 of plan 034 are committed together: Step 1 alone (saveStatus/writeSnapshot signature change, field deletion) leaves the three builders referencing a deleted field and treating an object as a string, so it doesn't compile or pass tests on its own. Co-Authored-By: Claude Fable 5 --- boat/prima/src/prima.ts | 35 +++++++++++++++------------------- boat/prima/tests/prima.test.ts | 2 +- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index c5cfdbd4..8c4e2dd7 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts'; import { pluralize } from '../../../src/utils/logger.ts'; import { mdq } from '../../../src/utils/markdown-query.ts'; import { safeFilename } from '../../../src/utils/strings.ts'; -import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts'; +import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts'; import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts'; import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts'; import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts'; @@ -74,7 +74,6 @@ export class Prima { private server: { close: () => Promise } | null = null; private attached: string | null = null; private session: SessionRun | null = null; - private artifacts?: EnvelopeData['artifacts']; constructor(options: PrimaOptions = {}) { this.options = options; @@ -982,16 +981,13 @@ export class Prima { private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise { const changes = await this.pageChanges(result, previousState, used[0]); - const status = await this.saveStatus(result); return { ok: true, command, used, page: this.pageBlock(result, previousState), changes, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } @@ -1000,31 +996,30 @@ export class Prima { const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) }; if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true); - const status = await this.saveStatus(result); return { ok: false, command, page: this.pageBlock(result, previousState), failure, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial): Promise { - const status = await this.saveStatus(result); return { ok: true, command, page: this.pageBlock(result, previousState), ...outcome, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } + private async envelopeTail(result: ActionResult): Promise> { + const { hash, artifacts } = await this.saveStatus(result); + return { instance: await this.instanceInfo(), status: hash, artifacts }; + } + private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise { const captured = await this.bot .getExplorer() @@ -1087,11 +1082,11 @@ export class Prima { }; } - private async saveStatus(result: ActionResult): Promise { + private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> { const hash = this.statusHash(); - await this.writeSnapshot(result); + const artifacts = await this.writeSnapshot(result); writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8'); - return hash; + return { hash, artifacts }; } private async writeStepFiles(index: number, label: string, diff: string): Promise { @@ -1108,12 +1103,12 @@ export class Prima { if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8'); } - private async writeSnapshot(result: ActionResult): Promise { - this.artifacts = writeArtifacts(this.statusDir(), { + private async writeSnapshot(result: ActionResult): Promise { + return writeArtifacts(this.statusDir(), { aria: result.ariaSnapshot, html: await result.combinedHtml(), screenshot: result.screenshot, - requests: this.bot.requestStore().getRequests(), + requests: this.bot.requestStore().getMadeRequests(), }); } diff --git a/boat/prima/tests/prima.test.ts b/boat/prima/tests/prima.test.ts index b2087d89..f16f3edd 100644 --- a/boat/prima/tests/prima.test.ts +++ b/boat/prima/tests/prima.test.ts @@ -95,7 +95,7 @@ function fakePrima(options: Record = {}) { }), getCurrentState: () => fakeState(), getConfig: () => ({}), - requestStore: () => ({ getRequests: () => [] }), + requestStore: () => ({ getMadeRequests: () => [] }), getProvider: () => ({ chat: async () => '' }), }; (prima as any).artifactsDir = artifactsRoot; From d9c4d1cf7c5ebc9994e7776f686a83e4b7393031 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:57:43 +0300 Subject: [PATCH 2/5] Give step-file names one owner: STEP_FILES writeStepFiles wrote .aria.yaml/.html/.diff.yaml by string concatenation while envelope.ts advertised the same names as a hardcoded literal in its doc line, with nothing enforcing agreement. Export STEP_FILES next to ARTIFACT_FILES and have both the writer and the rendered doc line derive from it. Pin the doc line's exact extensions with a new test so the two can no longer drift silently. Co-Authored-By: Claude Fable 5 --- boat/prima/src/envelope.ts | 4 +++- boat/prima/src/prima.ts | 8 ++++---- boat/prima/tests/envelope.test.ts | 7 ++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/boat/prima/src/envelope.ts b/boat/prima/src/envelope.ts index c1e6d477..9250f310 100644 --- a/boat/prima/src/envelope.ts +++ b/boat/prima/src/envelope.ts @@ -5,6 +5,8 @@ export const STATUS_FILE = 'status.json'; const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' }; +export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' }; + const EXPECTATION_LABELS = { passed: 'PASSED ', failed: 'FAILED ', @@ -117,7 +119,7 @@ function renderSteps(data: EnvelopeData): string | null { lines.push(`${index + 1}. ${mark} ${step.label}`); for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`); }); - if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{aria.yaml,html,diff.yaml}`); + if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{${Object.values(STEP_FILES).join(',')}}`); return section('Steps', lines.join('\n')); } diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index 8c4e2dd7..0bb95a71 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts'; import { pluralize } from '../../../src/utils/logger.ts'; import { mdq } from '../../../src/utils/markdown-query.ts'; import { safeFilename } from '../../../src/utils/strings.ts'; -import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts'; +import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts'; import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts'; import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts'; import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts'; @@ -1098,9 +1098,9 @@ export class Prima { const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`); const result = ActionResult.fromState(state); - writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8'); - writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8'); - if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8'); + if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8'); } private async writeSnapshot(result: ActionResult): Promise { diff --git a/boat/prima/tests/envelope.test.ts b/boat/prima/tests/envelope.test.ts index c2ae6532..ae773c4a 100644 --- a/boat/prima/tests/envelope.test.ts +++ b/boat/prima/tests/envelope.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { type EnvelopeData, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; +import { type EnvelopeData, STEP_FILES, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; const base: EnvelopeData = { ok: true, @@ -180,6 +180,11 @@ describe('renderEnvelope', () => { expect(out).toContain('page after each step: /tmp/x/-.{aria.yaml,html,diff.yaml}'); }); + test('the advertised step-file line names every STEP_FILES extension', () => { + const out = renderEnvelope({ ...base, steps: [{ label: "I.click('Add')", ok: true, proof: '' }], stepFiles: '/tmp/x' }); + for (const extension of Object.values(STEP_FILES)) expect(out).toContain(extension); + }); + test('open tabs alone are evidence enough for a running browser', () => { const out = renderEnvelope({ ...base, instance: { name: 'default', tabs: 2, others: [] } }); expect(out).toContain('| running'); From 49ee14dcfa20c68c22b7f9a600cb3f84c6151367 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:58:37 +0300 Subject: [PATCH 3/5] Route the status action through runPrima's shared lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status action had its own hand-rolled copy of runPrima's lifecycle and, unlike every other command, never called prima.stop() — accidental divergence, not a deliberate choice. Give runPrima a browser/record toggle so status can reuse the shared path while staying browserless (the point of PR #166) and skipping session recording, picking up activity-line handling and prima.stop() for free. Exit code semantics are unchanged. Verified manually: `prima status nonexistent` returns a failure envelope, exits 1, and the Instance line shows the browser as not running. Co-Authored-By: Claude Fable 5 --- boat/prima/src/cli.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/boat/prima/src/cli.ts b/boat/prima/src/cli.ts index bac3fbb6..8243aa0d 100644 --- a/boat/prima/src/cli.ts +++ b/boat/prima/src/cli.ts @@ -129,7 +129,8 @@ function primaFor(options: any): Prima { return new Prima(buildOptions(options)); } -async function runPrima(options: any, command: string, run: (prima: Prima) => Promise): Promise { +async function runPrima(options: any, command: string, run: (prima: Prima) => Promise, opts: { browser?: boolean; record?: boolean } = {}): Promise { + const { browser = true, record = true } = opts; setQuietMode(!isVerboseMode()); trackActivityLine(); const prima = primaFor(options); @@ -137,13 +138,13 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr let envelope: EnvelopeData; try { - await prima.start(); + if (browser) await prima.start(); envelope = await run(prima); } catch (error) { envelope = await prima.toolFailureEnvelope(command, error); } - prima.record(envelope, Date.now() - startedAt); + if (record) prima.record(envelope, Date.now() - startedAt); clearActivityLine(); console.log(renderEnvelope(envelope)); await prima.stop().catch(() => {}); @@ -221,11 +222,7 @@ export function createPrimaCommands(name = 'prima'): Command { addCommonOptions(cmd.command('status ').description('Show the artifacts and page detail recorded for an earlier command')) .addHelpText('after', `\n${statusHelp}`) .action(async (hash, options) => { - setQuietMode(!isVerboseMode()); - const prima = primaFor(options); - const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error)); - console.log(renderEnvelope(envelope)); - process.exit(envelope.ok ? 0 : 1); + await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false }); }); addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report')) From b3c7295c442788872270deebbe2bc1a5339240c3 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:59:42 +0300 Subject: [PATCH 4/5] Add changelog entry for prima's envelope consolidation Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40849d9d..dc293bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-09-02 + +### Changes + +- Prima: The three envelope builders (`pw`/`do`/`go`, failures, and `check`/`ask`/`verify`/`research`) + now share one tail builder instead of each repeating the instance/status/artifacts block. Artifact + paths flow back from the write as a return value rather than through a mutable field that had to be + read in the right order, so an envelope can no longer come back missing its Artifacts section. + Per-step file names (`aria.yaml`/`html`/`diff.yaml`) written during a `do` run now have a single + owner shared with the doc line the envelope advertises, so the two can no longer drift apart. + ## 2026-09-01 ### Changes From a828050c76ffa10560e5704f18d5ed6bc3c3f4eb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 01:01:30 +0300 Subject: [PATCH 5/5] Scope the STEP_FILES test to the step-file line itself The assertion checked the whole rendered envelope, so the 'html' value passed vacuously via base.artifacts's page.html path rather than the doc line under test. Scope it to the line that advertises the step-file names, per the plan's test-plan section. Co-Authored-By: Claude Fable 5 --- boat/prima/tests/envelope.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/boat/prima/tests/envelope.test.ts b/boat/prima/tests/envelope.test.ts index ae773c4a..2d400f5a 100644 --- a/boat/prima/tests/envelope.test.ts +++ b/boat/prima/tests/envelope.test.ts @@ -182,7 +182,8 @@ describe('renderEnvelope', () => { test('the advertised step-file line names every STEP_FILES extension', () => { const out = renderEnvelope({ ...base, steps: [{ label: "I.click('Add')", ok: true, proof: '' }], stepFiles: '/tmp/x' }); - for (const extension of Object.values(STEP_FILES)) expect(out).toContain(extension); + const line = out.split('\n').find((entry) => entry.includes('page after each step'))!; + for (const extension of Object.values(STEP_FILES)) expect(line).toContain(extension); }); test('open tabs alone are evidence enough for a running browser', () => {