From ecc127300b17030d2b0e75a140ae296f6b119ec0 Mon Sep 17 00:00:00 2001 From: Erick Jaimez <281780387+ejaimez14@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:05:19 -0700 Subject: [PATCH] feat(tracing): Share Turn Attribution and Closure Helpers --- .oxlintrc.json | 6 + dist/tracing/lifecycle/closure.d.ts | 9 + dist/tracing/lifecycle/closure.d.ts.map | 1 + dist/tracing/lifecycle/closure.js | 168 +++++++++++++ dist/tracing/lifecycle/closure.js.map | 1 + dist/tracing/settlement/index.d.ts | 3 + dist/tracing/settlement/index.d.ts.map | 1 + dist/tracing/settlement/index.js | 2 + dist/tracing/settlement/index.js.map | 1 + dist/tracing/settlement/settlement.d.ts | 7 + dist/tracing/settlement/settlement.d.ts.map | 1 + dist/tracing/settlement/settlement.js | 49 ++++ dist/tracing/settlement/settlement.js.map | 1 + package.json | 4 + src/test-support/tracing-settlement.ts | 63 +++++ src/tracing/lifecycle/closure.ts | 250 ++++++++++++++++++++ src/tracing/settlement/index.ts | 8 + src/tracing/settlement/settlement.test.ts | 88 +++++++ src/tracing/settlement/settlement.ts | 65 +++++ 19 files changed, 728 insertions(+) create mode 100644 dist/tracing/lifecycle/closure.d.ts create mode 100644 dist/tracing/lifecycle/closure.d.ts.map create mode 100644 dist/tracing/lifecycle/closure.js create mode 100644 dist/tracing/lifecycle/closure.js.map create mode 100644 dist/tracing/settlement/index.d.ts create mode 100644 dist/tracing/settlement/index.d.ts.map create mode 100644 dist/tracing/settlement/index.js create mode 100644 dist/tracing/settlement/index.js.map create mode 100644 dist/tracing/settlement/settlement.d.ts create mode 100644 dist/tracing/settlement/settlement.d.ts.map create mode 100644 dist/tracing/settlement/settlement.js create mode 100644 dist/tracing/settlement/settlement.js.map create mode 100644 src/test-support/tracing-settlement.ts create mode 100644 src/tracing/lifecycle/closure.ts create mode 100644 src/tracing/settlement/index.ts create mode 100644 src/tracing/settlement/settlement.test.ts create mode 100644 src/tracing/settlement/settlement.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index adf3d13..2589373 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,12 @@ "no-new": "off", "unicorn/no-array-reverse": "off" } + }, + { + "files": ["src/tracing/settlement/settlement.ts"], + "rules": { + "unicorn/no-array-sort": "off" + } } ] } diff --git a/dist/tracing/lifecycle/closure.d.ts b/dist/tracing/lifecycle/closure.d.ts new file mode 100644 index 0000000..55e8aa6 --- /dev/null +++ b/dist/tracing/lifecycle/closure.d.ts @@ -0,0 +1,9 @@ +import type { CodingAgentIntegration } from "../../metadata/models.js"; +import type { StoredCapture } from "../../storage/capture/models.js"; +import type { PreparedRunSubmission } from "../upload/models.js"; +import type { LifecycleEndTimeWithholdingInput } from "./models.js"; +export declare function deriveAttributionReadiness(value: unknown, integration: CodingAgentIntegration): boolean; +export declare function storedAttributionReadiness(record: StoredCapture, integration: CodingAgentIntegration): boolean; +export declare function indexCaptureSources(sources: readonly StoredCapture[]): ReadonlyMap; +export declare function withholdUnresolvedEndTime(input: LifecycleEndTimeWithholdingInput): Promise; +//# sourceMappingURL=closure.d.ts.map \ No newline at end of file diff --git a/dist/tracing/lifecycle/closure.d.ts.map b/dist/tracing/lifecycle/closure.d.ts.map new file mode 100644 index 0000000..58d6781 --- /dev/null +++ b/dist/tracing/lifecycle/closure.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"closure.d.ts","sourceRoot":"","sources":["../../../src/tracing/lifecycle/closure.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,KAAK,EAGV,aAAa,EACd,MAAM,iCAAiC,CAAC;AAUzC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAOjE,OAAO,KAAK,EAAE,gCAAgC,EAAE,MAAM,aAAa,CAAC;AAEpE,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,OAAO,EACd,WAAW,EAAE,sBAAsB,GAClC,OAAO,CAST;AAED,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,aAAa,EACrB,WAAW,EAAE,sBAAsB,GAClC,OAAO,CAkBT;AAED,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,SAAS,aAAa,EAAE,GAChC,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,CAEpC;AAWD,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,gCAAgC,GACtC,OAAO,CAAC,qBAAqB,CAAC,CA0ChC"} \ No newline at end of file diff --git a/dist/tracing/lifecycle/closure.js b/dist/tracing/lifecycle/closure.js new file mode 100644 index 0000000..0726471 --- /dev/null +++ b/dist/tracing/lifecycle/closure.js @@ -0,0 +1,168 @@ +import { buildCodingAgentMetadata, prepareCodingAgentMetadataProvenance, } from "../../metadata/index.js"; +import { ownDataField, requireBoolean, requireNonBlankString, requireOwnDataField, requirePlainRecord, requireStringArray, } from "../../utils/validation/objects.js"; +import { LIFECYCLE_ATTRIBUTION_READY_FIELD, LIFECYCLE_PATCH_EVENT_KIND, LIFECYCLE_POST_EVENT_KIND, LIFECYCLE_TURN_CLOSURE_STATES, } from "./constants.js"; +export function deriveAttributionReadiness(value, integration) { + const source = requirePlainRecord(value, "Prepared run submission"); + const metadata = prepareCodingAgentMetadataProvenance(requireOwnDataField(source, "metadata"), integration, "full"); + if (metadata.status === "deferred") + return false; + return attributionMetadataReady(buildCodingAgentMetadata(metadata.value)); +} +export function storedAttributionReadiness(record, integration) { + const evidence = requirePlainRecord(record.turnEvidence, "Stored turn evidence"); + const readiness = ownDataField(evidence, LIFECYCLE_ATTRIBUTION_READY_FIELD); + if (readiness.present) { + return requireBoolean(readiness.value, `Stored turn evidence ${LIFECYCLE_ATTRIBUTION_READY_FIELD}`); + } + const payload = requirePlainRecord(record.normalizedPayload, "Stored run payload"); + if (requireOwnDataField(payload, "privacyMode") !== "full") + return false; + const metadata = prepareCodingAgentMetadataProvenance(record.metadataProvenance, integration, "full"); + if (metadata.status === "deferred") + return false; + return attributionMetadataReady(buildCodingAgentMetadata(metadata.value)); +} +export function indexCaptureSources(sources) { + return new Map(sources.map((source) => [captureScopeKey(captureScope(source)), source])); +} +function attributionMetadataReady(projected) { + return (typeof projected["repository_name"] === "string" && + projected["repository_name"].length > 0 && + typeof projected["ls_attribution_identifier"] === "string" && + projected["ls_attribution_identifier"].length > 0); +} +export async function withholdUnresolvedEndTime(input) { + const { record, submission, sourceSnapshot, sourceByScope, integration, destinations, readOutcome, } = input; + if (record.eventKind !== LIFECYCLE_POST_EVENT_KIND && + record.eventKind !== LIFECYCLE_PATCH_EVENT_KIND) { + return submission; + } + const evidence = requirePlainRecord(record.turnEvidence, "Stored turn evidence"); + const attributionReady = storedAttributionReadiness(record, integration); + const closureState = requireOwnDataField(evidence, "closureState"); + if (typeof closureState !== "string" || + !LIFECYCLE_TURN_CLOSURE_STATES.includes(closureState)) { + throw new TypeError("Stored turn evidence has an invalid closure state"); + } + const runType = submission.metadata.runType; + if (runType !== "tool" && runType !== "root") + return submission; + const hasCurrentEndTime = submission.operation === "post" + ? submission.run.end_time !== undefined + : submission.patch.fields.includes("end_time"); + const hasEndTime = hasCurrentEndTime || hasPriorEndTime(record, sourceByScope); + if (!hasEndTime || attributionReady) + return submission; + if (runType === "root" && + !(await hasMissingChildReceipts(record, evidence, sourceSnapshot, destinations, readOutcome))) { + return submission; + } + return removeOutgoingEndTime(submission); +} +function hasPriorEndTime(record, sourceByScope) { + const pending = [...(record.dependencies ?? [])]; + const visited = new Set(); + while (pending.length > 0) { + const scope = pending.pop(); + const key = captureScopeKey(scope); + if (visited.has(key)) + continue; + visited.add(key); + const previous = sourceByScope.get(key); + if (previous === undefined) + continue; + if (previous.runId === record.runId && + (previous.eventKind === LIFECYCLE_POST_EVENT_KIND || + previous.eventKind === LIFECYCLE_PATCH_EVENT_KIND) && + recordHasEndTime(previous)) { + return true; + } + pending.push(...(previous.dependencies ?? [])); + } + return false; +} +function recordHasEndTime(record) { + const payload = requirePlainRecord(record.normalizedPayload, "Stored run payload"); + if (payload["operation"] === "post") { + const run = requirePlainRecord(requireOwnDataField(payload, "run"), "Stored run snapshot"); + const endTime = ownDataField(run, "end_time"); + return endTime.present && endTime.value !== undefined; + } + if (payload["operation"] !== "patch") + return false; + const patch = requirePlainRecord(requireOwnDataField(payload, "patch"), "Stored run patch"); + const fields = requireStringArray(requireOwnDataField(patch, "fields"), "Patch fields"); + if (!fields.includes("end_time")) + return false; + const values = requirePlainRecord(requireOwnDataField(patch, "values"), "Patch values"); + const endTime = ownDataField(values, "end_time"); + return endTime.present && endTime.value !== undefined; +} +async function hasMissingChildReceipts(record, evidence, sourceSnapshot, destinations, readOutcome) { + const childRunIds = requireStringArray(requireOwnDataField(evidence, "childRunIds"), "Child run IDs").map((runId) => requireNonBlankString(runId, "Child run ID")); + const children = childRunIds.filter((runId) => runId !== record.runId); + if (children.length === 0) + return false; + for (const childRunId of children) { + const childCaptures = sourceSnapshot.filter((source) => source.runId === childRunId && + source.destinationFingerprint === record.destinationFingerprint && + source.eventKind === LIFECYCLE_POST_EVENT_KIND); + if (childCaptures.length === 0) + return true; + for (const child of childCaptures) { + const scope = captureScope(child); + for (const destination of destinations) { + const outcome = await readOutcome(scope, destination.id); + if (outcome.status === "failed") + throw new Error(`Could not read child delivery receipt: ${outcome.code}`); + if (outcome.status !== "settled" || outcome.receipt.outcome !== "delivered") + return true; + } + } + } + return false; +} +function removeOutgoingEndTime(submission) { + const privacyContext = submission.privacyMode !== "metadata" + ? undefined + : submission.operation === "post" + ? { + status: submission.run.error !== undefined || submission.privacyContext?.status === "error" + ? "error" + : "running", + } + : { + status: submission.privacyContext.status === "error" + ? "error" + : "running", + }; + if (submission.operation === "post") { + const run = { ...submission.run }; + delete run.end_time; + return { + ...submission, + run, + ...(privacyContext === undefined ? {} : { privacyContext }), + }; + } + const fields = submission.patch.fields.filter((field) => field !== "end_time"); + const values = { ...submission.patch.values }; + delete values.end_time; + return { + ...submission, + patch: { fields, values }, + ...(privacyContext === undefined ? {} : { privacyContext }), + }; +} +function captureScope(record) { + return { + integration: record.integration, + sessionId: record.sessionId, + turnId: record.turnId, + eventId: record.eventId, + }; +} +function captureScopeKey(scope) { + return JSON.stringify([scope.integration, scope.sessionId, scope.turnId, scope.eventId]); +} +//# sourceMappingURL=closure.js.map \ No newline at end of file diff --git a/dist/tracing/lifecycle/closure.js.map b/dist/tracing/lifecycle/closure.js.map new file mode 100644 index 0000000..8f12ced --- /dev/null +++ b/dist/tracing/lifecycle/closure.js.map @@ -0,0 +1 @@ +{"version":3,"file":"closure.js","sourceRoot":"","sources":["../../../src/tracing/lifecycle/closure.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/tracing/settlement/index.d.ts b/dist/tracing/settlement/index.d.ts new file mode 100644 index 0000000..4ae140d --- /dev/null +++ b/dist/tracing/settlement/index.d.ts @@ -0,0 +1,3 @@ +export { attributionOf, metadataAfterFill, namesARepository, settledTurnMetadata, turnAttribution, } from "./settlement.js"; +export type { Attribution, RecordedRun, TurnRecord } from "./models.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/tracing/settlement/index.d.ts.map b/dist/tracing/settlement/index.d.ts.map new file mode 100644 index 0000000..6336dbf --- /dev/null +++ b/dist/tracing/settlement/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/tracing/settlement/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,EACnB,eAAe,GAChB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC"} \ No newline at end of file diff --git a/dist/tracing/settlement/index.js b/dist/tracing/settlement/index.js new file mode 100644 index 0000000..474bfd8 --- /dev/null +++ b/dist/tracing/settlement/index.js @@ -0,0 +1,2 @@ +export { attributionOf, metadataAfterFill, namesARepository, settledTurnMetadata, turnAttribution, } from "./settlement.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/tracing/settlement/index.js.map b/dist/tracing/settlement/index.js.map new file mode 100644 index 0000000..54033d2 --- /dev/null +++ b/dist/tracing/settlement/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/tracing/settlement/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,EACnB,eAAe,GAChB,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/dist/tracing/settlement/settlement.d.ts b/dist/tracing/settlement/settlement.d.ts new file mode 100644 index 0000000..b711165 --- /dev/null +++ b/dist/tracing/settlement/settlement.d.ts @@ -0,0 +1,7 @@ +import type { Attribution, RecordedRun, TurnRecord } from "./models.js"; +export declare function attributionOf(metadata: Record | undefined): Attribution; +export declare const namesARepository: (carried: Attribution) => boolean; +export declare function turnAttribution(record: TurnRecord): Attribution | undefined; +export declare function metadataAfterFill(run: RecordedRun, filled: Attribution): Record | undefined; +export declare function settledTurnMetadata(base: Record | undefined, record: TurnRecord | undefined): Record | undefined; +//# sourceMappingURL=settlement.d.ts.map \ No newline at end of file diff --git a/dist/tracing/settlement/settlement.d.ts.map b/dist/tracing/settlement/settlement.d.ts.map new file mode 100644 index 0000000..af3b1d5 --- /dev/null +++ b/dist/tracing/settlement/settlement.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../../../src/tracing/settlement/settlement.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAExE,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,WAAW,CAOxF;AAED,eAAO,MAAM,gBAAgB,YAAa,WAAW,KAAG,OACZ,CAAC;AAE7C,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,GAAG,SAAS,CAoB3E;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,WAAW,EAChB,MAAM,EAAE,WAAW,GAClB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CASrC;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,MAAM,EAAE,UAAU,GAAG,SAAS,GAC7B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAMrC"} \ No newline at end of file diff --git a/dist/tracing/settlement/settlement.js b/dist/tracing/settlement/settlement.js new file mode 100644 index 0000000..de31db7 --- /dev/null +++ b/dist/tracing/settlement/settlement.js @@ -0,0 +1,49 @@ +import { ATTRIBUTION_IDENTIFIER_KEY, REPOSITORY_METADATA_KEYS, REPOSITORY_NAME_KEY, } from "./constants.js"; +export function attributionOf(metadata) { + const carried = {}; + for (const key of REPOSITORY_METADATA_KEYS) { + const value = metadata?.[key]; + if (typeof value === "string" && value.length > 0) + carried[key] = value; + } + return carried; +} +export const namesARepository = (carried) => carried[REPOSITORY_NAME_KEY] !== undefined; +export function turnAttribution(record) { + const root = attributionOf(record.root?.metadata); + const inToolCallOrder = [...record.children] + .sort((left, right) => (left.dotted_order < right.dotted_order ? -1 : 1)) + .map((child) => attributionOf(child.metadata)); + const source = namesARepository(root) + ? root + : inToolCallOrder.find((carried) => namesARepository(carried)); + const knowsWhoWorkedInSource = (carried) => carried[ATTRIBUTION_IDENTIFIER_KEY] !== undefined && + carried[REPOSITORY_NAME_KEY] === source?.[REPOSITORY_NAME_KEY]; + const author = root[ATTRIBUTION_IDENTIFIER_KEY] ?? + source?.[ATTRIBUTION_IDENTIFIER_KEY] ?? + inToolCallOrder.find(knowsWhoWorkedInSource)?.[ATTRIBUTION_IDENTIFIER_KEY]; + const filled = { ...source }; + if (author !== undefined) + filled[ATTRIBUTION_IDENTIFIER_KEY] = author; + return Object.keys(filled).length > 0 ? filled : undefined; +} +export function metadataAfterFill(run, filled) { + const carried = attributionOf(run.metadata); + const workedOutItsOwn = namesARepository(carried) && carried[REPOSITORY_NAME_KEY] !== filled[REPOSITORY_NAME_KEY]; + if (workedOutItsOwn) + return undefined; + const missing = Object.entries(filled).filter(([key]) => carried[key] === undefined); + if (missing.length === 0) + return undefined; + return { ...run.metadata, ...Object.fromEntries(missing) }; +} +export function settledTurnMetadata(base, record) { + if (!record?.root) + return base; + const filled = turnAttribution({ ...record, root: { ...record.root, metadata: base ?? {} } }); + if (!filled) + return base; + const missing = Object.entries(filled).filter(([key]) => base?.[key] === undefined); + return missing.length === 0 ? base : { ...base, ...Object.fromEntries(missing) }; +} +//# sourceMappingURL=settlement.js.map \ No newline at end of file diff --git a/dist/tracing/settlement/settlement.js.map b/dist/tracing/settlement/settlement.js.map new file mode 100644 index 0000000..5262c5c --- /dev/null +++ b/dist/tracing/settlement/settlement.js.map @@ -0,0 +1 @@ +{"version":3,"file":"settlement.js","sourceRoot":"","sources":["../../../src/tracing/settlement/settlement.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/package.json b/package.json index c361f8c..bd5aae6 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,10 @@ "./tracing/lifecycle": { "types": "./dist/tracing/lifecycle/index.d.ts", "default": "./dist/tracing/lifecycle/index.js" + }, + "./tracing/settlement": { + "types": "./dist/tracing/settlement/index.d.ts", + "default": "./dist/tracing/settlement/index.js" } }, "publishConfig": { diff --git a/src/test-support/tracing-settlement.ts b/src/test-support/tracing-settlement.ts new file mode 100644 index 0000000..d4e9c17 --- /dev/null +++ b/src/test-support/tracing-settlement.ts @@ -0,0 +1,63 @@ +/** + * Setup for the reconcile tests: runs shaped the way a turn record holds them. + */ + +import type { RecordedRun, TurnRecord } from "../tracing/settlement/models.js"; + +export const inAlpha = { + repository_name: "acme/a", + repository_provider: "github", + repository_url: "https://github.com/acme/a", + git_branch: "trunk-a", + git_commit_sha: "aaaa", + ls_attribution_identifier: "Alpha Owner", +}; + +export const inBeta = { + repository_name: "acme/b", + repository_provider: "gitlab", + repository_url: "https://gitlab.com/acme/b", + git_branch: "trunk-b", + git_commit_sha: "bbbb", + ls_attribution_identifier: "Beta Owner", +}; + +/** A run as the record holds it, with its place in the turn given by `order`. */ +export function recorded(name: string, metadata: Record, order = 1): RecordedRun { + return { + run_id: name, + parent_run_id: "root", + trace_id: "trace", + dotted_order: `trace.2025010100000${order}000000Z${name}`, + name, + run_type: "tool", + project_name: "p", + // Recent, since the settle gives up on a turn the service would no longer accept. + start_time: new Date().toISOString(), + end_time: new Date().toISOString(), + tracing: "full", + metadata, + }; +} + +export function record( + root: Record, + children: RecordedRun[], + overrides: Partial = {}, +): TurnRecord { + return { + path: "/tmp/turn.jsonl", + origin: "account", + root: { + ...recorded("root", root), + run_id: "root", + parent_run_id: undefined, + run_type: "chain", + }, + children, + closed: true, + delivered: new Set(children.map((child) => child.run_id)), + fixed: new Set(), + ...overrides, + }; +} diff --git a/src/tracing/lifecycle/closure.ts b/src/tracing/lifecycle/closure.ts new file mode 100644 index 0000000..5bdcb82 --- /dev/null +++ b/src/tracing/lifecycle/closure.ts @@ -0,0 +1,250 @@ +import { + buildCodingAgentMetadata, + prepareCodingAgentMetadataProvenance, +} from "../../metadata/index.js"; +import type { CodingAgentIntegration } from "../../metadata/models.js"; +import type { + CaptureScope, + OutcomeReadResult, + StoredCapture, +} from "../../storage/capture/models.js"; +import { + ownDataField, + requireBoolean, + requireNonBlankString, + requireOwnDataField, + requirePlainRecord, + requireStringArray, +} from "../../utils/validation/objects.js"; +import type { DeliveryDestination } from "../delivery/models.js"; +import type { PreparedRunSubmission } from "../upload/models.js"; +import { + LIFECYCLE_ATTRIBUTION_READY_FIELD, + LIFECYCLE_PATCH_EVENT_KIND, + LIFECYCLE_POST_EVENT_KIND, + LIFECYCLE_TURN_CLOSURE_STATES, +} from "./constants.js"; +import type { LifecycleEndTimeWithholdingInput } from "./models.js"; + +export function deriveAttributionReadiness( + value: unknown, + integration: CodingAgentIntegration, +): boolean { + const source = requirePlainRecord(value, "Prepared run submission"); + const metadata = prepareCodingAgentMetadataProvenance( + requireOwnDataField(source, "metadata"), + integration, + "full", + ); + if (metadata.status === "deferred") return false; + return attributionMetadataReady(buildCodingAgentMetadata(metadata.value)); +} + +export function storedAttributionReadiness( + record: StoredCapture, + integration: CodingAgentIntegration, +): boolean { + const evidence = requirePlainRecord(record.turnEvidence, "Stored turn evidence"); + const readiness = ownDataField(evidence, LIFECYCLE_ATTRIBUTION_READY_FIELD); + if (readiness.present) { + return requireBoolean( + readiness.value, + `Stored turn evidence ${LIFECYCLE_ATTRIBUTION_READY_FIELD}`, + ); + } + const payload = requirePlainRecord(record.normalizedPayload, "Stored run payload"); + if (requireOwnDataField(payload, "privacyMode") !== "full") return false; + const metadata = prepareCodingAgentMetadataProvenance( + record.metadataProvenance, + integration, + "full", + ); + if (metadata.status === "deferred") return false; + return attributionMetadataReady(buildCodingAgentMetadata(metadata.value)); +} + +export function indexCaptureSources( + sources: readonly StoredCapture[], +): ReadonlyMap { + return new Map(sources.map((source) => [captureScopeKey(captureScope(source)), source])); +} + +function attributionMetadataReady(projected: Record): boolean { + return ( + typeof projected["repository_name"] === "string" && + projected["repository_name"].length > 0 && + typeof projected["ls_attribution_identifier"] === "string" && + projected["ls_attribution_identifier"].length > 0 + ); +} + +export async function withholdUnresolvedEndTime( + input: LifecycleEndTimeWithholdingInput, +): Promise { + const { + record, + submission, + sourceSnapshot, + sourceByScope, + integration, + destinations, + readOutcome, + } = input; + if ( + record.eventKind !== LIFECYCLE_POST_EVENT_KIND && + record.eventKind !== LIFECYCLE_PATCH_EVENT_KIND + ) { + return submission; + } + const evidence = requirePlainRecord(record.turnEvidence, "Stored turn evidence"); + const attributionReady = storedAttributionReadiness(record, integration); + const closureState = requireOwnDataField(evidence, "closureState"); + if ( + typeof closureState !== "string" || + !LIFECYCLE_TURN_CLOSURE_STATES.includes( + closureState as "open" | "provisional" | "authoritative", + ) + ) { + throw new TypeError("Stored turn evidence has an invalid closure state"); + } + const runType = submission.metadata.runType; + if (runType !== "tool" && runType !== "root") return submission; + const hasCurrentEndTime = + submission.operation === "post" + ? submission.run.end_time !== undefined + : submission.patch.fields.includes("end_time"); + const hasEndTime = hasCurrentEndTime || hasPriorEndTime(record, sourceByScope); + if (!hasEndTime || attributionReady) return submission; + if ( + runType === "root" && + !(await hasMissingChildReceipts(record, evidence, sourceSnapshot, destinations, readOutcome)) + ) { + return submission; + } + return removeOutgoingEndTime(submission); +} + +function hasPriorEndTime( + record: StoredCapture, + sourceByScope: ReadonlyMap, +): boolean { + const pending = [...(record.dependencies ?? [])]; + const visited = new Set(); + while (pending.length > 0) { + const scope = pending.pop()!; + const key = captureScopeKey(scope); + if (visited.has(key)) continue; + visited.add(key); + const previous = sourceByScope.get(key); + if (previous === undefined) continue; + if ( + previous.runId === record.runId && + (previous.eventKind === LIFECYCLE_POST_EVENT_KIND || + previous.eventKind === LIFECYCLE_PATCH_EVENT_KIND) && + recordHasEndTime(previous) + ) { + return true; + } + pending.push(...(previous.dependencies ?? [])); + } + return false; +} + +function recordHasEndTime(record: StoredCapture): boolean { + const payload = requirePlainRecord(record.normalizedPayload, "Stored run payload"); + if (payload["operation"] === "post") { + const run = requirePlainRecord(requireOwnDataField(payload, "run"), "Stored run snapshot"); + const endTime = ownDataField(run, "end_time"); + return endTime.present && endTime.value !== undefined; + } + if (payload["operation"] !== "patch") return false; + const patch = requirePlainRecord(requireOwnDataField(payload, "patch"), "Stored run patch"); + const fields = requireStringArray(requireOwnDataField(patch, "fields"), "Patch fields"); + if (!fields.includes("end_time")) return false; + const values = requirePlainRecord(requireOwnDataField(patch, "values"), "Patch values"); + const endTime = ownDataField(values, "end_time"); + return endTime.present && endTime.value !== undefined; +} + +async function hasMissingChildReceipts( + record: StoredCapture, + evidence: Record, + sourceSnapshot: readonly StoredCapture[], + destinations: readonly DeliveryDestination[], + readOutcome: (scope: CaptureScope, destination: string) => Promise, +): Promise { + const childRunIds = requireStringArray( + requireOwnDataField(evidence, "childRunIds"), + "Child run IDs", + ).map((runId) => requireNonBlankString(runId, "Child run ID")); + const children = childRunIds.filter((runId) => runId !== record.runId); + if (children.length === 0) return false; + for (const childRunId of children) { + const childCaptures = sourceSnapshot.filter( + (source) => + source.runId === childRunId && + source.destinationFingerprint === record.destinationFingerprint && + source.eventKind === LIFECYCLE_POST_EVENT_KIND, + ); + if (childCaptures.length === 0) return true; + for (const child of childCaptures) { + const scope = captureScope(child); + for (const destination of destinations) { + const outcome = await readOutcome(scope, destination.id); + if (outcome.status === "failed") + throw new Error(`Could not read child delivery receipt: ${outcome.code}`); + if (outcome.status !== "settled" || outcome.receipt.outcome !== "delivered") return true; + } + } + } + return false; +} + +function removeOutgoingEndTime(submission: PreparedRunSubmission): PreparedRunSubmission { + const privacyContext = + submission.privacyMode !== "metadata" + ? undefined + : submission.operation === "post" + ? { + status: + submission.run.error !== undefined || submission.privacyContext?.status === "error" + ? ("error" as const) + : ("running" as const), + } + : { + status: + submission.privacyContext.status === "error" + ? ("error" as const) + : ("running" as const), + }; + if (submission.operation === "post") { + const run = { ...submission.run }; + delete run.end_time; + return { + ...submission, + run, + ...(privacyContext === undefined ? {} : { privacyContext }), + }; + } + const fields = submission.patch.fields.filter((field) => field !== "end_time"); + const values = { ...submission.patch.values }; + delete values.end_time; + return { + ...submission, + patch: { fields, values }, + ...(privacyContext === undefined ? {} : { privacyContext }), + }; +} + +function captureScope(record: StoredCapture): CaptureScope { + return { + integration: record.integration, + sessionId: record.sessionId, + turnId: record.turnId, + eventId: record.eventId, + }; +} + +function captureScopeKey(scope: CaptureScope): string { + return JSON.stringify([scope.integration, scope.sessionId, scope.turnId, scope.eventId]); +} diff --git a/src/tracing/settlement/index.ts b/src/tracing/settlement/index.ts new file mode 100644 index 0000000..9653bbf --- /dev/null +++ b/src/tracing/settlement/index.ts @@ -0,0 +1,8 @@ +export { + attributionOf, + metadataAfterFill, + namesARepository, + settledTurnMetadata, + turnAttribution, +} from "./settlement.js"; +export type { Attribution, RecordedRun, TurnRecord } from "./models.js"; diff --git a/src/tracing/settlement/settlement.test.ts b/src/tracing/settlement/settlement.test.ts new file mode 100644 index 0000000..f31fa6b --- /dev/null +++ b/src/tracing/settlement/settlement.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { metadataAfterFill, turnAttribution } from "./settlement.js"; +import { inAlpha, inBeta, record, recorded } from "../../test-support/tracing-settlement.js"; + +const bare = { cwd: "/somewhere", thread_id: "s1" }; + +describe("what a turn settles on", () => { + // Catches a reconcile that ignores the repository the session already configured and + // goes looking at the tools instead, which relabels a whole turn from one stray call. + it("keeps the repository the turn's own root already carries", () => { + const turn = record({ ...bare, ...inAlpha }, [recorded("Glob", { ...bare, ...inBeta })]); + + expect(turnAttribution(turn)).toMatchObject({ repository_name: "acme/a" }); + }); + + // Catches evidence picked in the order uploads finished rather than the order the + // tools ran, which labels a turn from whichever call happened to answer first. + it("takes the repository from the earliest tool call, not the first one recorded", () => { + const turn = record(bare, [ + recorded("Edit", { ...bare, ...inAlpha }, 1), + recorded("Glob", { ...bare, ...inBeta }, 2), + ]); + turn.children.reverse(); + + expect(turnAttribution(turn)).toMatchObject({ repository_name: "acme/a" }); + }); + + // Catches an author taken from a tool working in a different repository, which + // credits one codebase's work to someone who never touched it. + it("leaves the author alone when the only one on offer worked somewhere else", () => { + const turn = record(bare, [ + recorded("Glob", { ...bare, repository_name: "acme/a" }, 1), + recorded("Edit", { ...bare, ...inBeta }, 2), + ]); + + expect(turnAttribution(turn)?.ls_attribution_identifier).toBeUndefined(); + }); + + // Catches a turn that settles on nothing because no tool reached a repository, when + // one of them still knew who was working. + it("still names the author when nothing landed in a repository", () => { + const turn = record(bare, [recorded("Bash", { ...bare, ls_attribution_identifier: "Nobody" })]); + + expect(turnAttribution(turn)).toEqual({ ls_attribution_identifier: "Nobody" }); + }); + + // Catches a reconcile that invents an empty fill and patches every run with nothing, + // which costs a request per run and rewrites metadata for no reason. + it("settles on nothing when no run knew anything", () => { + expect(turnAttribution(record(bare, [recorded("Bash", bare)]))).toBeUndefined(); + }); +}); + +describe("what one run ends up with", () => { + // Catches a fill that drops one repository's branch or commit onto another's name, + // which is worse than leaving the gap. + it("never mixes one repository's name with another's branch", () => { + const partial = { ...bare, repository_name: "acme/b", git_branch: "trunk-b" }; + + expect(metadataAfterFill(recorded("Edit", partial), inAlpha)).toBeUndefined(); + }); + + // Catches a patch that sends only the repository keys, which drops everything else + // the run already carried because an update replaces the whole metadata block. + it("keeps every field the run already had", () => { + const filled = metadataAfterFill(recorded("Bash", { ...bare, ls_tool_name: "Bash" }), inAlpha); + + expect(filled).toEqual({ ...bare, ls_tool_name: "Bash", ...inAlpha }); + }); + + // Catches a reconcile that patches a run which already has everything, so every turn + // costs a pointless request per run forever. + it("asks for no change when the run already has it all", () => { + expect(metadataAfterFill(recorded("Edit", { ...bare, ...inAlpha }), inAlpha)).toBeUndefined(); + }); + + // Catches a fill that only adds the repository and leaves the author missing, when + // the author is half of what the turn was reconciled for. + it("fills the author onto a run that only knew the repository", () => { + const run = recorded("Edit", { ...bare, repository_name: "acme/a" }); + + expect(metadataAfterFill(run, inAlpha)).toMatchObject({ + ls_attribution_identifier: "Alpha Owner", + git_branch: "trunk-a", + }); + }); +}); diff --git a/src/tracing/settlement/settlement.ts b/src/tracing/settlement/settlement.ts new file mode 100644 index 0000000..13f3687 --- /dev/null +++ b/src/tracing/settlement/settlement.ts @@ -0,0 +1,65 @@ +import { + ATTRIBUTION_IDENTIFIER_KEY, + REPOSITORY_METADATA_KEYS, + REPOSITORY_NAME_KEY, +} from "./constants.js"; +import type { Attribution, RecordedRun, TurnRecord } from "./models.js"; + +export function attributionOf(metadata: Record | undefined): Attribution { + const carried: Attribution = {}; + for (const key of REPOSITORY_METADATA_KEYS) { + const value = metadata?.[key]; + if (typeof value === "string" && value.length > 0) carried[key] = value; + } + return carried; +} + +export const namesARepository = (carried: Attribution): boolean => + carried[REPOSITORY_NAME_KEY] !== undefined; + +export function turnAttribution(record: TurnRecord): Attribution | undefined { + const root = attributionOf(record.root?.metadata); + const inToolCallOrder = [...record.children] + .sort((left, right) => (left.dotted_order < right.dotted_order ? -1 : 1)) + .map((child) => attributionOf(child.metadata)); + const source = namesARepository(root) + ? root + : inToolCallOrder.find((carried) => namesARepository(carried)); + + const knowsWhoWorkedInSource = (carried: Attribution): boolean => + carried[ATTRIBUTION_IDENTIFIER_KEY] !== undefined && + carried[REPOSITORY_NAME_KEY] === source?.[REPOSITORY_NAME_KEY]; + const author = + root[ATTRIBUTION_IDENTIFIER_KEY] ?? + source?.[ATTRIBUTION_IDENTIFIER_KEY] ?? + inToolCallOrder.find(knowsWhoWorkedInSource)?.[ATTRIBUTION_IDENTIFIER_KEY]; + + const filled: Attribution = { ...source }; + if (author !== undefined) filled[ATTRIBUTION_IDENTIFIER_KEY] = author; + return Object.keys(filled).length > 0 ? filled : undefined; +} + +export function metadataAfterFill( + run: RecordedRun, + filled: Attribution, +): Record | undefined { + const carried = attributionOf(run.metadata); + const workedOutItsOwn = + namesARepository(carried) && carried[REPOSITORY_NAME_KEY] !== filled[REPOSITORY_NAME_KEY]; + if (workedOutItsOwn) return undefined; + + const missing = Object.entries(filled).filter(([key]) => carried[key] === undefined); + if (missing.length === 0) return undefined; + return { ...run.metadata, ...Object.fromEntries(missing) }; +} + +export function settledTurnMetadata( + base: Record | undefined, + record: TurnRecord | undefined, +): Record | undefined { + if (!record?.root) return base; + const filled = turnAttribution({ ...record, root: { ...record.root, metadata: base ?? {} } }); + if (!filled) return base; + const missing = Object.entries(filled).filter(([key]) => base?.[key] === undefined); + return missing.length === 0 ? base : { ...base, ...Object.fromEntries(missing) }; +}