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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion src/lib/programs/__tests__/run-agent-legacy.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runNonInteractive } from '@lib/runners/run-non-interactive';
import { runWizard } from '@lib/runners/run-wizard';
import { authenticate } from '@lib/programs/authenticate';
Expand All @@ -13,9 +16,10 @@ import { WizardStore } from '@ui/tui/store';
import { getUI, setUI } from '@ui';
import { analytics } from '@utils/analytics';
import { initLogFile, logToFile } from '@utils/debug';
import { wizardAbort } from '@utils/wizard-abort';
import { registerCleanup, wizardAbort } from '@utils/wizard-abort';
import { ErrorCodes } from '@shared/errors';
import type { ProgramConfig } from '../program-step';
import { AUDIT_CHECKS_KEY } from '../audit/types';

const streamShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('@env', async (original) => ({
Expand Down Expand Up @@ -408,3 +412,83 @@ it('keeps a TUI run a success when its terminal analytics flush fails', async ()
);
exit.mockRestore();
});

describe('the audit ledger', () => {
let installDir: string;
const ledgerPath = () => path.join(installDir, '.posthog-audit-checks.json');
const audit = (): ProgramConfig => ({
...program(),
auditLedgerFile: '.posthog-audit-checks.json',
});
const auditSession = () => ({ ...session(), installDir });
/** The agent seeds the ledger and, like a real run, never runs the `rm`. */
const seedThen =
(finish: typeof runAgent): typeof runAgent =>
(...args) => {
fs.writeFileSync(ledgerPath(), '[]');
return finish(...args);
};

beforeEach(() => {
installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-ledger-'));
});
afterEach(() => fs.rmSync(installDir, { recursive: true, force: true }));

it('is removed from the project once the run settles', async () => {
vi.mocked(runAgent).mockImplementation(seedThen(finishRun));
await runProgramAgent(audit(), auditSession());
expect(fs.existsSync(ledgerPath())).toBe(false);
});

it('is removed when the run throws', async () => {
const error = new Error('agent crashed');
vi.mocked(runAgent).mockImplementation(
seedThen(() => Promise.reject(error)),
);
await expect(runProgramAgent(audit(), auditSession())).rejects.toBe(error);
expect(fs.existsSync(ledgerPath())).toBe(false);
});

it('is removed by the abort cleanup', async () => {
const onAbort: Array<() => void> = [];
vi.mocked(registerCleanup).mockImplementation((fn) => {
onAbort.push(fn);
});
let leftAfterAbort = true;
vi.mocked(runAgent).mockImplementation(
seedThen((...args) => {
onAbort.forEach((fn) => fn());
leftAfterAbort = fs.existsSync(ledgerPath());
return finishRun(...args);
}),
);
await runProgramAgent(audit(), auditSession());
expect(leftAfterAbort).toBe(false);
});

it('keeps a finished run a success when the ledger cannot be removed', async () => {
vi.mocked(runAgent).mockImplementation((...args) => {
fs.mkdirSync(ledgerPath());
return finishRun(...args);
});
await expect(
runProgramAgent(audit(), auditSession()),
).resolves.toBeUndefined();
expect(logToFile).toHaveBeenCalledWith(
expect.stringContaining('[audit-ledger] could not remove'),
);
});

it('mirrors a last write the watcher has not read yet', async () => {
const checks = [
{ id: 'sdk', area: 'SDK', label: 'Install the SDK', status: 'pass' },
];
const mirror = vi.spyOn(getUI(), 'setFrameworkContext');
vi.mocked(runAgent).mockImplementation((...args) => {
fs.writeFileSync(ledgerPath(), JSON.stringify(checks));
return finishRun(...args);
});
await runProgramAgent(audit(), auditSession());
expect(mirror).toHaveBeenCalledWith(AUDIT_CHECKS_KEY, checks);
});
});
16 changes: 14 additions & 2 deletions src/lib/programs/audit/ledger-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Mirrors the agent's `.posthog-audit-checks.json` into the session, so the TUI
* screens and the task stream read one value. `runAgent` owns the lifecycle, so
* every path gets it — including the e2e host, which builds no task stream.
* screens and the task stream read one value. `runProgramAgent` owns the
* lifecycle and removes the file at run end, so every path gets it — including
* the e2e host, which builds no task stream.
*/

import fs from 'fs';
import path from 'path';
import { getUI } from '@ui';
import {
Expand Down Expand Up @@ -36,3 +38,13 @@ export function startAuditLedgerWatcher(
},
);
}

/** The program declared the ledger, so it removes it, agent `rm` step or not. */
export function removeAuditLedger(installDir: string, file: string): void {
const target = path.join(installDir, file);
try {
fs.rmSync(target, { force: true });
} catch (error) {
logToFile(`[audit-ledger] could not remove ${target}: ${String(error)}`);
}
}
2 changes: 1 addition & 1 deletion src/lib/programs/program-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export interface ProgramConfig {
* stale or unrelated `.posthog-events.json` file.
*/
eventPlanFile?: string;
/** Audit ledger to mirror into the session, relative to `installDir`. */
/** Audit ledger to mirror into the session and delete at run end, relative to `installDir`. */
auditLedgerFile?: string;
/**
* Channel the task stream publishes this run under, when it differs from the
Expand Down
20 changes: 15 additions & 5 deletions src/lib/programs/run-agent-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ import { FRAMEWORK_REGISTRY } from '@lib/registry';
import { postAuthGateSteps, type ProgramConfig } from './program-step';
import { authenticate, refreshAccessTokenIfNeeded } from './authenticate';
import { maybeStampAiSdkDetected } from './posthog-integration/detect';
import { startAuditLedgerWatcher } from './audit/ledger-watcher';
import {
removeAuditLedger,
startAuditLedgerWatcher,
} from './audit/ledger-watcher';

/**
* Resolve a ProgramConfig's agent run definition and execute the pipeline.
Expand All @@ -78,10 +81,17 @@ export async function runProgramAgent(

// Before `run()` resolves: an audit seeds the ledger from inside its recipe,
// and a watcher started later would ignore that write as pre-existing.
const ledger = programConfig.auditLedgerFile
? startAuditLedgerWatcher(session.installDir, programConfig.auditLedgerFile)
const ledgerFile = programConfig.auditLedgerFile;
const ledger = ledgerFile
? startAuditLedgerWatcher(session.installDir, ledgerFile)
: null;
if (ledger) registerCleanup(() => ledger.stop());
const releaseLedger = () => {
// Read a last write the watch debounce hasn't picked up before stopping.
ledger?.refresh();
ledger?.stop();
if (ledgerFile) removeAuditLedger(session.installDir, ledgerFile);
};
if (ledger) registerCleanup(releaseLedger);

try {
const runDef =
Expand All @@ -91,7 +101,7 @@ export async function runProgramAgent(

await runProgram(session, runDef, programConfig, options.composed ?? false);
} finally {
ledger?.stop();
releaseLedger();
}
}

Expand Down
Loading