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
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"qs": ">=6.15.2"
}
},
"bundleBudgetKB": 596,
"bundleBudgetKB": 598,
"scripts": {
"build": "tsup && node scripts/check-bundle-size.mjs",
"build:check-size": "node scripts/check-bundle-size.mjs",
Expand Down
3 changes: 3 additions & 0 deletions cli/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
// 596 was set when `by_agent` learned to tell a main thread from a tool that never
// names an agent: measured 592.0 -> 593.8 KB across two changes, the flow axis's own
// tool-stated row included. Same 2.2 KB headroom the raise before it left.
// 598 was set when the journal reader began reading the schema a journal states it was
// written under, and the diagnostic gained the reason for refusing one: measured
// 594.3 -> 595.8 KB. Same 2.2 KB headroom as the two raises before it.
const budgetKB = pkg.bundleBudgetKB ?? 500;
const budgetBytes = budgetKB * 1024;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export class DiagnoseTelemetryUseCase {
hookTrust,
recorderDeclared: recorderDeclaration.declared,
recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0,
foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(),
};
}

Expand Down
36 changes: 33 additions & 3 deletions cli/src/domain/models/telemetry-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export type NoRunFileReason =
| "recorder-declared-nowhere"
| "recorder-declared-not-yet-fired"
| "recorder-declaration-unreadable"
| "anchorless-run-file";
| "anchorless-run-file"
| "journal-in-another-schema";

export type TelemetryClaimReason =
| "session-anchored"
Expand Down Expand Up @@ -132,6 +133,12 @@ export interface TelemetryEvidence {
* `recorderDeclared` is `true`: a declaration found at one readable location is real
* regardless of what else could not be read. */
readonly recorderDeclarationReadable: boolean;
/** The schema stated by every run file the journal reader refused, from
* `RunJournalReader.listForeignSchemas`. Carried separately from `journals` because a
* refused file is absent from that list while being present on disk: without this, the
* one fact actually known about it — that it states a schema this build does not read —
* is invisible, and the claim below falls through to a branch that is false about it. */
readonly foreignSchemaVersions: readonly number[];
}

function sessionJournalsOf(
Expand Down Expand Up @@ -243,14 +250,36 @@ function anchorlessRunFileClaim(runsDirLabel: string, fileCount: number): Teleme
};
}

// The schema a journal states is the writer's own statement about its shape, so a build
// that does not read that schema knows exactly one thing about the file: not what its lines
// mean. Ahead of `anchorlessRunFileClaim` for that reason — "none carry a readable
// session_start" is a claim about the file's contents, which is the claim this build has
// just said it cannot make.
function foreignSchemaClaim(runsDirLabel: string, stated: readonly number[]): TelemetryClaim {
const versions = [...new Set(stated)].sort((left, right) => left - right).join(", ");
return {
claim: "hook-fired",
verdict: "fail",
reason: "journal-in-another-schema",
detail:
`${stated.length} run file(s) in ${runsDirLabel} written under a schema this build does ` +
`not read (${versions}) — a journal from another version of the plugin, never a hook ` +
"that did not fire",
};
}

function noRunFileClaim(
runsDirLabel: string,
hookTrust: TelemetryCodexHookTrust | undefined,
recorderDeclared: boolean,
recorderDeclarationReadable: boolean,
anchorlessFileCount: number
anchorlessFileCount: number,
foreignSchemaVersions: readonly number[]
): TelemetryClaim {
if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust);
if (foreignSchemaVersions.length > 0) {
return foreignSchemaClaim(runsDirLabel, foreignSchemaVersions);
}
if (anchorlessFileCount > 0) return anchorlessRunFileClaim(runsDirLabel, anchorlessFileCount);
if (!recorderDeclarationReadable) return recorderDeclarationUnreadableClaim(runsDirLabel);
if (recorderDeclared) return recorderDeclaredNotYetFiredClaim(runsDirLabel);
Expand Down Expand Up @@ -308,7 +337,8 @@ function noSessionJournalClaim(evidence: TelemetryEvidence): TelemetryClaim {
hookTrust,
evidence.recorderDeclared,
evidence.recorderDeclarationReadable,
journals.length
journals.length,
evidence.foreignSchemaVersions
);
}

Expand Down
13 changes: 13 additions & 0 deletions cli/src/domain/ports/run-journal-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export type RunJournalBoundary = RunJournalStepStart | RunJournalTurnEnd | RunJo
export interface RunJournalSessionStart {
readonly type: "session_start";
readonly at: string;
/** The schema the hook stamped this journal with, absent for a journal written before
* this reader looked at the field. Read and carried, never derived: which schema a file
* was written under is the writer's statement about it, and a reader that infers one
* from the shapes it happens to recognise is exactly the silent misreading the field
* exists to prevent. */
readonly schema_version?: number;
readonly run_id: string;
readonly tool: string;
readonly vendor_id: string;
Expand Down Expand Up @@ -143,6 +149,13 @@ export interface RunJournalReader {
* throws; a missing or unreadable runs directory answers an empty list, the same
* failure direction as `list()`. */
listRunFiles(): Promise<readonly string[]>;
/** The schema stated by every journal this reader refused to read, one entry per file.
* `list()` drops such a journal outright — reading it would mean guessing that whatever
* lines this parser still recognises mean what they used to — and a caller shown only
* that emptiness would report a missing or torn file about one whose header it parsed
* perfectly well. Empty is the ordinary answer: every journal on disk states the schema
* this build reads, or states none at all. Never throws, like everything else here. */
listForeignSchemas(): Promise<readonly number[]>;
}

/**
Expand Down
66 changes: 62 additions & 4 deletions cli/src/infrastructure/adapters/run-journal-reader-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import type {
import { isBareFileName } from "../confined-file-name.js";
import { repositoryRootAbove } from "../repository-root.js";

/**
* The one schema this reader knows how to read, mirroring `record.cjs`'s own
* `SCHEMA_VERSION` — the same kind of mirror `sanitizePathSegment` above is, and pinned the
* same way: `run-journal-reader-adapter.integration.test.ts` compares this against the
* hook's own exported constant rather than against a second copy of the number.
*
* Version 1 was a mutable record, not this append-only line log, so its lines are another
* shape entirely; a later version can change any line's shape the same way. A journal
* stating either is refused rather than read, since reading it would mean guessing that
* whatever lines this parser still recognises mean what they used to.
*/
export const READABLE_JOURNAL_SCHEMA_VERSION = 2;

const ULID_LENGTH = 26; // encodeTime(10) + encodeRandom(16), matching record.cjs's own ULID_LENGTH.
const RUN_FILE_EXTENSION = ".jsonl";

Expand Down Expand Up @@ -41,6 +54,21 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

/** Whether a journal says outright that it was written under some other schema. Absence is
* never that statement: every journal on disk before this reader looked at the field
* carries none, and refusing those would drop attribution this reader has always given —
* "an unknown is never a zero", applied to the reader rather than to a figure. A value that
* is not a finite number is read as absent for the same reason, since a torn or hand-edited
* field states nothing either. */
function statesAnotherSchema(session: RunJournalSessionStart | undefined): boolean {
const stated = session?.schema_version;
return stated !== undefined && stated !== READABLE_JOURNAL_SCHEMA_VERSION;
}

interface RawJournalLine {
readonly type?: unknown;
readonly at?: unknown;
Expand All @@ -55,6 +83,7 @@ interface RawJournalLine {
readonly worktree_repo_id?: unknown;
readonly path?: unknown;
readonly plugin_version?: unknown;
readonly schema_version?: unknown;
}

function parseLine(line: string): RawJournalLine | null {
Expand Down Expand Up @@ -110,15 +139,27 @@ function parseSessionStart(parsed: RawJournalLine): RunJournalSessionStart | nul
if (at === undefined || runId === undefined || tool === undefined || vendorId === undefined) {
return null;
}
const projectId = asString(parsed.project_id);
const projectRemote = asString(parsed.project_remote);
const pluginVersion = asString(parsed.plugin_version);
return {
type: "session_start",
at,
run_id: runId,
tool,
vendor_id: vendorId,
...headerExtras(parsed),
};
}

/** Every header field a journal may state and may omit — each absent rather than defaulted,
* the same rule `parseWorktree` above already follows: a field the writer left out is one
* this reader has nothing to say about, and a default would be an answer nobody wrote. Split
* out of `parseSessionStart` so that function stays under the line-count limit. */
function headerExtras(parsed: RawJournalLine): Partial<RunJournalSessionStart> {
const projectId = asString(parsed.project_id);
const projectRemote = asString(parsed.project_remote);
const pluginVersion = asString(parsed.plugin_version);
const schemaVersion = asNumber(parsed.schema_version);
return {
...(schemaVersion === undefined ? {} : { schema_version: schemaVersion }),
...(projectId === undefined ? {} : { project_id: projectId }),
...(projectRemote === undefined ? {} : { project_remote: projectRemote }),
...parseWorktree(parsed),
Expand Down Expand Up @@ -222,6 +263,17 @@ export class RunJournalReaderAdapter implements RunJournalStore {
return journals;
}

async listForeignSchemas(): Promise<readonly number[]> {
const stated: number[] = [];
for (const fileName of await this.listRunFiles()) {
const collector = await this.collect(join(this.runsDir, fileName));
const version = collector?.session?.schema_version;
if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION)
stated.push(version);
}
return stated;
}

async listRunFiles(): Promise<readonly string[]> {
try {
const entries = await readdir(this.runsDir);
Expand Down Expand Up @@ -257,7 +309,7 @@ export class RunJournalReaderAdapter implements RunJournalStore {
return match ? join(dir, match) : null;
}

private async readJournal(filePath: string): Promise<RunJournal | null> {
private async collect(filePath: string): Promise<JournalCollector | null> {
let content: string;
try {
content = await readFile(filePath, "utf8");
Expand All @@ -269,6 +321,12 @@ export class RunJournalReaderAdapter implements RunJournalStore {
const parsed = parseLine(line);
if (parsed) classifyLine(collector, parsed);
}
return collector;
}

private async readJournal(filePath: string): Promise<RunJournal | null> {
const collector = await this.collect(filePath);
if (!collector || statesAnotherSchema(collector.session)) return null;
const { boundaries, filesWritten, taskDeclarations, session } = collector;
return { boundaries, filesWritten, taskDeclarations, ...(session ? { session } : {}) };
}
Expand Down
40 changes: 40 additions & 0 deletions cli/tests/domain/models/telemetry-claim.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ function evidence(overrides: Partial<TelemetryEvidence> = {}): TelemetryEvidence
// Readable by default — a clean machine where the declaration itself was never in
// question. A test for the "could not be read" branch sets this to `false` explicitly.
recorderDeclarationReadable: true,
// Empty by default: every journal on disk states the schema this build reads, or states
// none. A test for the disagreement sets it explicitly.
foreignSchemaVersions: [],
...overrides,
};
}
Expand Down Expand Up @@ -104,6 +107,38 @@ describe("diagnoseTelemetryClaims — hook fired", () => {
expect(hookFired?.detail).not.toMatch(/declared nowhere/u);
});

// A journal the reader refused for stating a schema it does not read leaves `journals`
// empty, which every other branch here reads as "no run file". Two of them would then be
// outright false about a file that demonstrably exists and whose header parsed perfectly:
// "declared nowhere" claims no file, and "none carry a readable session_start" blames a
// torn write. The version disagreement is the fact that is actually known.
it("names a journal written under another schema, never a torn write or a missing file", () => {
const result = diagnoseTelemetryClaims(
evidence({ currentSessionId: "s-1", foreignSchemaVersions: [3] })
);
const hookFired = claim(result, "hook-fired");
expect(hookFired?.verdict).toBe("fail");
expect(hookFired?.reason).toBe("journal-in-another-schema");
expect(hookFired?.detail).toContain("3");
expect(hookFired?.detail).not.toMatch(/declared nowhere/u);
expect(hookFired?.detail).not.toMatch(/none carry a readable session_start/u);
});

// Ahead of the anchorless reading, deliberately: a build that cannot read a journal's
// schema cannot tell whether its session_start is missing or merely shaped differently,
// so blaming a torn write would be asserting what it just said it cannot see.
it("prefers the schema disagreement over an anchorless file when both are present", () => {
const result = diagnoseTelemetryClaims(
evidence({
currentSessionId: "s-1",
journals: [journal({ vendorId: undefined })],
foreignSchemaVersions: [3],
})
);

expect(claim(result, "hook-fired")?.reason).toBe("journal-in-another-schema");
});

it("names this session as having left no run file when an older one exists but not its own", () => {
const result = diagnoseTelemetryClaims(
evidence({
Expand Down Expand Up @@ -537,6 +572,11 @@ describe("the diagnostic skill's account of every no-run-file reason matches the
phrase: "none carry a readable session_start",
evidenceOverrides: { currentSessionId: "s-1", journals: [journal({ vendorId: undefined })] },
},
"journal-in-another-schema": {
verdict: "fail",
phrase: "written under a schema this build does not read",
evidenceOverrides: { currentSessionId: "s-1", foreignSchemaVersions: [3] },
},
};

// The bullet mentioning `phrase`, bounded by the nearest period on either side — one
Expand Down
8 changes: 8 additions & 0 deletions cli/tests/helpers/ports/in-memory-run-journal-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type { RunJournal, RunJournalStore } from "../../../src/domain/ports/run-
export class InMemoryRunJournalReader implements RunJournalStore {
readonly runsDir = "/fake/project/aidd_docs/runs";
runFileNames: string[] = [];
/** Settable directly, like `runFileNames` and for the same reason: a refused journal is
* one `list()` never returns, so it cannot be derived from what this double holds. */
foreignSchemaVersions: number[] = [];
readonly deletedFiles: string[] = [];
readonly deletedFromDirs: string[] = [];
readonly undeletable = new Set<string>();
Expand All @@ -32,6 +35,10 @@ export class InMemoryRunJournalReader implements RunJournalStore {
return this.runFileNames;
}

async listForeignSchemas(): Promise<readonly number[]> {
return this.foreignSchemaVersions;
}

async deleteRunFile(dir: string, fileName: string): Promise<void> {
if (this.undeletable.has(fileName)) throw new Error(`cannot delete ${fileName}`);
this.deletedFromDirs.push(dir);
Expand All @@ -47,5 +54,6 @@ export const NULL_RUN_JOURNAL_READER: RunJournalStore = {
read: async () => null,
list: async () => [],
listRunFiles: async () => [],
listForeignSchemas: async () => [],
deleteRunFile: async () => {},
};
4 changes: 4 additions & 0 deletions cli/tests/helpers/telemetry-journal-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const journalRepo: JournalRepoModule = createRequire(import.meta.url)(
interface JournalRecordModule {
codexSessionIdFromTranscriptPath(transcriptPath: unknown): string | undefined;
readSessionId(host: string, payload: Record<string, unknown>): string | undefined;
/** The schema the hook stamps on every `session_start` it writes. Reached rather than
* copied so the reader's own notion of which schema it can read is pinned against the
* writer's, not against a second constant that can drift from it silently. */
SCHEMA_VERSION: number;
}

export const journalRecord: JournalRecordModule = createRequire(import.meta.url)(
Expand Down
Loading