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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 5 additions & 8 deletions boat/prima/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,22 @@ function primaFor(options: any): Prima {
return new Prima(buildOptions(options));
}

async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>, opts: { browser?: boolean; record?: boolean } = {}): Promise<void> {
const { browser = true, record = true } = opts;
setQuietMode(!isVerboseMode());
trackActivityLine();
const prima = primaFor(options);
const startedAt = Date.now();

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(() => {});
Expand Down Expand Up @@ -221,11 +222,7 @@ export function createPrimaCommands(name = 'prima'): Command {
addCommonOptions(cmd.command('status <hash>').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'))
Expand Down
4 changes: 3 additions & 1 deletion boat/prima/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
Expand Down Expand Up @@ -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}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
return section('Steps', lines.join('\n'));
}

Expand Down
41 changes: 18 additions & 23 deletions boat/prima/src/prima.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
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, 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';
Expand Down Expand Up @@ -74,7 +74,6 @@
private server: { close: () => Promise<void> } | null = null;
private attached: string | null = null;
private session: SessionRun | null = null;
private artifacts?: EnvelopeData['artifacts'];

constructor(options: PrimaOptions = {}) {
this.options = options;
Expand Down Expand Up @@ -982,16 +981,13 @@

private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
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)),
};
}

Expand All @@ -1000,31 +996,30 @@
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<EnvelopeData>): Promise<EnvelopeData> {
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<Pick<EnvelopeData, 'instance' | 'status' | 'artifacts'>> {
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<ActionResult> {
const captured = await this.bot
.getExplorer()
Expand Down Expand Up @@ -1087,11 +1082,11 @@
};
}

private async saveStatus(result: ActionResult): Promise<string> {
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<void> {
Expand All @@ -1103,17 +1098,17 @@
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<void> {
this.artifacts = writeArtifacts(this.statusDir(), {
private async writeSnapshot(result: ActionResult): Promise<ArtifactPaths> {
return writeArtifacts(this.statusDir(), {
aria: result.ariaSnapshot,
html: await result.combinedHtml(),
screenshot: result.screenshot,
requests: this.bot.requestStore().getRequests(),
requests: this.bot.requestStore().getMadeRequests(),

Check failure on line 1111 in boat/prima/src/prima.ts

View workflow job for this annotation

GitHub Actions / integration (24)

TypeError: this.bot.requestStore().getMadeRequests is not a function. (In 'this.bot.requestStore().getMadeRequests()', 'this.bot.requestStore().getMadeRequests' is undefined)

at writeSnapshot (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1111:41) at async saveStatus (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1087:34) at async envelopeTail (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1019:44) at async failureEnvelope (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1004:22)

Check failure on line 1111 in boat/prima/src/prima.ts

View workflow job for this annotation

GitHub Actions / integration (24)

TypeError: this.bot.requestStore().getMadeRequests is not a function. (In 'this.bot.requestStore().getMadeRequests()', 'this.bot.requestStore().getMadeRequests' is undefined)

at writeSnapshot (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1111:41) at async saveStatus (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1087:34) at async envelopeTail (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1019:44) at async failureEnvelope (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1004:22)

Check failure on line 1111 in boat/prima/src/prima.ts

View workflow job for this annotation

GitHub Actions / integration (24)

TypeError: this.bot.requestStore().getMadeRequests is not a function. (In 'this.bot.requestStore().getMadeRequests()', 'this.bot.requestStore().getMadeRequests' is undefined)

at writeSnapshot (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1111:41) at async saveStatus (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1087:34) at async envelopeTail (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1019:44) at async successEnvelope (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:990:22) at async do (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:259:33) at async <anonymous> (/home/runner/work/explorbot/explorbot/tests/integration/prima-do.test.ts:125:17)

Check failure on line 1111 in boat/prima/src/prima.ts

View workflow job for this annotation

GitHub Actions / integration (24)

TypeError: this.bot.requestStore().getMadeRequests is not a function. (In 'this.bot.requestStore().getMadeRequests()', 'this.bot.requestStore().getMadeRequests' is undefined)

at writeSnapshot (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1111:41) at async saveStatus (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1087:34) at async envelopeTail (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:1019:44) at async successEnvelope (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:990:22) at async do (/home/runner/work/explorbot/explorbot/boat/prima/src/prima.ts:259:33) at async <anonymous> (/home/runner/work/explorbot/explorbot/tests/integration/prima-do.test.ts:112:34)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need fix

});
}

Expand Down
8 changes: 7 additions & 1 deletion boat/prima/tests/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -180,6 +180,12 @@ describe('renderEnvelope', () => {
expect(out).toContain('page after each step: /tmp/x/<n>-<step>.{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' });
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', () => {
const out = renderEnvelope({ ...base, instance: { name: 'default', tabs: 2, others: [] } });
expect(out).toContain('| running');
Expand Down
2 changes: 1 addition & 1 deletion boat/prima/tests/prima.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function fakePrima(options: Record<string, unknown> = {}) {
}),
getCurrentState: () => fakeState(),
getConfig: () => ({}),
requestStore: () => ({ getRequests: () => [] }),
requestStore: () => ({ getMadeRequests: () => [] }),
getProvider: () => ({ chat: async () => '' }),
};
(prima as any).artifactsDir = artifactsRoot;
Expand Down
Loading