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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence
import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
import type { VersionControl } from "../domain/ports/version-control.js";
import { resolveSessionAnchor } from "../domain/session-anchor.js";
import {
anchorProjectElsewhere,
resolveSessionProject,
type SessionProject,
} from "../domain/session-project.js";
import {
attributeMoment,
buildStepIntervals,
Expand Down Expand Up @@ -230,13 +235,15 @@ export class DiagnoseTelemetryUseCase {
const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot);
const hookTrust = await this.resolveHookTrust(options.env, currentSessionId);
const toolReads = await this.gatherToolReads(journals);
const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals);
return {
journals: journals.map(toClaimJournal),
toolReads,
runsDirLabel: DEFAULT_RUNS_DIR_LABEL,
currentSessionId,
unrecognisedPayloadAt: unrecognisedPayload?.at,
hookTrust,
...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }),
recorderDeclared: recorderDeclaration.declared,
recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0,
foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(),
Expand All @@ -258,6 +265,21 @@ export class DiagnoseTelemetryUseCase {
return null;
}

/** Asked only of an anchor that left no run file here: one journalled here is ours already. */
private async resolveAnchorProject(
currentSessionId: string | undefined,
journals: readonly RunJournal[]
): Promise<string | null> {
if (currentSessionId === undefined) return null;
if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null;
const here = journals
.map(resolveSessionProject)
.filter((project): project is SessionProject => project !== null);
if (here.length === 0) return null;
const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId);
return anchorProjectElsewhere(here, stored);
}

// Only Codex gates a hook behind a trust grant it can decline in silence: a session
// running under any other tool has nothing to read here, and asks nothing of it.
private async resolveHookTrust(
Expand Down
34 changes: 34 additions & 0 deletions cli/src/contexts/telemetry/domain/session-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface SessionProject {
readonly projectField: ProjectField;
}

/** A stored record's project, and which field named it — the pair, never the value alone. */
export interface RecordProjectClaim {
readonly project_id?: string;
readonly project_field?: string;
}

/** `project_remote` wins when present: one value for every checkout of a repository, where
* `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */
export function resolveSessionProject(journal: RunJournal | null): SessionProject | null {
Expand All @@ -22,3 +28,31 @@ export function resolveSessionProject(journal: RunJournal | null): SessionProjec
}
return null;
}

function projectOfRecord(record: RecordProjectClaim): SessionProject | null {
const { project_id: projectId, project_field: projectField } = record;
if (projectId === undefined || projectId === "") return null;
if (projectField !== "project_id" && projectField !== "project_remote") return null;
return { projectId, projectField };
}

function sameProject(left: SessionProject, right: SessionProject): boolean {
return left.projectField === right.projectField && left.projectId === right.projectId;
}

/** The project the stored records put an anchored session under, when it is not one of `here`'s.
* Compares only values named by the same field — `project_id` is a directory name and
* `project_remote` a URL, so across the two a match reads as a mismatch — and one record naming
* a project in `here` settles the session as this project's, whatever the others say. */
export function anchorProjectElsewhere(
here: readonly SessionProject[],
anchorRecords: readonly RecordProjectClaim[]
): string | null {
const comparable = anchorRecords
.map(projectOfRecord)
.filter((project): project is SessionProject => project !== null)
.filter((project) => here.some((mine) => mine.projectField === project.projectField));
if (comparable.length === 0) return null;
if (comparable.some((project) => here.some((mine) => sameProject(mine, project)))) return null;
return [...new Set(comparable.map((project) => project.projectId))].join(", ");
}
27 changes: 25 additions & 2 deletions cli/src/contexts/telemetry/domain/telemetry-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type TelemetryClaimReason =
| NoRunFileReason
| "unrecognised-payload"
| "session-left-no-run-file"
| "anchor-in-another-project"
| "no-session-anchor"
| "turn-closed"
| "only-session-start"
Expand Down Expand Up @@ -88,6 +89,10 @@ export interface TelemetryEvidence {
readonly currentSessionId?: string;
readonly unrecognisedPayloadAt?: string;
readonly hookTrust?: TelemetryCodexHookTrust;
/** The project the stored records put the anchored session under, present only when that is
* decidably not this one: an anchor is inherited by any process nested inside a session,
* whatever directory it runs in. */
readonly anchorInAnotherProject?: string;
/** Whether the recorder is declared anywhere this build checks — read the same way
* `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof
* the hook will fire: a declaration can be silently dropped. */
Expand Down Expand Up @@ -256,13 +261,30 @@ function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: strin
};
}

function anotherProjectClaim(project: string, latest: string): TelemetryClaim {
return {
claim: "hook-fired",
verdict: "unknown",
reason: "anchor-in-another-project",
detail:
`this session belongs to ${project}, not to this project — its stored figures name that ` +
`project, and nothing here is evidence about its hook. The newest run file here is from ` +
latest,
};
}

function sessionAnchoredClaim(
journals: readonly TelemetryClaimJournal[],
latest: string,
currentSessionId: string,
hookTrust: TelemetryCodexHookTrust | undefined
hookTrust: TelemetryCodexHookTrust | undefined,
anchorInAnotherProject: string | undefined
): TelemetryClaim {
if (!firedForSession(journals, currentSessionId)) {
// Before the trust gate: local trust state says nothing about another project's session.
if (anchorInAnotherProject !== undefined) {
return anotherProjectClaim(anchorInAnotherProject, latest);
}
if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust);
return {
claim: "hook-fired",
Expand Down Expand Up @@ -301,7 +323,8 @@ function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim {
sessionJournals,
latest,
evidence.currentSessionId,
evidence.hookTrust
evidence.hookTrust,
evidence.anchorInAnotherProject
);
}

Expand Down
4 changes: 2 additions & 2 deletions cli/tests/architecture/comments.arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const EXTERNAL_REFERENCE: readonly { readonly name: string; readonly pattern: Re
{ name: "pull request", pattern: /\bpull request\b|\bPR\s*#?\d/i },
];

/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit. */
const MAX_COMMENT_LINES = { src: 4471, tests: 2987 };
/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit; src/ 4471 to 4481, why an inherited session anchor is decidable and why comparing two namings of a project is not. */
const MAX_COMMENT_LINES = { src: 4481, tests: 2987 };

function testFiles(): string[] {
const out: string[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,23 @@ function buildUseCase(options: {
hookTrustReader?: StubHookTrustReader;
manifestRepo?: ManifestRepository;
hostRegistries?: ReadonlyMap<AiToolId, HostPluginRegistryReader>;
sink?: InMemoryTelemetrySink;
}) {
const evidence = options.evidence ?? new StubEvidenceReader();
const journalReader = new InMemoryRunJournalReader();
for (const [index, journal] of (options.journals ?? []).entries()) {
journalReader.set(journal.session?.vendor_id ?? `no-session-${index}`, journal);
}
const hookTrustReader = options.hookTrustReader ?? new StubHookTrustReader();
const sink = options.sink ?? new InMemoryTelemetrySink();
const useCase = new DiagnoseTelemetryUseCase(
evidence,
versionControl(options.isRepository ?? true),
journalReader,
options.readers ?? new Map(),
hookTrustReader,
new InMemoryPersonIdentityStore(),
new InMemoryTelemetrySink(),
sink,
new FakeCurrentVersion("9.9.9-check"),
installedPluginsFromManifest(
options.manifestRepo ?? {
Expand All @@ -123,7 +125,7 @@ function buildUseCase(options: {
),
options.hostRegistries ?? new Map()
);
return { useCase, evidence, hookTrustReader, journalReader };
return { useCase, evidence, hookTrustReader, journalReader, sink };
}

function runOptions(env: NodeJS.ProcessEnv = {}) {
Expand Down Expand Up @@ -585,6 +587,112 @@ function useCaseOverIdentity(store: InMemoryPersonIdentityStore): DiagnoseTeleme
);
}

const HERE = "git@github.com:acme/widgets.git";

async function sinkHolding(
vendorId: string,
projectRemote: string
): Promise<InMemoryTelemetrySink> {
const sink = new InMemoryTelemetrySink();
await sink.appendRecord(
{
sink_schema_version: 1,
kind: "request",
provenance: "local-read",
tool: "claude",
vendor_id: vendorId,
vendor_field: "session_id",
step_attribution: "unattributed",
project_id: projectRemote,
project_field: "project_remote",
},
new Date("2026-08-20T09:01:00Z")
);
return sink;
}

function journalledHere(vendorId: string): RunJournal {
const session = sessionStart(vendorId);
return journalOf(session && { ...session, project_remote: HERE });
}

describe("DiagnoseTelemetryUseCase — an anchor inherited from another project", () => {
it("names the project the stored figures put the anchored session under", async () => {
const { useCase } = buildUseCase({
journals: [journalledHere("s-old")],
sink: await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"),
});

const result = claimsOf(
await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" }))
);

const hookFired = result.claims.find((claim) => claim.claim === "hook-fired");
expect(hookFired?.verdict).toBe("unknown");
expect(hookFired?.detail).toContain("git@github.com:acme/other.git");
});

it("still fails an anchor the stored figures put in this very project", async () => {
const { useCase } = buildUseCase({
journals: [journalledHere("s-old")],
sink: await sinkHolding("s-mine", HERE),
});

const result = claimsOf(
await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-mine" }))
);

expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe(
"session-left-no-run-file"
);
});

it("asks the sink nothing about a session this project journalled itself", async () => {
const sink = await sinkHolding("s-1", "git@github.com:acme/other.git");
const { useCase } = buildUseCase({
journals: [journalOf(undefined), journalledHere("s-old"), journalledHere("s-1")],
sink,
});

const result = claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })));

expect(result.claims.find((claim) => claim.claim === "hook-fired")?.verdict).toBe("ok");
expect(sink.vendorsRead).toStrictEqual([]);
});

it("asks the sink nothing when no anchor names a session at all", async () => {
const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git");
const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink });

claimsOf(await useCase.execute(runOptions()));

expect(sink.vendorsRead).toStrictEqual([]);
});

it("asks the sink nothing when no run file here names a project to compare against", async () => {
const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git");
const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-old"))], sink });

const result = claimsOf(
await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" }))
);

expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe(
"session-left-no-run-file"
);
expect(sink.vendorsRead).toStrictEqual([]);
});

it("asks the sink about the anchor alone", async () => {
const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git");
const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink });

claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })));

expect(sink.vendorsRead).toStrictEqual(["s-elsewhere"]);
});
});

describe("DiagnoseTelemetryUseCase — the setup it prints", () => {
it("names the sink's own root as where records land", async () => {
const { useCase } = buildUseCase({});
Expand Down
Loading
Loading