mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* test(docsync): cut per-case spawn cost in the fault interface and split its outer run (GSTA-63) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: docsync-fault-interface per-case cost (GSTA-63) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.179.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
373 lines
23 KiB
TypeScript
373 lines
23 KiB
TypeScript
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { spawnSync } from 'node:child_process';
|
||
import type { fixtureDocs } from './docsync-fixture';
|
||
import { DOC_PATH, docsSnapshotArgv, gitAt, gitBlobId, repoContents, repoInventory, type DocsCandidateRecord } from './docsync-repo';
|
||
import { extractDocsDispatch } from './docsync-contract';
|
||
import { docsNativeInterface } from './docsync-interface';
|
||
|
||
export const DOCS_CHECKPOINT_MARKER = '<!-- DOCSYNC_CHECKPOINT -->';
|
||
|
||
export type DocsFault = 'missing-marker' | 'missing-asset' | 'launch-failure' | 'timeout-unsettled' |
|
||
'late-result' | 'stale-before' | 'stale-after' | 'recovery' | 'legacy-completion';
|
||
export interface ActorEvent { action: string; audit_id?: string; task_id?: string; detail?: string; }
|
||
export interface DocsActorState {
|
||
root: string;
|
||
scenario: DocsFault;
|
||
events: ActorEvent[];
|
||
tasks: Array<{ id: string | null; audit_id: string; settled: boolean; stopRequested: boolean; elapsed_ms: number;
|
||
prompt: string; candidate: string; prompt_sha256: string; candidate_sha256: string;
|
||
observed_candidate: DocsCandidateRecord }>;
|
||
repaired: boolean;
|
||
armed: boolean;
|
||
lateChanged: boolean;
|
||
acceptedId: string | null;
|
||
}
|
||
|
||
export function docsActorCanRepair(scenario: DocsFault): boolean {
|
||
return scenario === 'recovery' || scenario === 'late-result';
|
||
}
|
||
|
||
function owned(root: string, file: string): string {
|
||
const absolute = path.resolve(file);
|
||
if (!absolute.startsWith(fs.realpathSync(root) + path.sep)) throw Error('actor path outside fixture');
|
||
const existing = fs.existsSync(absolute) ? absolute : path.dirname(absolute);
|
||
if (fs.realpathSync(existing) !== fs.realpathSync(root) && !fs.realpathSync(existing).startsWith(fs.realpathSync(root) + path.sep)) throw Error('actor symlink escape');
|
||
return absolute;
|
||
}
|
||
|
||
function load(file: string): DocsActorState {
|
||
const s = JSON.parse(fs.readFileSync(file, 'utf8')) as DocsActorState;
|
||
owned(s.root, file);
|
||
return s;
|
||
}
|
||
|
||
function save(file: string, state: DocsActorState) {
|
||
fs.writeFileSync(file, JSON.stringify(state), { mode: 0o600 });
|
||
}
|
||
|
||
function locked<T>(file: string, body: () => T): T {
|
||
const lock = owned(load(file).root, file + '.lock');
|
||
const fd = fs.openSync(lock, 'wx', 0o600);
|
||
try { return body(); }
|
||
finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
||
}
|
||
|
||
const SNAPSHOT_TIMEOUT_MS = 30_000;
|
||
const shellWord = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
||
|
||
export const SNAPSHOT_STDERR_LIMIT = 800;
|
||
export type SnapshotFailureReason = 'snapshot_timeout' | 'snapshot_spawn_error' | 'snapshot_exit' | 'snapshot_invalid_record';
|
||
|
||
class SnapshotFailure extends Error {
|
||
constructor(readonly reason: SnapshotFailureReason, readonly status: string, readonly stderr: string) { super(reason); }
|
||
}
|
||
|
||
/**
|
||
* The tail of the helper's stderr with secrets redacted and the fixture root replaced by <HOME>, at most SNAPSHOT_STDERR_LIMIT characters.
|
||
* The redaction engine loads only here, on a failed snapshot, so hook and CLI spawns skip its import.
|
||
*/
|
||
function stderrExcerpt(root: string, stderr: string): string {
|
||
const { redactFindingSpans } = require('../../lib/redact-engine') as typeof import('../../lib/redact-engine');
|
||
const redacted = redactFindingSpans(stderr.replaceAll(root, '<HOME>').trim());
|
||
if (redacted === null) return '[stderr omitted: unredactable secret]';
|
||
return redacted.length > SNAPSHOT_STDERR_LIMIT ? '…' + redacted.slice(-(SNAPSHOT_STDERR_LIMIT - 1)) : redacted;
|
||
}
|
||
|
||
/**
|
||
* Runs the installed `gstack-docs-candidate snapshot` (the section's Prepare helper) for the fixture selection
|
||
* and returns the record it wrote, or throws a SnapshotFailure with a stable reason. `out` must not exist yet.
|
||
*/
|
||
function installedSnapshot(s: DocsActorState, auditId: string, out: string): { record: DocsCandidateRecord; command: string } {
|
||
const repo = path.join(s.root, 'repo');
|
||
const argv = docsSnapshotArgv({ repo, skills: path.join(s.root, '.claude/skills/gstack') }, auditId, out);
|
||
const run = spawnSync(argv[0]!, argv.slice(1), { cwd: repo, encoding: 'utf8', timeout: SNAPSHOT_TIMEOUT_MS,
|
||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
|
||
const stderr = run.stderr || '';
|
||
if (run.error) {
|
||
const timedOut = (run.error as NodeJS.ErrnoException).code === 'ETIMEDOUT';
|
||
throw new SnapshotFailure(timedOut ? 'snapshot_timeout' : 'snapshot_spawn_error', run.error.message, stderr);
|
||
}
|
||
if (run.status !== 0) throw new SnapshotFailure('snapshot_exit', `exit ${run.status ?? run.signal}`, stderr);
|
||
let record: DocsCandidateRecord;
|
||
try { record = JSON.parse(fs.readFileSync(out, 'utf8')) as DocsCandidateRecord; }
|
||
catch (error) { throw new SnapshotFailure('snapshot_invalid_record', `exit 0, unreadable record: ${(error as Error).message}`, stderr); }
|
||
if (record.schema_version !== 1 || record.audit_id !== auditId || record.mode !== 'edit' || record.base !== argv[argv.indexOf('--base') + 1]
|
||
|| !Array.isArray(record.index) || !record.hashes || typeof record.hashes !== 'object') throw new SnapshotFailure('snapshot_invalid_record', 'exit 0, invalid record', stderr);
|
||
return { record, command: argv.map(shellWord).join(' ') };
|
||
}
|
||
|
||
/** The candidate prepare would save now, observed without touching any saved artifact. */
|
||
function observeCandidate(s: DocsActorState, auditId: string): DocsCandidateRecord {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-actor-observe-'));
|
||
try { return installedSnapshot(s, auditId, path.join(dir, 'observed.json')).record; }
|
||
finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||
}
|
||
|
||
function changeCandidate(s: DocsActorState) {
|
||
fs.writeFileSync(path.join(s.root, 'repo/app.ts'), 'export const format = "json";\n');
|
||
s.lateChanged = true;
|
||
s.armed = false;
|
||
s.events.push({ action: 'scheduled-input-edit', detail: 'app.ts' });
|
||
}
|
||
|
||
export function docsActorCommand(file: string, action: string, args: Record<string, string> = {}): { text: string; exit: number } {
|
||
return locked(file, () => {
|
||
const s = load(file);
|
||
let text = '';
|
||
let exit = 0;
|
||
const last = () => {
|
||
const task = s.tasks.find(t => t.id === args.task_id);
|
||
if (!task || !args.task_id) throw Error('unknown fixture child');
|
||
return task;
|
||
};
|
||
const complete = (auditId: string, status: 'current' | 'updated' | 'blocked', blockers: string[] = [], updated: string[] = []) => {
|
||
return `SESSION_KIND: ${blockers.includes('Missing spawned marker') ? 'interactive' : 'spawned'}\n` + JSON.stringify({
|
||
schema_version: 1, audit_id: auditId, status, files_updated: updated,
|
||
files_reviewed: blockers.length ? [] : [DOC_PATH],
|
||
documentation_section: `${status} — fixture child audit ${auditId}; ${blockers.length ? blockers.join('; ') : 'reviewed the selected command reference'}.`,
|
||
blockers, decisions: [],
|
||
});
|
||
};
|
||
try {
|
||
if (action === 'prepare') {
|
||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(args.audit_id ?? '')) throw Error('prepare requires a fresh literal audit id');
|
||
if (s.tasks.some(t => !t.settled)) throw Error('attempted snapshot with unsettled writer');
|
||
const failedPrepares = s.events.filter(e => e.action === 'prepare-failed');
|
||
if (failedPrepares.some(e => e.audit_id === args.audit_id)) throw Error(`reason=snapshot_retry_needs_fresh_id: prepare ${args.audit_id} failed; retry once with a fresh audit_id`);
|
||
if (failedPrepares.length >= 2) throw Error('reason=snapshot_retry_exhausted: prepare already failed and was retried once; report the documentation phase blocked');
|
||
const repo = path.join(s.root, 'repo');
|
||
const skills = path.join(s.root, '.claude/skills/gstack');
|
||
const candidateFile = owned(s.root, path.join(s.root, `candidate-${args.audit_id}.json`));
|
||
const promptFile = owned(s.root, path.join(s.root, `prompt-${args.audit_id}.md`));
|
||
const saved = (file: string) => fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined;
|
||
if (saved(candidateFile) || saved(promptFile)) throw Error('prepare cannot overwrite a saved snapshot or prompt');
|
||
const source = extractDocsDispatch(fs.readFileSync(path.join(skills, 'ship/sections/documentation.md'), 'utf8'));
|
||
let snapshot: ReturnType<typeof installedSnapshot>;
|
||
try { snapshot = installedSnapshot(s, args.audit_id, candidateFile); }
|
||
catch (error) {
|
||
if (!(error instanceof SnapshotFailure)) throw error;
|
||
const failureFile = owned(s.root, path.join(s.root, `prepare-${args.audit_id}-failure.json`));
|
||
const kept = [...(saved(candidateFile) ? [candidateFile] : []), failureFile];
|
||
const failure = { reason: error.reason, audit_id: args.audit_id, status: error.status,
|
||
stderr_excerpt: stderrExcerpt(s.root, error.stderr), kept,
|
||
retry: failedPrepares.length ? 'none left' : 'at most once, with a fresh audit_id' };
|
||
fs.writeFileSync(failureFile, JSON.stringify(failure, null, 2), { flag: 'wx', mode: 0o600 });
|
||
s.events.push({ action: 'prepare-failed', audit_id: args.audit_id, detail: JSON.stringify(failure) });
|
||
save(file, s);
|
||
return { text: `prepare failed: reason=${failure.reason} (${failure.status}); retry ${failure.retry}; kept for inspection: ${kept.join(', ')}\nstderr (redacted, last ${SNAPSHOT_STDERR_LIMIT} chars):\n${failure.stderr_excerpt}`, exit: 25 };
|
||
}
|
||
const candidate = snapshot.record;
|
||
const prompt = source.replaceAll('${HOME}', s.root).replaceAll('<branch>', candidate.branch)
|
||
.replaceAll('<base>', 'main').replaceAll('<candidate-path>', candidateFile)
|
||
.replaceAll('<audit-id>', args.audit_id).replaceAll('<mode>', candidate.mode)
|
||
+ '\n\n' + docsNativeInterface({ home: s.root, repo, skills });
|
||
fs.writeFileSync(promptFile, prompt, { flag: 'wx', mode: 0o600 });
|
||
s.events.push({ action, audit_id: args.audit_id, detail: JSON.stringify({ candidate, prompt, snapshot_command: snapshot.command }) });
|
||
text = JSON.stringify({ audit_id: args.audit_id, candidate: candidateFile, prompt: promptFile, snapshot_command: snapshot.command });
|
||
} else if (action === 'dispatch') {
|
||
if (!args.audit_id || args.run_in_background !== 'false') throw Error('dispatch requires identity and explicit foreground flag');
|
||
if (s.tasks.some(t => !t.settled)) throw Error('attempted writer overlap');
|
||
if (s.events.some(e => e.action === 'dispatch' && e.audit_id === args.audit_id)) throw Error('reused audit identity');
|
||
if (s.events.some(e => e.action === 'prepare-failed' && e.audit_id === args.audit_id)) throw Error('dispatch of a failed prepare');
|
||
const prompt = fs.readFileSync(owned(s.root, args.prompt), 'utf8');
|
||
const candidate = fs.readFileSync(owned(s.root, args.candidate));
|
||
if (!prompt.includes('document-release') || !prompt.includes('files_updated') || !prompt.includes(args.audit_id)) throw Error('dispatch did not carry the actual workflow prompt');
|
||
const task = { id: s.scenario === 'launch-failure' ? null : `fixture-child-${s.tasks.length + 1}`, audit_id: args.audit_id, settled: true, stopRequested: false, elapsed_ms: 0,
|
||
prompt, candidate: candidate.toString('utf8'), prompt_sha256: new Bun.CryptoHasher('sha256').update(prompt).digest('hex'),
|
||
candidate_sha256: new Bun.CryptoHasher('sha256').update(candidate).digest('hex'),
|
||
observed_candidate: observeCandidate(s, args.audit_id) };
|
||
s.events.push({ action, audit_id: args.audit_id, ...(task.id === null ? {} : { task_id: task.id }) });
|
||
if (s.scenario === 'missing-asset') throw Error('missing installed asset must block before dispatch');
|
||
s.tasks.push(task);
|
||
if (s.scenario === 'launch-failure') {
|
||
exit = 23;
|
||
text = 'Child launch failed: injected unavailable worker. No child was started.';
|
||
save(file, s);
|
||
return { text, exit };
|
||
}
|
||
if (s.scenario === 'legacy-completion') {
|
||
const doc = owned(s.root, path.join(s.root, 'repo', DOC_PATH));
|
||
fs.writeFileSync(doc, fs.readFileSync(doc, 'utf8').replace('Default format: text.', 'Default format: JSON.'));
|
||
s.events.push({ action: 'partial-doc-edit', audit_id: args.audit_id, detail: DOC_PATH });
|
||
text = 'SESSION_KIND: spawned\n' + JSON.stringify({ files_updated: [], commit_sha: null, pushed: false, documentation_section: null });
|
||
} else if (s.scenario === 'missing-marker' || s.scenario === 'recovery' && !s.repaired) {
|
||
text = complete(args.audit_id, 'blocked', ['Missing spawned marker']);
|
||
} else if (s.scenario === 'timeout-unsettled' || s.scenario === 'late-result' && s.tasks.length === 1) {
|
||
task.settled = false;
|
||
text = JSON.stringify({ task_id: task.id, status: 'running', elapsed_ms: 0, virtual_clock: true });
|
||
} else if (s.scenario === 'late-result') {
|
||
if (!s.repaired) throw Error('transport must be repaired before retry');
|
||
text = complete(s.tasks[0].audit_id, 'current');
|
||
s.events.push({ action: 'late-callback', audit_id: s.tasks[0].audit_id });
|
||
} else if (s.scenario.startsWith('stale-') && s.tasks.length === 1) {
|
||
if (s.scenario === 'stale-before') changeCandidate(s);
|
||
else s.armed = true;
|
||
text = complete(args.audit_id, 'current');
|
||
s.events.push({ action: 'completion', audit_id: args.audit_id });
|
||
} else {
|
||
const updated: string[] = [];
|
||
if (s.lateChanged) {
|
||
const doc = path.join(s.root, 'repo', DOC_PATH);
|
||
fs.writeFileSync(doc, fs.readFileSync(doc, 'utf8').replace('Default format: text.', 'Default format: JSON.'));
|
||
updated.push(DOC_PATH);
|
||
s.events.push({ action: 'factual-doc-edit', audit_id: args.audit_id, detail: DOC_PATH });
|
||
}
|
||
s.acceptedId = args.audit_id;
|
||
text = complete(args.audit_id, updated.length ? 'updated' : 'current', [], updated);
|
||
s.events.push({ action: 'completion', audit_id: args.audit_id });
|
||
}
|
||
} else if (action === 'inspect') {
|
||
if (Object.keys(args).length) throw Error('inspect takes no arguments');
|
||
if (s.armed) changeCandidate(s);
|
||
const repo = path.join(s.root, 'repo');
|
||
const inventory = repoInventory(repo);
|
||
if (inventory.length > 64) throw Error('inspect inventory exceeds the bound');
|
||
const contents = repoContents(repo, inventory);
|
||
const [head, base] = gitAt(repo, 'rev-parse', 'HEAD', 'main').split('\n');
|
||
const files = Object.fromEntries(inventory.map(rel => {
|
||
const bytes = contents[rel];
|
||
if (bytes === undefined) return [rel, { exists: false }];
|
||
const buffer = Buffer.from(bytes, 'base64');
|
||
return [rel, { exists: true, hash: gitBlobId(buffer), content: buffer.toString('utf8') }];
|
||
}));
|
||
text = JSON.stringify({
|
||
operation: 'inspect', base, head,
|
||
branch: gitAt(repo, 'branch', '--show-current'), index: gitAt(repo, 'ls-files', '-s', '-z').split('\0').filter(Boolean),
|
||
pre_existing_dirty: gitAt(repo, 'status', '--porcelain', '-z'),
|
||
diff_committed: gitAt(repo, 'diff', base, 'HEAD'),
|
||
diff_cached: gitAt(repo, 'diff', '--cached'), diff_worktree: gitAt(repo, 'diff'),
|
||
inventory, files,
|
||
});
|
||
s.events.push({ action, detail: new Bun.CryptoHasher('sha256').update(text).digest('hex') });
|
||
} else if (action === 'status') {
|
||
const task = last();
|
||
task.elapsed_ms += task.stopRequested ? 300_001 : 600_001;
|
||
s.events.push({ action, task_id: args.task_id, detail: task.settled ? 'settled' : 'running' });
|
||
text = JSON.stringify({ task_id: task.id, status: task.settled ? 'stopped' : 'running', settled: task.settled,
|
||
elapsed_ms: task.elapsed_ms, virtual_clock: true });
|
||
} else if (action === 'stop') {
|
||
const task = last();
|
||
task.stopRequested = true;
|
||
if (s.scenario !== 'timeout-unsettled') task.settled = true;
|
||
s.events.push({ action, task_id: args.task_id, detail: task.settled ? 'settled' : 'unsettled' });
|
||
text = JSON.stringify({ task_id: task.id, stop_requested: true, settled: task.settled });
|
||
} else if (action === 'repair') {
|
||
if (!docsActorCanRepair(s.scenario) || s.repaired || !s.tasks.length || s.tasks.some(t => !t.settled)) throw Error('repair not available');
|
||
s.repaired = true;
|
||
s.events.push({ action, detail: 'fixture transport/marking repaired' });
|
||
text = 'Fixture transport/marking repaired; future dispatches use the corrected launcher.';
|
||
} else if (action === 'publish') {
|
||
s.events.push({ action, audit_id: args.audit_id });
|
||
if (!s.acceptedId || args.audit_id !== s.acceptedId || s.tasks.some(t => !t.settled)) throw Error('publication attempted without a current settled audit');
|
||
const report = fs.readFileSync(owned(s.root, args.report), 'utf8');
|
||
if (!report.includes(s.acceptedId)) throw Error('publication did not consume actual audit result');
|
||
fs.writeFileSync(path.join(s.root, 'publication.json'), JSON.stringify({ audit_id: s.acceptedId, report }), { mode: 0o600 });
|
||
text = 'Mock publication recorded.';
|
||
} else throw Error('unsupported fixture action');
|
||
} catch (error) {
|
||
s.events.push({ action: 'rejected', audit_id: args.audit_id, detail: String(error) });
|
||
text = String(error);
|
||
exit = 24;
|
||
}
|
||
save(file, s);
|
||
return { text, exit };
|
||
});
|
||
}
|
||
|
||
export function docsActorHook(file: string, input: string) {
|
||
return locked(file, () => {
|
||
const s = load(file);
|
||
const e = JSON.parse(input);
|
||
if (s.armed && e.hook_event_name === 'PreToolUse' && e.cwd === path.join(s.root, 'repo') &&
|
||
!JSON.stringify(e.tool_input ?? {}).includes('docsync-fault-actor.ts')) {
|
||
changeCandidate(s);
|
||
save(file, s);
|
||
}
|
||
});
|
||
}
|
||
|
||
export function installDocsActor(fixture: ReturnType<typeof fixtureDocs>, scenario: DocsFault): string {
|
||
fs.writeFileSync(fixture.invocation, `# Bounded ship fixture invocation
|
||
|
||
This is synthetic prior-stage state supplied by the test, not evidence that real reviews or checks ran. Steps 0–14 are complete only within this isolated documentation-phase fixture. Do not reconstruct or rerun them.
|
||
|
||
## Release
|
||
Base main (${gitAt(fixture.repo, 'rev-parse', 'main')}); HEAD ${fixture.before.head}; VERSION 0.1.0.0; package version 0.1.0; BUMP_LEVEL not applicable to this bounded phase. Existing metadata is intentional fixture input, not a request to repair release preparation.
|
||
## Decisions
|
||
No risk exceptions or risky edits approved. Preserve unrelated and partial content. No real publication or later ship steps authorized.
|
||
## Reviews
|
||
Earlier review stages are synthetic and outside this fixture. No live review handles or tokens are asserted.
|
||
## Checks
|
||
Earlier check stages are synthetic and outside this fixture. No test receipts are asserted.
|
||
## Initial documentation state
|
||
Attempts used: 0. No accepted audit, hashes, exception or child handle. The supplied candidate.json is initial fixture input, not an accepted audit; prepare saves the same selection with current hashes, so it needs no separate Read.
|
||
## Initial next steps
|
||
1. CURRENT: documentation phase (Step 14.5, or store documentation preflight).
|
||
2. Save the result and optionally execute the authorized local publication stand-in if the actual documentation gate permits it.
|
||
3. STOP before Step 15 or any store action.
|
||
|
||
## Documentation checkpoint journal
|
||
Append changes in order. The latest stated value is current; earlier entries and the initial state remain evidence, not instructions to repeat completed work.
|
||
${DOCS_CHECKPOINT_MARKER}
|
||
`, { mode: 0o600 });
|
||
const file = path.join(fixture.home, 'actor-state.json');
|
||
save(file, { root: fixture.home, scenario, events: [], tasks: [], repaired: false, armed: false, lateChanged: false, acceptedId: null });
|
||
const configFile = path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json');
|
||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||
const quote = (p: string) => `'${p.replaceAll("'", "'\\''")}'`;
|
||
config.hooks.PreToolUse.push({ matcher: '^(Bash|Read|Write|Edit|Glob|Grep)$', hooks: [{ type: 'command',
|
||
command: `${quote(process.execPath)} ${quote(import.meta.path)} hook ${quote(file)}`, timeout: 5 }] });
|
||
fs.writeFileSync(configFile, JSON.stringify(config));
|
||
if (scenario === 'missing-asset') fs.unlinkSync(path.join(fixture.skills, 'document-release/sections/audit-scope.md'));
|
||
return file;
|
||
}
|
||
|
||
export const DOCS_SEEDED_AUDIT_ID = 'ship-docs-a1';
|
||
const DOCS_CHILD_ASSETS = ['document-release/SKILL.md', 'document-release/sections/audit-scope.md', 'document-release/sections/release-body.md'];
|
||
|
||
export function docsActorSeeded(scenario: DocsFault): boolean {
|
||
return scenario !== 'missing-asset' && scenario !== 'legacy-completion';
|
||
}
|
||
|
||
export function seedDocsFirstAttempt(fixture: ReturnType<typeof fixtureDocs>, file: string) {
|
||
const assets = DOCS_CHILD_ASSETS.map(relative => {
|
||
const asset = path.join(fixture.skills, relative);
|
||
if (!fs.existsSync(asset)) throw Error(`seeded attempt requires installed ${relative}`);
|
||
return { asset, sha256: new Bun.CryptoHasher('sha256').update(fs.readFileSync(asset)).digest('hex') };
|
||
});
|
||
const prepared = docsActorCommand(file, 'prepare', { audit_id: DOCS_SEEDED_AUDIT_ID });
|
||
if (prepared.exit) throw Error(`seeded prepare failed: ${prepared.text}`);
|
||
const { candidate, prompt, snapshot_command } = JSON.parse(prepared.text) as { candidate: string; prompt: string; snapshot_command: string };
|
||
const dispatched = docsActorCommand(file, 'dispatch', { audit_id: DOCS_SEEDED_AUDIT_ID, candidate, prompt, run_in_background: 'false' });
|
||
if (dispatched.exit === 24) throw Error(`seeded dispatch rejected: ${dispatched.text}`);
|
||
const completion = path.join(fixture.home, `completion-${DOCS_SEEDED_AUDIT_ID}.md`);
|
||
fs.writeFileSync(completion, dispatched.text, { flag: 'wx', mode: 0o600 });
|
||
let handle = '';
|
||
try { handle = JSON.parse(dispatched.text).task_id ?? ''; } catch {}
|
||
const record = fs.readFileSync(fixture.invocation, 'utf8');
|
||
if (record.split(DOCS_CHECKPOINT_MARKER).length !== 2) throw Error('invocation journal marker must occur exactly once');
|
||
fs.writeFileSync(fixture.invocation, record.replace(DOCS_CHECKPOINT_MARKER, () => `### Checkpoint 1 — attempt 1 (fixture-owned prior state)
|
||
Attempts used: 1. Prepare step 1: installed child assets present with sha256 ${assets.map(a => `${a.asset} ${a.sha256}`).join('; ')}. Prepare steps 2–3: audit ${DOCS_SEEDED_AUDIT_ID}; candidate ${candidate}, written by ${snapshot_command}; prompt ${prompt}. Launched with dispatch run_in_background=false; dispatch exit code ${dispatched.exit}; its verbatim output is saved once at ${completion}.${handle ? ` Returned child handle: ${handle}.` : ''}
|
||
Next: Parent processing for attempt 1, starting at Collect. A repeated Prepare may confirm step 1 with one literal sha256sum of these three asset paths instead of re-reading them; a missing asset or changed hash blocks before launch.
|
||
${DOCS_CHECKPOINT_MARKER}`));
|
||
return { auditId: DOCS_SEEDED_AUDIT_ID, candidate, prompt, snapshotCommand: snapshot_command, completion, exit: dispatched.exit, text: dispatched.text,
|
||
events: load(file).events.length };
|
||
}
|
||
|
||
if (import.meta.main) {
|
||
const [action, file, ...rest] = process.argv.slice(2);
|
||
if (action === 'hook') docsActorHook(file, fs.readFileSync(0, 'utf8'));
|
||
else {
|
||
const args = Object.fromEntries(rest.map(arg => {
|
||
const at = arg.indexOf('=');
|
||
if (at < 1) throw Error('fixture arguments use key=value');
|
||
return [arg.slice(0, at), arg.slice(at + 1)];
|
||
}));
|
||
const result = docsActorCommand(file, action, args);
|
||
console.log(result.text);
|
||
process.exitCode = result.exit;
|
||
}
|
||
}
|