@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,3866 @@
|
|
|
1
|
+
import { d as gitAt, u as git, v as resolveRepoContext } from "./src-CmdiBL20.js";
|
|
2
|
+
import { a as jsonNumber, c as jsonString, i as jsonBoolean, m as isStringValue, n as isJsonObject, o as jsonObject, r as jsonArray, t as isJsonArray, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
+
import { a as TRACE_STORE_CLIENT_ID, b as commitShaSchema, c as deleteStoreResponseSchema, d as listUploadsResponseSchema, f as storeErrorCodeSchema, g as uploadManifestMismatch, h as traceObjectNameSchema, i as storeRoutes, l as listSessionsResponseSchema, m as storeResponseSchema, n as DEVICE_TOKEN_PATH, o as beginUploadResponseSchema, p as storeErrorEnvelopeSchema, r as SESSION_PATH, s as completeUploadResponseSchema, t as DEVICE_CODE_PATH, x as sessionIdSchema } from "./src-CGP5ytbV.js";
|
|
4
|
+
import { A as devReviewHome, E as writeTraceConfigFile, M as writeFileAtomic, P as writePrivateJsonAtomic, S as readTraceConfigFile, _ as emptyTraceConfig, c as resolveS3Setup, f as traceEnvValue$1, g as currentStore, h as TraceConfigurationError, i as isS3MockMode, j as normalizeStoreOrigin, l as s3CacheIdentity, m as DEFAULT_HOSTED_ORIGIN, n as clearTraceEnvCache$1, u as s3MockRoot, v as enabledOriginsOf, w as s3Store, x as hostedOrigin, y as hasHostedStore } from "./s3-config-CAxhO9u_.js";
|
|
5
|
+
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
9
|
+
import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { Readable, Transform } from "node:stream";
|
|
14
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import { pipeline } from "node:stream/promises";
|
|
16
|
+
import { createGunzip, createGzip } from "node:zlib";
|
|
17
|
+
//#region ../trace-protocol/src/event-text.ts
|
|
18
|
+
function extractTraceEventText$1(event) {
|
|
19
|
+
if (!event) return "";
|
|
20
|
+
if (event.kind === "user") return event.text;
|
|
21
|
+
if (event.kind === "assistant") return event.markdown;
|
|
22
|
+
if (event.kind === "tool") return [
|
|
23
|
+
event.title,
|
|
24
|
+
event.command,
|
|
25
|
+
event.input,
|
|
26
|
+
event.output
|
|
27
|
+
].filter(Boolean).join(" ");
|
|
28
|
+
if (event.kind === "separator") return event.label;
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region ../trace-core/src/cli-output.ts
|
|
33
|
+
/** The stream a human report belongs on. */
|
|
34
|
+
function humanStream(output) {
|
|
35
|
+
return output.json ? output.stderr : output.stdout;
|
|
36
|
+
}
|
|
37
|
+
/** Writes one event line, and only under --json. */
|
|
38
|
+
function emitJsonEvent(output, event) {
|
|
39
|
+
if (output.json) output.stdout.write(`${JSON.stringify(event)}\n`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reports a failure on both channels and returns the exit code.
|
|
43
|
+
*
|
|
44
|
+
* The human message always reaches stderr. Under --json the same message also
|
|
45
|
+
* reaches stdout as a parseable error event, so an agent never has to read
|
|
46
|
+
* prose to learn why a command failed.
|
|
47
|
+
*/
|
|
48
|
+
function failWithJsonError(output, stage, message) {
|
|
49
|
+
output.stderr.write(`${message}\n`);
|
|
50
|
+
emitJsonEvent(output, {
|
|
51
|
+
event: "error",
|
|
52
|
+
stage,
|
|
53
|
+
message
|
|
54
|
+
});
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
58
|
+
"--review",
|
|
59
|
+
"--session",
|
|
60
|
+
"--agent-session"
|
|
61
|
+
]);
|
|
62
|
+
/**
|
|
63
|
+
* Report whether the caller asked for JSON, by reading raw argv.
|
|
64
|
+
*
|
|
65
|
+
* A usage error happens during parsing, before commander resolves any option,
|
|
66
|
+
* so the error path cannot read the parsed value. Actions read the parsed
|
|
67
|
+
* option instead; this scan only has to answer for failures.
|
|
68
|
+
*/
|
|
69
|
+
function jsonRequestedInArgv(argv) {
|
|
70
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
71
|
+
const token = argv[index];
|
|
72
|
+
if (token === "--") return false;
|
|
73
|
+
if (VALUE_FLAGS.has(token)) {
|
|
74
|
+
index += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token === "--json" || token === "--json=true") return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region ../trace-core/src/store-authorization.ts
|
|
83
|
+
const context = new AsyncLocalStorage();
|
|
84
|
+
/** Only a foreground CLI installs this callback; hooks never prompt. */
|
|
85
|
+
function withStoreAuthorization(authorize, operation) {
|
|
86
|
+
return context.run({
|
|
87
|
+
authorize,
|
|
88
|
+
attempted: false
|
|
89
|
+
}, operation);
|
|
90
|
+
}
|
|
91
|
+
async function requestStoreAuthorization(origin) {
|
|
92
|
+
const scope = context.getStore();
|
|
93
|
+
if (!scope || scope.attempted) return void 0;
|
|
94
|
+
scope.attempted = true;
|
|
95
|
+
return scope.authorize(origin);
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region ../trace-core/src/store-client.ts
|
|
99
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
100
|
+
/** Largest metadata answer the client reads; object bytes never come this way. */
|
|
101
|
+
const MAX_STORE_RESPONSE_BYTES = 8388608;
|
|
102
|
+
/**
|
|
103
|
+
* The message for a store whose responses do not match this client's
|
|
104
|
+
* contract. A 0.1 store, for example, answers a begin call without an
|
|
105
|
+
* `uploadId`.
|
|
106
|
+
*/
|
|
107
|
+
const STORE_UPGRADE_REQUIRED_MESSAGE = "The trace store does not support this version of Review. Update Review or use a matching store.";
|
|
108
|
+
/**
|
|
109
|
+
* The OAuth device-flow error shape used by the Better Auth device
|
|
110
|
+
* endpoints, e.g. `{"error":"invalid_grant","error_description":"..."}`.
|
|
111
|
+
* This is distinct from the store's own `storeErrorEnvelopeSchema`.
|
|
112
|
+
*/
|
|
113
|
+
const oauthDeviceErrorSchema = z.object({
|
|
114
|
+
error: z.string(),
|
|
115
|
+
error_description: z.string().optional()
|
|
116
|
+
});
|
|
117
|
+
var StoreApiError = class extends Error {
|
|
118
|
+
code;
|
|
119
|
+
status;
|
|
120
|
+
constructor(code, status, message) {
|
|
121
|
+
super(message);
|
|
122
|
+
this.code = code;
|
|
123
|
+
this.status = status;
|
|
124
|
+
this.name = "StoreApiError";
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var StoreClient = class {
|
|
128
|
+
origin;
|
|
129
|
+
token;
|
|
130
|
+
fetchImpl;
|
|
131
|
+
timeoutMs;
|
|
132
|
+
constructor(options) {
|
|
133
|
+
this.origin = options.origin;
|
|
134
|
+
this.token = options.token;
|
|
135
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
136
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
137
|
+
}
|
|
138
|
+
async listOwnUploads(repositoryId, query = {}) {
|
|
139
|
+
const params = new URLSearchParams();
|
|
140
|
+
if (query.session !== void 0) params.set("session", query.session);
|
|
141
|
+
if (query.cursor !== void 0) params.set("cursor", query.cursor);
|
|
142
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
143
|
+
return this.get(storeRoutes.ownUploads(repositoryId), listUploadsResponseSchema, params);
|
|
144
|
+
}
|
|
145
|
+
async deviceCode(traces = true) {
|
|
146
|
+
return this.requestJson(DEVICE_CODE_PATH, {
|
|
147
|
+
client_id: TRACE_STORE_CLIENT_ID,
|
|
148
|
+
scope: traces ? "repo" : "identity"
|
|
149
|
+
}, void 0);
|
|
150
|
+
}
|
|
151
|
+
async deviceToken(deviceCode) {
|
|
152
|
+
const url = new URL(DEVICE_TOKEN_PATH, this.origin);
|
|
153
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: this.jsonHeaders(),
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
158
|
+
device_code: deviceCode,
|
|
159
|
+
client_id: TRACE_STORE_CLIENT_ID
|
|
160
|
+
}),
|
|
161
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
162
|
+
});
|
|
163
|
+
if (response.status === 400) {
|
|
164
|
+
const raw = await this.readJsonBody(response);
|
|
165
|
+
const oauthError = oauthDeviceErrorSchema.safeParse(raw);
|
|
166
|
+
if (oauthError.success && (oauthError.data.error === "authorization_pending" || oauthError.data.error === "slow_down")) return { pending: oauthError.data.error };
|
|
167
|
+
throw this.oauthOrEnvelopeError(raw, response.status);
|
|
168
|
+
}
|
|
169
|
+
if (!response.ok) throw await this.toStoreApiError(response);
|
|
170
|
+
return await response.json();
|
|
171
|
+
}
|
|
172
|
+
async session() {
|
|
173
|
+
return this.get(SESSION_PATH, z.object({ user: z.object({
|
|
174
|
+
id: z.string().min(1),
|
|
175
|
+
name: z.string()
|
|
176
|
+
}) }));
|
|
177
|
+
}
|
|
178
|
+
async createStore(body) {
|
|
179
|
+
return this.requestJson(storeRoutes.stores(), body, storeResponseSchema);
|
|
180
|
+
}
|
|
181
|
+
async findStore(query) {
|
|
182
|
+
try {
|
|
183
|
+
return await this.get(storeRoutes.stores(), storeResponseSchema, new URLSearchParams({
|
|
184
|
+
owner: query.owner,
|
|
185
|
+
name: query.name
|
|
186
|
+
}));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof StoreApiError && error.code === "not_found") return null;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async beginUpload(repositoryId, sessionId, body) {
|
|
193
|
+
return this.requestJson(storeRoutes.uploads(repositoryId, sessionId), body, beginUploadResponseSchema);
|
|
194
|
+
}
|
|
195
|
+
async completeUpload(repositoryId, sessionId, uploadId, body) {
|
|
196
|
+
return this.requestJson(storeRoutes.uploadComplete(repositoryId, sessionId, uploadId), body, completeUploadResponseSchema);
|
|
197
|
+
}
|
|
198
|
+
/** Deletion is logical; the store answers 202 while an operator removes the objects. */
|
|
199
|
+
async deleteStore(repositoryId) {
|
|
200
|
+
return this.send("DELETE", storeRoutes.store(repositoryId), void 0, deleteStoreResponseSchema);
|
|
201
|
+
}
|
|
202
|
+
async listSessions(repositoryId, query) {
|
|
203
|
+
const params = new URLSearchParams();
|
|
204
|
+
if (query.commit) params.set("commit", query.commit);
|
|
205
|
+
if (query.session) params.set("session", query.session);
|
|
206
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
207
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
208
|
+
return this.get(storeRoutes.sessions(repositoryId), listSessionsResponseSchema, params);
|
|
209
|
+
}
|
|
210
|
+
jsonHeaders() {
|
|
211
|
+
return this.token ? {
|
|
212
|
+
"content-type": "application/json",
|
|
213
|
+
authorization: `Bearer ${this.token}`
|
|
214
|
+
} : { "content-type": "application/json" };
|
|
215
|
+
}
|
|
216
|
+
async get(path, schema, query) {
|
|
217
|
+
return this.send("GET", path, void 0, schema, query);
|
|
218
|
+
}
|
|
219
|
+
async requestJson(path, body, schema) {
|
|
220
|
+
return this.send("POST", path, body, schema);
|
|
221
|
+
}
|
|
222
|
+
async send(method, path, body, schema, query) {
|
|
223
|
+
const url = new URL(path, this.origin);
|
|
224
|
+
if (query) for (const [key, value] of query) url.searchParams.set(key, value);
|
|
225
|
+
const headers = {};
|
|
226
|
+
if (this.token) headers.authorization = `Bearer ${this.token}`;
|
|
227
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
228
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
229
|
+
method,
|
|
230
|
+
headers,
|
|
231
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
232
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
const error = await this.toStoreApiError(response);
|
|
236
|
+
if (error.code === "repository_authorization_required") {
|
|
237
|
+
const token = await requestStoreAuthorization(this.origin);
|
|
238
|
+
if (token) {
|
|
239
|
+
this.token = token;
|
|
240
|
+
return this.send(method, path, body, schema, query);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
const contentLength = Number(response.headers.get("content-length") ?? "0");
|
|
246
|
+
if (contentLength > MAX_STORE_RESPONSE_BYTES) throw new StoreApiError("internal", response.status, `The trace store answered ${contentLength} bytes; the client accepts at most ${MAX_STORE_RESPONSE_BYTES}.`);
|
|
247
|
+
return this.parseJson(response, schema);
|
|
248
|
+
}
|
|
249
|
+
async parseJson(response, schema) {
|
|
250
|
+
const raw = await response.json();
|
|
251
|
+
if (!schema) return raw;
|
|
252
|
+
const result = schema.safeParse(raw);
|
|
253
|
+
if (!result.success) throw new StoreApiError("upgrade_required", response.status, STORE_UPGRADE_REQUIRED_MESSAGE);
|
|
254
|
+
return result.data;
|
|
255
|
+
}
|
|
256
|
+
async toStoreApiError(response) {
|
|
257
|
+
const raw = await this.readJsonBody(response);
|
|
258
|
+
return this.oauthOrEnvelopeError(raw, response.status);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Reads a response body once, as text, and parses it as JSON. Returns
|
|
262
|
+
* `undefined` if the body cannot be read or is not valid JSON.
|
|
263
|
+
*/
|
|
264
|
+
async readJsonBody(response) {
|
|
265
|
+
let text;
|
|
266
|
+
try {
|
|
267
|
+
text = await response.text();
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
return parseJsonText(text);
|
|
273
|
+
} catch {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Builds a StoreApiError from an already-parsed body, which is either the
|
|
279
|
+
* store's own `{ error: { code, message } }` envelope or the OAuth
|
|
280
|
+
* device-flow error shape `{ error, error_description }` used by the
|
|
281
|
+
* Better Auth device endpoints.
|
|
282
|
+
*/
|
|
283
|
+
oauthOrEnvelopeError(raw, status) {
|
|
284
|
+
if (raw === void 0) return new StoreApiError("internal", status, "The store returned an unreadable response.");
|
|
285
|
+
const oauthError = oauthDeviceErrorSchema.safeParse(raw);
|
|
286
|
+
if (oauthError.success) {
|
|
287
|
+
const knownCode = storeErrorCodeSchema.safeParse(oauthError.data.error);
|
|
288
|
+
return new StoreApiError(knownCode.success ? knownCode.data : "invalid_request", status, oauthError.data.error_description ?? oauthError.data.error);
|
|
289
|
+
}
|
|
290
|
+
const result = storeErrorEnvelopeSchema.safeParse(raw);
|
|
291
|
+
if (!result.success) return new StoreApiError("internal", status, "The store returned an error response that does not match the contract.");
|
|
292
|
+
return new StoreApiError(result.data.error.code, status, result.data.error.message);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region ../trace-core/src/store-auth.ts
|
|
297
|
+
const DEFAULT_STORE_ORIGIN = DEFAULT_HOSTED_ORIGIN;
|
|
298
|
+
const storeAuthSchema = z.object({
|
|
299
|
+
origin: z.string().min(1),
|
|
300
|
+
token: z.string().min(1),
|
|
301
|
+
login: z.string(),
|
|
302
|
+
savedAt: z.string()
|
|
303
|
+
});
|
|
304
|
+
/** Slow-down backoff added to the poll interval, per the OAuth device spec. */
|
|
305
|
+
const SLOW_DOWN_MS = 5e3;
|
|
306
|
+
function storeAuthPath(env = process.env) {
|
|
307
|
+
return path.join(devReviewHome(env), "auth.json");
|
|
308
|
+
}
|
|
309
|
+
async function readStoreAuth(env = process.env) {
|
|
310
|
+
let raw;
|
|
311
|
+
try {
|
|
312
|
+
raw = await readFile(storeAuthPath(env), "utf8");
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error.code === "ENOENT") return null;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(raw);
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const result = storeAuthSchema.safeParse(parsed);
|
|
324
|
+
if (!result.success) return null;
|
|
325
|
+
try {
|
|
326
|
+
return {
|
|
327
|
+
...result.data,
|
|
328
|
+
origin: normalizeStoreOrigin(result.data.origin)
|
|
329
|
+
};
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function writeStoreAuth(auth, env = process.env) {
|
|
335
|
+
await writePrivateJsonAtomic(storeAuthPath(env), {
|
|
336
|
+
...auth,
|
|
337
|
+
origin: normalizeStoreOrigin(auth.origin)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function clearStoreAuth(env = process.env) {
|
|
341
|
+
await rm(storeAuthPath(env), { force: true });
|
|
342
|
+
}
|
|
343
|
+
/** A client bound to the saved login. Throws when no login is saved. */
|
|
344
|
+
async function requireStoreClient(env = process.env) {
|
|
345
|
+
const auth = await readStoreAuth(env);
|
|
346
|
+
if (!auth) throw new Error(`Run \`whiteboard login\` first.`);
|
|
347
|
+
return new StoreClient({
|
|
348
|
+
origin: auth.origin,
|
|
349
|
+
token: auth.token
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function defaultSleep(ms) {
|
|
353
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
354
|
+
}
|
|
355
|
+
/** The command that opens a URL in the user's browser on this platform. */
|
|
356
|
+
function browserOpenCommand(platform = process.platform) {
|
|
357
|
+
return platform === "darwin" ? "open" : "xdg-open";
|
|
358
|
+
}
|
|
359
|
+
/** Opens a URL in the browser. Callers inject a stub in tests. */
|
|
360
|
+
async function openUrlInBrowser(url) {
|
|
361
|
+
const { spawn } = await import("node:child_process");
|
|
362
|
+
const child = spawn(browserOpenCommand(), [url], {
|
|
363
|
+
stdio: "ignore",
|
|
364
|
+
detached: true
|
|
365
|
+
});
|
|
366
|
+
child.on("error", () => {});
|
|
367
|
+
child.unref();
|
|
368
|
+
}
|
|
369
|
+
async function runStoreLogin(input) {
|
|
370
|
+
const output = {
|
|
371
|
+
json: input.json,
|
|
372
|
+
stdout: input.stdout,
|
|
373
|
+
stderr: input.stderr
|
|
374
|
+
};
|
|
375
|
+
let origin;
|
|
376
|
+
try {
|
|
377
|
+
origin = normalizeStoreOrigin(input.origin ?? DEFAULT_STORE_ORIGIN);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return failWithJsonError(output, "login", errorMessage(error));
|
|
380
|
+
}
|
|
381
|
+
const openUrl = input.openUrl ?? openUrlInBrowser;
|
|
382
|
+
const sleep = input.sleep ?? defaultSleep;
|
|
383
|
+
const client = new StoreClient({
|
|
384
|
+
origin,
|
|
385
|
+
fetch: input.fetch
|
|
386
|
+
});
|
|
387
|
+
const existing = input.traces ? await readStoreAuth(input.env) : null;
|
|
388
|
+
let expectedUser;
|
|
389
|
+
if (existing?.origin === origin) try {
|
|
390
|
+
expectedUser = (await new StoreClient({
|
|
391
|
+
origin,
|
|
392
|
+
token: existing.token,
|
|
393
|
+
fetch: input.fetch
|
|
394
|
+
}).session()).user.id;
|
|
395
|
+
} catch {}
|
|
396
|
+
let device;
|
|
397
|
+
try {
|
|
398
|
+
device = await client.deviceCode(input.traces ?? true);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "Could not start the login.");
|
|
401
|
+
}
|
|
402
|
+
humanStream(output).write(`Open ${device.verification_uri_complete}\n`);
|
|
403
|
+
humanStream(output).write(`Code: ${device.user_code}\n`);
|
|
404
|
+
emitJsonEvent(output, {
|
|
405
|
+
event: "login",
|
|
406
|
+
status: "pending",
|
|
407
|
+
url: device.verification_uri_complete,
|
|
408
|
+
userCode: device.user_code
|
|
409
|
+
});
|
|
410
|
+
if (!input.noBrowser) await openUrl(device.verification_uri_complete);
|
|
411
|
+
let intervalMs = Math.max(1, device.interval) * 1e3;
|
|
412
|
+
const expiresAt = Date.now() + device.expires_in * 1e3;
|
|
413
|
+
let token;
|
|
414
|
+
while (Date.now() < expiresAt) {
|
|
415
|
+
await sleep(intervalMs);
|
|
416
|
+
let result;
|
|
417
|
+
try {
|
|
418
|
+
result = await client.deviceToken(device.device_code);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "The login failed.");
|
|
421
|
+
}
|
|
422
|
+
if ("pending" in result) {
|
|
423
|
+
if (result.pending === "slow_down") intervalMs += SLOW_DOWN_MS;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
token = result.access_token;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
if (!token) return failWithJsonError(output, "login", `The login expired. Run \`whiteboard login\` again.`);
|
|
430
|
+
const authedClient = new StoreClient({
|
|
431
|
+
origin,
|
|
432
|
+
token,
|
|
433
|
+
fetch: input.fetch
|
|
434
|
+
});
|
|
435
|
+
let login;
|
|
436
|
+
try {
|
|
437
|
+
const session = await authedClient.session();
|
|
438
|
+
if (expectedUser && session.user.id !== expectedUser) throw new Error("Authorize traces with the same GitHub account.");
|
|
439
|
+
login = session.user.name;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "Could not read the login.");
|
|
442
|
+
}
|
|
443
|
+
await writeStoreAuth({
|
|
444
|
+
origin,
|
|
445
|
+
token,
|
|
446
|
+
login,
|
|
447
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
448
|
+
}, input.env);
|
|
449
|
+
emitJsonEvent(output, {
|
|
450
|
+
event: "login",
|
|
451
|
+
status: "ok",
|
|
452
|
+
login
|
|
453
|
+
});
|
|
454
|
+
humanStream(output).write(`You are logged in as ${login}.\n`);
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
async function runStoreLogout(input) {
|
|
458
|
+
const existing = await readStoreAuth(input.env);
|
|
459
|
+
await clearStoreAuth(input.env);
|
|
460
|
+
input.stdout.write(existing ? "You are logged out.\n" : "You were not logged in.\n");
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
async function runStoreWhoami(input) {
|
|
464
|
+
const output = {
|
|
465
|
+
json: input.json,
|
|
466
|
+
stdout: input.stdout,
|
|
467
|
+
stderr: input.stderr
|
|
468
|
+
};
|
|
469
|
+
const auth = await readStoreAuth(input.env);
|
|
470
|
+
if (!auth) return failWithJsonError(output, "whoami", `Run \`whiteboard login\` first.`);
|
|
471
|
+
const client = new StoreClient({
|
|
472
|
+
origin: auth.origin,
|
|
473
|
+
token: auth.token,
|
|
474
|
+
fetch: input.fetch
|
|
475
|
+
});
|
|
476
|
+
let login;
|
|
477
|
+
try {
|
|
478
|
+
login = (await client.session()).user.name;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return failWithJsonError(output, "whoami", error instanceof StoreApiError ? error.message : "Could not reach the hosted trace store.");
|
|
481
|
+
}
|
|
482
|
+
emitJsonEvent(output, {
|
|
483
|
+
event: "whoami",
|
|
484
|
+
login,
|
|
485
|
+
origin: auth.origin
|
|
486
|
+
});
|
|
487
|
+
humanStream(output).write(`Logged in to ${auth.origin} as ${login}.\n`);
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region ../trace-core/src/trace-consent.ts
|
|
492
|
+
async function readTraceUserConfig(devHome) {
|
|
493
|
+
return (await readTraceUserConfigFile(devHome)).consent;
|
|
494
|
+
}
|
|
495
|
+
/** The consent entries together with the file read they came from. */
|
|
496
|
+
async function readTraceUserConfigFile(devHome) {
|
|
497
|
+
const file = readTraceConfigFile({ devHome: devHome ?? devReviewHome() });
|
|
498
|
+
if (file.error) throw new TraceConfigurationError(file.error);
|
|
499
|
+
return {
|
|
500
|
+
file,
|
|
501
|
+
consent: {
|
|
502
|
+
version: 2,
|
|
503
|
+
repositories: (file.config?.repositories ?? []).map((entry) => ({
|
|
504
|
+
repositoryId: entry.repositoryId,
|
|
505
|
+
name: entry.name,
|
|
506
|
+
enabledOrigins: enabledOriginsOf(entry).flatMap((origin) => {
|
|
507
|
+
try {
|
|
508
|
+
return [normalizeStoreOrigin(origin)];
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
}),
|
|
513
|
+
allowedAt: entry.allowedAt ?? null
|
|
514
|
+
}))
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async function writeRepositories(file, repositories) {
|
|
519
|
+
await writeTraceConfigFile(file, {
|
|
520
|
+
...file.config ?? emptyTraceConfig(),
|
|
521
|
+
repositories
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function toConfigEntry(entry) {
|
|
525
|
+
const written = {
|
|
526
|
+
repositoryId: entry.repositoryId,
|
|
527
|
+
name: entry.name,
|
|
528
|
+
enabledOrigins: entry.enabledOrigins
|
|
529
|
+
};
|
|
530
|
+
if (entry.allowedAt) written.allowedAt = entry.allowedAt;
|
|
531
|
+
return written;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Allows one repository at one origin. A repository has one entry, keyed by
|
|
535
|
+
* its immutable id; allowing it at another origin appends to its list, and
|
|
536
|
+
* a rename updates the display name.
|
|
537
|
+
*/
|
|
538
|
+
async function allowTraceRepository(entry, devHome = devReviewHome()) {
|
|
539
|
+
const origin = normalizeStoreOrigin(entry.origin);
|
|
540
|
+
if (!Number.isSafeInteger(entry.repositoryId) || entry.repositoryId < 1) throw new Error("The trace repository id must be a positive integer.");
|
|
541
|
+
const { file, consent: config } = await readTraceUserConfigFile(devHome);
|
|
542
|
+
const existing = config.repositories.find((candidate) => candidate.repositoryId === entry.repositoryId);
|
|
543
|
+
const merged = {
|
|
544
|
+
repositoryId: entry.repositoryId,
|
|
545
|
+
name: entry.name,
|
|
546
|
+
enabledOrigins: [.../* @__PURE__ */ new Set([...existing?.enabledOrigins ?? [], origin])],
|
|
547
|
+
allowedAt: existing?.allowedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
548
|
+
};
|
|
549
|
+
const repositories = [...config.repositories.filter((candidate) => candidate.repositoryId !== entry.repositoryId && candidate.name.toLowerCase() !== entry.name.toLowerCase()), merged];
|
|
550
|
+
await writeRepositories(file, repositories.map(toConfigEntry));
|
|
551
|
+
return {
|
|
552
|
+
version: 2,
|
|
553
|
+
repositories
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Withdraws consent for a repository at every origin. The immutable id
|
|
558
|
+
* catches an entry whose display name moved; the name catches one whose id
|
|
559
|
+
* this machine never learned.
|
|
560
|
+
*/
|
|
561
|
+
async function denyTraceRepository(repository, devHome = devReviewHome()) {
|
|
562
|
+
const { file, consent: config } = await readTraceUserConfigFile(devHome);
|
|
563
|
+
const repositories = config.repositories.filter((existing) => existing.name.toLowerCase() !== repository.name.toLowerCase() && (repository.repositoryId == null || existing.repositoryId !== repository.repositoryId));
|
|
564
|
+
const removed = repositories.length !== config.repositories.length;
|
|
565
|
+
if (removed) await writeRepositories(file, repositories.map(toConfigEntry));
|
|
566
|
+
return removed;
|
|
567
|
+
}
|
|
568
|
+
function findTraceRepository(config, name) {
|
|
569
|
+
return config.repositories.find((entry) => entry.name.toLowerCase() === name.toLowerCase()) ?? null;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region ../trace-core/src/trace-repo.ts
|
|
573
|
+
function parseRepo(value) {
|
|
574
|
+
const parts = value.split("/");
|
|
575
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository must be OWNER/REPO.");
|
|
576
|
+
return {
|
|
577
|
+
owner: parts[0],
|
|
578
|
+
repo: parts[1]
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function traceRepoName(repo) {
|
|
582
|
+
return `${repo.owner}/${repo.repo}`;
|
|
583
|
+
}
|
|
584
|
+
async function inferRepoFromGit(cwd) {
|
|
585
|
+
if (process.env.GITHUB_REPOSITORY) return parseRepo(process.env.GITHUB_REPOSITORY);
|
|
586
|
+
const slug = (await resolveRepoContext(cwd))?.githubSlug;
|
|
587
|
+
if (slug) return parseRepo(slug);
|
|
588
|
+
const result = await git(cwd, [
|
|
589
|
+
"remote",
|
|
590
|
+
"get-url",
|
|
591
|
+
"origin"
|
|
592
|
+
], { allowFailure: true });
|
|
593
|
+
if (result.ok && result.stdout.trim()) {
|
|
594
|
+
const raw = result.stdout.trim();
|
|
595
|
+
const match = /[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(raw);
|
|
596
|
+
if (match && match[1] && match[2]) return parseRepo(`${match[1]}/${match[2]}`);
|
|
597
|
+
}
|
|
598
|
+
throw new Error("Could not infer GitHub repository from origin remote.");
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region ../trace-core/src/trace-repository-target.ts
|
|
602
|
+
/**
|
|
603
|
+
* The identity of one target on this machine: a short digest of the origin
|
|
604
|
+
* and the repository id. Cache directories and provenance records use it,
|
|
605
|
+
* so a rename keeps its cache and a second origin never shares one.
|
|
606
|
+
*/
|
|
607
|
+
function traceTargetKey(target) {
|
|
608
|
+
if (!Number.isSafeInteger(target.repositoryId) || target.repositoryId < 1) throw new Error("The trace repository id must be a positive integer.");
|
|
609
|
+
const origin = normalizeStoreOrigin(target.origin);
|
|
610
|
+
return `${createHash("sha256").update(origin).digest("hex").slice(0, 16)}/r${target.repositoryId}`;
|
|
611
|
+
}
|
|
612
|
+
const cachedTargetSchema = z.object({
|
|
613
|
+
origin: z.string(),
|
|
614
|
+
repositoryId: z.number().int().positive(),
|
|
615
|
+
storeId: z.string().regex(/^[0-9a-f]{32}$/),
|
|
616
|
+
name: z.string().min(1),
|
|
617
|
+
/** The checkout's own `owner/repo` at the time of the lookup. */
|
|
618
|
+
checkout: z.string().min(1),
|
|
619
|
+
savedAt: z.string()
|
|
620
|
+
});
|
|
621
|
+
/** The shared Git directory of a checkout. Sibling worktrees share it. */
|
|
622
|
+
async function gitCommonDirectory(cwd) {
|
|
623
|
+
return (await resolveRepoContext(cwd).catch(() => null))?.commonDir ?? null;
|
|
624
|
+
}
|
|
625
|
+
async function cachedTargetPath(cwd, origin, devHome) {
|
|
626
|
+
const commonDir = await gitCommonDirectory(cwd);
|
|
627
|
+
if (!commonDir) return null;
|
|
628
|
+
const key = createHash("sha256").update(`${origin}\n${commonDir}`).digest("hex");
|
|
629
|
+
return path.join(devHome, "trace", "targets", `${key}.json`);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Saves the target this checkout resolved to, so an offline read can reuse
|
|
633
|
+
* the identity the store confirmed. Tests call it to stand in for a lookup.
|
|
634
|
+
*/
|
|
635
|
+
async function rememberTraceRepositoryTarget(input) {
|
|
636
|
+
const devHome = input.devHome ?? devReviewHome();
|
|
637
|
+
const origin = normalizeStoreOrigin(input.target.origin);
|
|
638
|
+
const filePath = await cachedTargetPath(input.cwd, origin, devHome);
|
|
639
|
+
if (!filePath) return;
|
|
640
|
+
await writePrivateJsonAtomic(filePath, {
|
|
641
|
+
...input.target,
|
|
642
|
+
origin,
|
|
643
|
+
checkout: input.checkout ?? input.target.name,
|
|
644
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The target this checkout resolved to on an earlier online lookup, or null
|
|
649
|
+
* when none was saved or the saved one names another origin or repository.
|
|
650
|
+
*/
|
|
651
|
+
async function readCachedTraceRepositoryTarget(input) {
|
|
652
|
+
const devHome = input.devHome ?? devReviewHome();
|
|
653
|
+
const origin = normalizeStoreOrigin(input.origin);
|
|
654
|
+
const filePath = await cachedTargetPath(input.cwd, origin, devHome);
|
|
655
|
+
if (!filePath) return null;
|
|
656
|
+
let raw;
|
|
657
|
+
try {
|
|
658
|
+
raw = await readFile(filePath, "utf8");
|
|
659
|
+
} catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
let parsed;
|
|
663
|
+
try {
|
|
664
|
+
const result = cachedTargetSchema.safeParse(JSON.parse(raw));
|
|
665
|
+
if (!result.success) return null;
|
|
666
|
+
parsed = result.data;
|
|
667
|
+
} catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
if (parsed.origin !== origin) return null;
|
|
671
|
+
const checkout = await inferRepoFromGit(input.cwd).catch(() => null);
|
|
672
|
+
if (!checkout || traceRepoName(checkout).toLowerCase() !== parsed.checkout.toLowerCase()) return null;
|
|
673
|
+
return {
|
|
674
|
+
origin: parsed.origin,
|
|
675
|
+
repositoryId: parsed.repositoryId,
|
|
676
|
+
storeId: parsed.storeId,
|
|
677
|
+
name: parsed.name
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/** Whether a store failure means the store could not answer at all. */
|
|
681
|
+
function isStoreUnreachable(error) {
|
|
682
|
+
return !(error instanceof StoreApiError) || error.status >= 500;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Resolves the checkout to its trace target through the store. A network
|
|
686
|
+
* failure on a read (`write === false`) falls back to the saved target of
|
|
687
|
+
* this checkout and reports `offline: true`. A write never falls back.
|
|
688
|
+
*/
|
|
689
|
+
async function resolveTraceRepositoryTarget(input) {
|
|
690
|
+
const origin = normalizeStoreOrigin(input.origin);
|
|
691
|
+
const repo = await inferRepoFromGit(input.cwd);
|
|
692
|
+
const checkout = traceRepoName(repo);
|
|
693
|
+
let store;
|
|
694
|
+
try {
|
|
695
|
+
store = await input.client.findStore({
|
|
696
|
+
owner: repo.owner,
|
|
697
|
+
name: repo.repo
|
|
698
|
+
});
|
|
699
|
+
} catch (error) {
|
|
700
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
701
|
+
if (input.write || !isStoreUnreachable(cause) || cause instanceof StoreApiError && cause.code === "store_deleted") throw cause;
|
|
702
|
+
const cached = await readCachedTraceRepositoryTarget({
|
|
703
|
+
cwd: input.cwd,
|
|
704
|
+
origin,
|
|
705
|
+
devHome: input.devHome
|
|
706
|
+
});
|
|
707
|
+
if (!cached) throw cause;
|
|
708
|
+
return {
|
|
709
|
+
target: cached,
|
|
710
|
+
offline: true
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (!store) throw new StoreApiError("not_found", 404, `This repository has no active hosted trace store. Run \`whiteboard trace store create\`.`);
|
|
714
|
+
if (store.status !== "active") throw new StoreApiError("store_deleted", 410, "This repository's trace store was deleted.");
|
|
715
|
+
const target = {
|
|
716
|
+
origin,
|
|
717
|
+
repositoryId: store.repositoryId,
|
|
718
|
+
storeId: store.storeId,
|
|
719
|
+
name: store.displayName
|
|
720
|
+
};
|
|
721
|
+
await rememberTraceRepositoryTarget({
|
|
722
|
+
cwd: input.cwd,
|
|
723
|
+
target,
|
|
724
|
+
checkout,
|
|
725
|
+
devHome: input.devHome
|
|
726
|
+
}).catch(() => void 0);
|
|
727
|
+
return {
|
|
728
|
+
target,
|
|
729
|
+
offline: false
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* The consent entry that covers this target: the repository, allowed at the
|
|
734
|
+
* target's origin. An entry allowed only elsewhere names where.
|
|
735
|
+
*/
|
|
736
|
+
async function requireTraceConsent(target, devHome) {
|
|
737
|
+
const entry = (await readTraceUserConfig(devHome)).repositories.find((candidate) => candidate.repositoryId === target.repositoryId);
|
|
738
|
+
if (entry?.enabledOrigins.includes(target.origin)) return entry;
|
|
739
|
+
if (entry) throw new Error(`${entry.name} is allowed to publish traces to ${entry.enabledOrigins.join(", ")}, not ${target.origin}. Run \`whiteboard trace allow .\` while logged in there.`);
|
|
740
|
+
throw new Error(`This repository is not allowed for trace publication. Run \`whiteboard trace allow .\`.`);
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region ../trace-core/src/trace-session-provenance.ts
|
|
744
|
+
/** Records older than this are pruned when a new record is written. */
|
|
745
|
+
const PROVENANCE_RETENTION_MS = 7776e6;
|
|
746
|
+
const provenanceRecordSchema = z.object({
|
|
747
|
+
identity: z.string().min(1),
|
|
748
|
+
allowed: z.boolean(),
|
|
749
|
+
recordedAt: z.string()
|
|
750
|
+
});
|
|
751
|
+
function capturedSessionsDir(devHome = devReviewHome()) {
|
|
752
|
+
return path.join(devHome, "trace", "captured-sessions");
|
|
753
|
+
}
|
|
754
|
+
function sessionDir(sessionId, devHome) {
|
|
755
|
+
return path.join(capturedSessionsDir(devHome), sessionIdSchema.parse(sessionId));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The identity of one checkout for provenance. An allowed checkout is its
|
|
759
|
+
* target key, so sibling worktrees of one repository count as one place. An
|
|
760
|
+
* unallowed checkout is named by its repository, or by its Git directory
|
|
761
|
+
* when it has no GitHub remote.
|
|
762
|
+
*/
|
|
763
|
+
function traceCaptureIdentity(input) {
|
|
764
|
+
if ("target" in input) return {
|
|
765
|
+
identity: traceTargetKey(input.target),
|
|
766
|
+
allowed: true
|
|
767
|
+
};
|
|
768
|
+
if ("repositoryName" in input) return {
|
|
769
|
+
identity: `unallowed:${input.repositoryName.toLowerCase()}`,
|
|
770
|
+
allowed: false
|
|
771
|
+
};
|
|
772
|
+
return {
|
|
773
|
+
identity: `unallowed:${input.gitDir}`,
|
|
774
|
+
allowed: false
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Writes the record for one session and identity when it does not exist
|
|
779
|
+
* yet. Heartbeats call this often, so an existing record costs one stat.
|
|
780
|
+
*/
|
|
781
|
+
async function recordTraceSessionProvenance(input) {
|
|
782
|
+
const devHome = input.devHome ?? devReviewHome();
|
|
783
|
+
const fileName = `${createHash("sha256").update(input.identity).digest("hex")}.json`;
|
|
784
|
+
const filePath = path.join(sessionDir(input.sessionId, devHome), fileName);
|
|
785
|
+
if (await stat(filePath).then(() => true, () => false)) return;
|
|
786
|
+
const record = {
|
|
787
|
+
identity: input.identity,
|
|
788
|
+
allowed: input.allowed,
|
|
789
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
790
|
+
};
|
|
791
|
+
await writePrivateJsonAtomic(filePath, record);
|
|
792
|
+
await pruneStaleProvenance(devHome).catch(() => void 0);
|
|
793
|
+
}
|
|
794
|
+
async function pruneStaleProvenance(devHome) {
|
|
795
|
+
const root = capturedSessionsDir(devHome);
|
|
796
|
+
const cutoff = Date.now() - PROVENANCE_RETENTION_MS;
|
|
797
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
798
|
+
if (!entry.isDirectory()) continue;
|
|
799
|
+
const dir = path.join(root, entry.name);
|
|
800
|
+
const info = await stat(dir).catch(() => null);
|
|
801
|
+
if (info && info.mtimeMs < cutoff) await rm(dir, {
|
|
802
|
+
recursive: true,
|
|
803
|
+
force: true
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** Every record the hooks wrote for one session. */
|
|
808
|
+
async function readTraceSessionProvenance(sessionId, devHome) {
|
|
809
|
+
const dir = sessionDir(sessionId, devHome ?? devReviewHome());
|
|
810
|
+
const files = await readdir(dir).catch(() => []);
|
|
811
|
+
const records = [];
|
|
812
|
+
for (const file of files.sort()) {
|
|
813
|
+
if (!file.endsWith(".json")) continue;
|
|
814
|
+
try {
|
|
815
|
+
const parsed = provenanceRecordSchema.safeParse(JSON.parse(await readFile(path.join(dir, file), "utf8")));
|
|
816
|
+
if (parsed.success) records.push(parsed.data);
|
|
817
|
+
} catch {}
|
|
818
|
+
}
|
|
819
|
+
return records;
|
|
820
|
+
}
|
|
821
|
+
var TraceProvenanceError = class extends Error {
|
|
822
|
+
reason;
|
|
823
|
+
constructor(reason, message) {
|
|
824
|
+
super(message);
|
|
825
|
+
this.reason = reason;
|
|
826
|
+
this.name = "TraceProvenanceError";
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
/** Require an allowed record and no records from another repository or origin. */
|
|
830
|
+
async function requireTraceSessionProvenance(sessionId, target, devHome) {
|
|
831
|
+
const records = await readTraceSessionProvenance(sessionId, devHome);
|
|
832
|
+
if (records.length === 0) throw new TraceProvenanceError("provenance_missing", `This session was not captured in ${target.name}. Start a new agent session there after \`whiteboard trace allow .\`; a commit trailer does not authorize publication.`);
|
|
833
|
+
const expected = traceTargetKey(target);
|
|
834
|
+
const samePlace = `unallowed:${target.name.toLowerCase()}`;
|
|
835
|
+
if (!(records.some((record) => record.allowed && record.identity === expected) && records.every((record) => record.identity === expected || record.identity === samePlace))) {
|
|
836
|
+
const mixed = records.some((record) => record.identity !== expected && record.identity !== samePlace);
|
|
837
|
+
throw new TraceProvenanceError(mixed ? "provenance_mixed" : "provenance_unapproved", mixed ? `This session also ran in a repository or destination outside ${target.name}. Start a new session in the allowed repository.` : `No hook recorded allowed capture for this session in ${target.name}. If publication is authorized, allow the repository and wait for the next hook before syncing, or start a new session.`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region ../trace-core/src/trace-store-transport.ts
|
|
842
|
+
/** An object response failed. Its URL is deliberately not retained. */
|
|
843
|
+
var TraceObjectHttpError = class extends Error {
|
|
844
|
+
status;
|
|
845
|
+
constructor(status, message) {
|
|
846
|
+
super(message);
|
|
847
|
+
this.status = status;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
const DEFAULT_LIMITS = {
|
|
851
|
+
timeoutMs: 3e5,
|
|
852
|
+
maxExpandedBytes: 1073741824
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* One private directory per process holds every staged upload. Its mode is
|
|
856
|
+
* 0700, so other local accounts cannot list or read staged transcripts. The
|
|
857
|
+
* process removes it on exit; a crash leaves it to the OS temp cleaner.
|
|
858
|
+
*/
|
|
859
|
+
let stagingDirectory = null;
|
|
860
|
+
function stagingDir() {
|
|
861
|
+
stagingDirectory ??= (async () => {
|
|
862
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "review-trace-"));
|
|
863
|
+
await chmod(dir, 448);
|
|
864
|
+
process.once("exit", () => {
|
|
865
|
+
try {
|
|
866
|
+
rmSync(dir, {
|
|
867
|
+
recursive: true,
|
|
868
|
+
force: true
|
|
869
|
+
});
|
|
870
|
+
} catch {}
|
|
871
|
+
});
|
|
872
|
+
return dir;
|
|
873
|
+
})();
|
|
874
|
+
stagingDirectory.catch(() => {
|
|
875
|
+
stagingDirectory = null;
|
|
876
|
+
});
|
|
877
|
+
return stagingDirectory;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Compresses one file into the private staging directory. The size and the
|
|
881
|
+
* digest describe the gzipped bytes, which is what the store signs and checks.
|
|
882
|
+
*/
|
|
883
|
+
async function gzipToTemp(sourcePath) {
|
|
884
|
+
const targetPath = path.join(await stagingDir(), `${randomBytes(8).toString("hex")}.jsonl.gz`);
|
|
885
|
+
const hash = createHash("sha256");
|
|
886
|
+
const digestTap = new Transform({ transform(chunk, _encoding, callback) {
|
|
887
|
+
hash.update(chunk);
|
|
888
|
+
callback(null, chunk);
|
|
889
|
+
} });
|
|
890
|
+
try {
|
|
891
|
+
await pipeline(createReadStream(sourcePath), createGzip(), digestTap, createWriteStream(targetPath, {
|
|
892
|
+
flags: "wx",
|
|
893
|
+
mode: 384
|
|
894
|
+
}));
|
|
895
|
+
} catch (cause) {
|
|
896
|
+
await rm(targetPath, { force: true });
|
|
897
|
+
throw cause;
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
path: targetPath,
|
|
901
|
+
size: (await stat(targetPath)).size,
|
|
902
|
+
sha256: hash.digest("hex"),
|
|
903
|
+
cleanup: async () => {
|
|
904
|
+
await rm(targetPath, { force: true });
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
/** The transport used at run time. */
|
|
909
|
+
function createHttpTraceStoreTransport(client, fetchImpl = globalThis.fetch, options = {}) {
|
|
910
|
+
const limits = {
|
|
911
|
+
...DEFAULT_LIMITS,
|
|
912
|
+
...options.limits
|
|
913
|
+
};
|
|
914
|
+
return {
|
|
915
|
+
beginUpload: (repositoryId, sessionId, body) => client.beginUpload(repositoryId, sessionId, body),
|
|
916
|
+
completeUpload: (repositoryId, sessionId, uploadId, body) => client.completeUpload(repositoryId, sessionId, uploadId, body),
|
|
917
|
+
listSessions: (repositoryId, query) => client.listSessions(repositoryId, query),
|
|
918
|
+
async putObject(upload, filePath) {
|
|
919
|
+
const init = {
|
|
920
|
+
method: "PUT",
|
|
921
|
+
headers: upload.headers,
|
|
922
|
+
body: Readable.toWeb(createReadStream(filePath)),
|
|
923
|
+
duplex: "half",
|
|
924
|
+
signal: AbortSignal.timeout(limits.timeoutMs)
|
|
925
|
+
};
|
|
926
|
+
const response = await fetchImpl(upload.url, init);
|
|
927
|
+
if (response.status === 412) return;
|
|
928
|
+
if (!response.ok) throw new Error(await storageErrorMessage(response, `store ${upload.name}`));
|
|
929
|
+
},
|
|
930
|
+
async getObject(object, destinationPath) {
|
|
931
|
+
const signal = AbortSignal.timeout(limits.timeoutMs);
|
|
932
|
+
const response = await fetchImpl(object.url, {
|
|
933
|
+
method: "GET",
|
|
934
|
+
signal
|
|
935
|
+
});
|
|
936
|
+
if (!response.ok) throw new TraceObjectHttpError(response.status, await storageErrorMessage(response, "read the object"));
|
|
937
|
+
const contentLength = response.headers.get("content-length");
|
|
938
|
+
if (contentLength !== null && Number(contentLength) !== object.size) throw new Error(`The trace store announced ${contentLength} bytes for ${object.name}; the session declared ${object.size}.`);
|
|
939
|
+
if (!response.body) throw new Error(`The trace store sent no body for ${object.name} (HTTP ${response.status}).`);
|
|
940
|
+
await writeVerifiedObject(Readable.fromWeb(response.body, { signal }), object, destinationPath, limits);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Gunzips a compressed stream into `destinationPath` and only keeps the
|
|
946
|
+
* result when the compressed bytes match the declared size and digest. The
|
|
947
|
+
* output lands in a private temp file next to the destination first, so a
|
|
948
|
+
* failed or corrupt transfer never touches an existing file.
|
|
949
|
+
*/
|
|
950
|
+
async function writeVerifiedObject(compressed, object, destinationPath, limits) {
|
|
951
|
+
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
952
|
+
const tempPath = `${destinationPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
953
|
+
const hash = createHash("sha256");
|
|
954
|
+
let compressedBytes = 0;
|
|
955
|
+
let expandedBytes = 0;
|
|
956
|
+
const compressedTap = new Transform({ transform(chunk, _encoding, callback) {
|
|
957
|
+
compressedBytes += chunk.byteLength;
|
|
958
|
+
if (compressedBytes > object.size) {
|
|
959
|
+
callback(/* @__PURE__ */ new Error(`The trace store sent more than the declared ${object.size} bytes for ${object.name}.`));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
hash.update(chunk);
|
|
963
|
+
callback(null, chunk);
|
|
964
|
+
} });
|
|
965
|
+
const expansionTap = new Transform({ transform(chunk, _encoding, callback) {
|
|
966
|
+
expandedBytes += chunk.byteLength;
|
|
967
|
+
if (expandedBytes > limits.maxExpandedBytes) {
|
|
968
|
+
callback(/* @__PURE__ */ new Error(`${object.name} expands past the ${limits.maxExpandedBytes} byte limit.`));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
callback(null, chunk);
|
|
972
|
+
} });
|
|
973
|
+
try {
|
|
974
|
+
await pipeline(compressed, compressedTap, createGunzip(), expansionTap, createWriteStream(tempPath, {
|
|
975
|
+
flags: "wx",
|
|
976
|
+
mode: 384
|
|
977
|
+
}));
|
|
978
|
+
if (compressedBytes !== object.size) throw new Error(`The trace store sent ${compressedBytes} bytes for ${object.name}; the session declared ${object.size}.`);
|
|
979
|
+
if (hash.digest("hex") !== object.sha256) throw new Error(`The bytes of ${object.name} do not match the declared checksum.`);
|
|
980
|
+
await rename(tempPath, destinationPath);
|
|
981
|
+
} catch (cause) {
|
|
982
|
+
await rm(tempPath, { force: true });
|
|
983
|
+
throw cause;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* A presigned URL carries credentials, so no message shows it. S3 answers with
|
|
988
|
+
* an XML body whose `Code` names the fault.
|
|
989
|
+
*/
|
|
990
|
+
async function storageErrorMessage(response, action) {
|
|
991
|
+
let code = null;
|
|
992
|
+
try {
|
|
993
|
+
code = /<Code>([^<]+)<\/Code>/.exec(await response.text())?.[1] ?? null;
|
|
994
|
+
} catch {
|
|
995
|
+
code = null;
|
|
996
|
+
}
|
|
997
|
+
return code ? `The trace store did not ${action} (HTTP ${response.status}: ${code}).` : `The trace store did not ${action} (HTTP ${response.status}).`;
|
|
998
|
+
}
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region ../trace-core/src/trace-sync-status.ts
|
|
1001
|
+
const MAX_ERROR_LENGTH = 300;
|
|
1002
|
+
const syncFailureSchema = z.object({
|
|
1003
|
+
session: z.string(),
|
|
1004
|
+
repository: z.string().nullable(),
|
|
1005
|
+
status: z.literal("failed"),
|
|
1006
|
+
error: z.string(),
|
|
1007
|
+
at: z.string(),
|
|
1008
|
+
retry: z.string().optional(),
|
|
1009
|
+
reason: z.enum([
|
|
1010
|
+
"provenance_missing",
|
|
1011
|
+
"provenance_unapproved",
|
|
1012
|
+
"provenance_mixed",
|
|
1013
|
+
"sync_failed"
|
|
1014
|
+
]).optional()
|
|
1015
|
+
});
|
|
1016
|
+
function traceSyncStatusDir(devHome = devReviewHome()) {
|
|
1017
|
+
return path.join(devHome, "trace", "sync-status");
|
|
1018
|
+
}
|
|
1019
|
+
function statusPath(sessionId, devHome) {
|
|
1020
|
+
return path.join(traceSyncStatusDir(devHome), `${sessionIdSchema.parse(sessionId)}.json`);
|
|
1021
|
+
}
|
|
1022
|
+
/** Strips URLs and credentials from a message and caps its length. */
|
|
1023
|
+
function sanitizeTraceSyncError(message) {
|
|
1024
|
+
const cleaned = message.replace(/https?:\/\/\S+/g, "<url>").replace(/Bearer \S+/g, "Bearer <token>").replace(/X-Amz-\S+/g, "<signature>").replace(/\s+/g, " ").trim();
|
|
1025
|
+
return cleaned.length > MAX_ERROR_LENGTH ? `${cleaned.slice(0, 299)}…` : cleaned;
|
|
1026
|
+
}
|
|
1027
|
+
async function recordTraceSyncFailure(input) {
|
|
1028
|
+
if (!sessionIdSchema.safeParse(input.sessionId).success) return;
|
|
1029
|
+
const record = {
|
|
1030
|
+
session: input.sessionId,
|
|
1031
|
+
repository: input.repository,
|
|
1032
|
+
status: "failed",
|
|
1033
|
+
error: sanitizeTraceSyncError(input.error),
|
|
1034
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1035
|
+
reason: input.reason ?? "sync_failed",
|
|
1036
|
+
retry: input.reason ? void 0 : `whiteboard trace sync ${input.sessionId}`
|
|
1037
|
+
};
|
|
1038
|
+
await writePrivateJsonAtomic(statusPath(input.sessionId, input.devHome ?? devReviewHome()), record);
|
|
1039
|
+
}
|
|
1040
|
+
async function clearTraceSyncFailure(sessionId, devHome) {
|
|
1041
|
+
if (!sessionIdSchema.safeParse(sessionId).success) return;
|
|
1042
|
+
await rm(statusPath(sessionId, devHome ?? devReviewHome()), { force: true });
|
|
1043
|
+
}
|
|
1044
|
+
function describeTraceSyncFailure(failure) {
|
|
1045
|
+
const retry = failure.reason === "sync_failed" && failure.retry ? ` Retry with \`${failure.retry}\`.` : "";
|
|
1046
|
+
return `Failed background sync: session ${failure.session}${failure.repository ? ` of ${failure.repository}` : ""} at ${failure.at}: ${failure.error}${retry}\n`;
|
|
1047
|
+
}
|
|
1048
|
+
async function listTraceSyncFailures(devHome) {
|
|
1049
|
+
const dir = traceSyncStatusDir(devHome ?? devReviewHome());
|
|
1050
|
+
const files = await readdir(dir).catch(() => []);
|
|
1051
|
+
const failures = [];
|
|
1052
|
+
for (const file of files.sort()) {
|
|
1053
|
+
if (!file.endsWith(".json")) continue;
|
|
1054
|
+
try {
|
|
1055
|
+
const parsed = syncFailureSchema.safeParse(JSON.parse(await readFile(path.join(dir, file), "utf8")));
|
|
1056
|
+
if (parsed.success) failures.push(parsed.data);
|
|
1057
|
+
} catch {}
|
|
1058
|
+
}
|
|
1059
|
+
return failures;
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region ../trace-core/src/trace-storage/types.ts
|
|
1063
|
+
/** The store answered with a refusal (forbidden, deleted, bad login). Nothing is served. */
|
|
1064
|
+
var TraceStorageDeniedError = class extends Error {
|
|
1065
|
+
constructor(message) {
|
|
1066
|
+
super(message);
|
|
1067
|
+
this.name = "TraceStorageDeniedError";
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
/** The store did not answer; a saved copy may be served, labeled offline. */
|
|
1071
|
+
var TraceStorageUnavailableError = class extends Error {
|
|
1072
|
+
constructor(message) {
|
|
1073
|
+
super(message);
|
|
1074
|
+
this.name = "TraceStorageUnavailableError";
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region ../trace-core/src/trace-storage/hosted.ts
|
|
1079
|
+
function defaultWarning(message) {
|
|
1080
|
+
process.stderr.write(`${message}\n`);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* One line that names why a store read failed, or null when the answer
|
|
1084
|
+
* means the store simply holds nothing for this repository or session.
|
|
1085
|
+
*/
|
|
1086
|
+
function storeReadWarning(error) {
|
|
1087
|
+
if (error instanceof StoreApiError) {
|
|
1088
|
+
if (error.code === "not_found") return null;
|
|
1089
|
+
if (error.code === "unauthorized") return `Trace store request failed: unauthorized. Run \`whiteboard login\`.`;
|
|
1090
|
+
if (error.code === "forbidden") return `Trace store request failed: forbidden. ${error.message}`;
|
|
1091
|
+
return `Trace store request failed: ${error.message}`;
|
|
1092
|
+
}
|
|
1093
|
+
return `Trace store request failed: ${error.message}`;
|
|
1094
|
+
}
|
|
1095
|
+
/** The object name the store uses for one trace of a session. */
|
|
1096
|
+
function traceObjectName(traceName) {
|
|
1097
|
+
if (traceName === "main") return "main.jsonl.gz";
|
|
1098
|
+
const base = path.basename(traceName);
|
|
1099
|
+
return `subagents/${base.endsWith(".jsonl") ? base : `${base}.jsonl`}.gz`;
|
|
1100
|
+
}
|
|
1101
|
+
/** The trace name behind one store object name. */
|
|
1102
|
+
function traceNameFromObject(name) {
|
|
1103
|
+
if (name === "main.jsonl.gz") return "main";
|
|
1104
|
+
return name.slice(10, -9);
|
|
1105
|
+
}
|
|
1106
|
+
function contentId(session, sha256) {
|
|
1107
|
+
return `sha256:${sha256}@${session.generation}`;
|
|
1108
|
+
}
|
|
1109
|
+
var HostedTraceStorage = class HostedTraceStorage {
|
|
1110
|
+
kind = "hosted";
|
|
1111
|
+
target;
|
|
1112
|
+
repositoryTarget;
|
|
1113
|
+
offline;
|
|
1114
|
+
transport;
|
|
1115
|
+
devHome;
|
|
1116
|
+
warn;
|
|
1117
|
+
/** One listing per session per instance; an instance lives one operation. */
|
|
1118
|
+
lookups = /* @__PURE__ */ new Map();
|
|
1119
|
+
constructor(parts) {
|
|
1120
|
+
this.repositoryTarget = parts.target;
|
|
1121
|
+
this.target = {
|
|
1122
|
+
kind: "hosted",
|
|
1123
|
+
...parts.target
|
|
1124
|
+
};
|
|
1125
|
+
this.transport = parts.transport;
|
|
1126
|
+
this.offline = parts.offline ?? false;
|
|
1127
|
+
this.devHome = parts.devHome ?? devReviewHome();
|
|
1128
|
+
this.warn = parts.onWarning ?? defaultWarning;
|
|
1129
|
+
}
|
|
1130
|
+
/** A storage over an already resolved target; tests use a memory transport. */
|
|
1131
|
+
static fromParts(parts) {
|
|
1132
|
+
return new HostedTraceStorage(parts);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* The store for this checkout, or null when no store answers for it. A
|
|
1136
|
+
* reader needs no consent entry: a login plus GitHub admin access is enough
|
|
1137
|
+
* and the store enforces that. Without a login, or when the store does
|
|
1138
|
+
* not answer, the target this checkout resolved to earlier serves the
|
|
1139
|
+
* saved copies with `offline: true`. A write never falls back.
|
|
1140
|
+
*/
|
|
1141
|
+
static async resolve(input) {
|
|
1142
|
+
const env = input.env ?? process.env;
|
|
1143
|
+
const devHome = devReviewHome(env, input.homeDir);
|
|
1144
|
+
const report = input.onWarning ?? defaultWarning;
|
|
1145
|
+
const origin = input.origin;
|
|
1146
|
+
if (input.write) {
|
|
1147
|
+
let client = input.client;
|
|
1148
|
+
if (!client) {
|
|
1149
|
+
const auth = await readStoreAuth(env);
|
|
1150
|
+
if (!auth || auth.origin !== origin) throw new Error(auth ? `You are logged in to ${auth.origin}, not the selected store ${origin}. Run \`whiteboard login --origin ${origin}\`.` : `The trace store login is missing. Run \`whiteboard login --origin ${origin}\`.`);
|
|
1151
|
+
client = new StoreClient({
|
|
1152
|
+
origin,
|
|
1153
|
+
token: auth.token
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
const { target } = await resolveTraceRepositoryTarget({
|
|
1157
|
+
cwd: input.cwd,
|
|
1158
|
+
origin,
|
|
1159
|
+
client,
|
|
1160
|
+
write: true,
|
|
1161
|
+
devHome
|
|
1162
|
+
});
|
|
1163
|
+
return new HostedTraceStorage({
|
|
1164
|
+
target,
|
|
1165
|
+
transport: input.transport ?? createHttpTraceStoreTransport(client),
|
|
1166
|
+
devHome,
|
|
1167
|
+
onWarning: input.onWarning
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
const auth = await readStoreAuth(env);
|
|
1171
|
+
const client = input.client ?? (auth && auth.origin === origin ? new StoreClient({
|
|
1172
|
+
origin,
|
|
1173
|
+
token: auth.token
|
|
1174
|
+
}) : void 0);
|
|
1175
|
+
if (!client) {
|
|
1176
|
+
if (auth && auth.origin !== origin) report(`You are logged in to ${auth.origin}, not the selected store ${origin}. Run \`whiteboard login --origin ${origin}\`; using saved copies until then.`);
|
|
1177
|
+
const cached = await readCachedTraceRepositoryTarget({
|
|
1178
|
+
cwd: input.cwd,
|
|
1179
|
+
origin,
|
|
1180
|
+
devHome
|
|
1181
|
+
}).catch(() => null);
|
|
1182
|
+
if (!cached) return null;
|
|
1183
|
+
return new HostedTraceStorage({
|
|
1184
|
+
target: cached,
|
|
1185
|
+
transport: input.transport ?? createHttpTraceStoreTransport(new StoreClient({ origin })),
|
|
1186
|
+
devHome,
|
|
1187
|
+
offline: true,
|
|
1188
|
+
onWarning: input.onWarning
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
const { target, offline } = await resolveTraceRepositoryTarget({
|
|
1193
|
+
cwd: input.cwd,
|
|
1194
|
+
origin,
|
|
1195
|
+
client,
|
|
1196
|
+
write: false,
|
|
1197
|
+
devHome
|
|
1198
|
+
});
|
|
1199
|
+
if (offline) report("The trace store did not answer. Using the saved copies of this repository.");
|
|
1200
|
+
return new HostedTraceStorage({
|
|
1201
|
+
target,
|
|
1202
|
+
transport: input.transport ?? createHttpTraceStoreTransport(client),
|
|
1203
|
+
devHome,
|
|
1204
|
+
offline,
|
|
1205
|
+
onWarning: input.onWarning
|
|
1206
|
+
});
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
1209
|
+
if (cause instanceof StoreApiError && (cause.code === "unauthorized" || cause.code === "forbidden" || cause.code === "store_deleted")) throw new TraceStorageDeniedError(cause.message);
|
|
1210
|
+
const message = cause instanceof StoreApiError && cause.code === "not_found" ? cause.message : storeReadWarning(cause);
|
|
1211
|
+
if (message) report(message);
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
cacheIdentity() {
|
|
1216
|
+
return `hosted:${traceTargetKey(this.repositoryTarget)}:${this.repositoryTarget.storeId}`;
|
|
1217
|
+
}
|
|
1218
|
+
cacheScope() {
|
|
1219
|
+
const [originKey, repositoryKey] = traceTargetKey(this.repositoryTarget).split("/");
|
|
1220
|
+
return {
|
|
1221
|
+
owner: originKey,
|
|
1222
|
+
repo: repositoryKey
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
async readiness() {
|
|
1226
|
+
if (this.offline) return {
|
|
1227
|
+
ready: false,
|
|
1228
|
+
reason: "The trace store did not answer. Saved copies are served offline."
|
|
1229
|
+
};
|
|
1230
|
+
return { ready: true };
|
|
1231
|
+
}
|
|
1232
|
+
async describeObject(sessionId, traceName) {
|
|
1233
|
+
const stored = await this.requireSession(sessionId);
|
|
1234
|
+
if (!stored) return null;
|
|
1235
|
+
const object = stored.objects.find((entry) => entry.name === traceObjectName(traceName));
|
|
1236
|
+
if (!object) return null;
|
|
1237
|
+
return {
|
|
1238
|
+
size: object.size,
|
|
1239
|
+
contentId: contentId(stored, object.sha256)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
async downloadObject(sessionId, traceName, destinationPath) {
|
|
1243
|
+
const stored = await this.requireSession(sessionId);
|
|
1244
|
+
if (!stored) return null;
|
|
1245
|
+
const object = stored.objects.find((entry) => entry.name === traceObjectName(traceName));
|
|
1246
|
+
if (!object) return null;
|
|
1247
|
+
let selected = object;
|
|
1248
|
+
let refreshed = false;
|
|
1249
|
+
const refresh = async () => {
|
|
1250
|
+
refreshed = true;
|
|
1251
|
+
this.lookups.delete(sessionId);
|
|
1252
|
+
const fresh = await this.requireSession(sessionId);
|
|
1253
|
+
const replacement = fresh?.objects.find((entry) => entry.name === object.name);
|
|
1254
|
+
if (!fresh || fresh.uploadId !== stored.uploadId || !replacement || replacement.sha256 !== object.sha256 || replacement.size !== object.size) throw new TraceStorageDeniedError("The trace changed while downloading. Run the read command again.");
|
|
1255
|
+
return replacement;
|
|
1256
|
+
};
|
|
1257
|
+
if (Date.parse(selected.expiresAt) <= Date.now() + 3e4) selected = await refresh();
|
|
1258
|
+
try {
|
|
1259
|
+
await this.transport.getObject(selected, destinationPath);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
if (!(error instanceof TraceObjectHttpError) || error.status !== 403) throw error;
|
|
1262
|
+
if (refreshed) throw new TraceStorageDeniedError(error.message);
|
|
1263
|
+
selected = await refresh();
|
|
1264
|
+
try {
|
|
1265
|
+
await this.transport.getObject(selected, destinationPath);
|
|
1266
|
+
} catch (retryError) {
|
|
1267
|
+
if (retryError instanceof TraceObjectHttpError && retryError.status === 403) throw new TraceStorageDeniedError(retryError.message);
|
|
1268
|
+
throw retryError;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return {
|
|
1272
|
+
size: object.size,
|
|
1273
|
+
contentId: contentId(stored, object.sha256)
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
async listSubagents(sessionId) {
|
|
1277
|
+
const lookup = await this.lookupSession(sessionId);
|
|
1278
|
+
if (lookup.status !== "found") return [];
|
|
1279
|
+
return lookup.session.objects.filter((object) => object.name !== "main.jsonl.gz").map((object) => traceNameFromObject(object.name)).sort();
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* The store's record of one session, in the shape the bucket's meta.json
|
|
1283
|
+
* has: repository, commits, branch, author, and last update. The store
|
|
1284
|
+
* keeps no pull request number, so that stays null.
|
|
1285
|
+
*/
|
|
1286
|
+
async sessionMeta(sessionId) {
|
|
1287
|
+
const stored = await reachableSession(() => this.requireSession(sessionId));
|
|
1288
|
+
if (!stored) return null;
|
|
1289
|
+
return {
|
|
1290
|
+
session: stored.sessionId,
|
|
1291
|
+
repo: this.repositoryTarget.name,
|
|
1292
|
+
branch: stored.branch ?? null,
|
|
1293
|
+
pr: null,
|
|
1294
|
+
commits: [...stored.commits],
|
|
1295
|
+
author: stored.author ?? null,
|
|
1296
|
+
ts: stored.updatedAt
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
async sessionsForCommit(commit) {
|
|
1300
|
+
if (this.offline) return null;
|
|
1301
|
+
if (!commitShaSchema.safeParse(commit).success) return null;
|
|
1302
|
+
try {
|
|
1303
|
+
const sessions = [];
|
|
1304
|
+
let cursor;
|
|
1305
|
+
for (let page = 0; page < MAX_COMMIT_LISTING_PAGES; page += 1) {
|
|
1306
|
+
const response = await this.transport.listSessions(this.repositoryTarget.repositoryId, cursor === void 0 ? { commit } : {
|
|
1307
|
+
commit,
|
|
1308
|
+
cursor
|
|
1309
|
+
});
|
|
1310
|
+
sessions.push(...response.sessions.map((session) => session.sessionId));
|
|
1311
|
+
if (!response.nextCursor) break;
|
|
1312
|
+
cursor = response.nextCursor;
|
|
1313
|
+
}
|
|
1314
|
+
return sessions.length > 0 ? {
|
|
1315
|
+
sessions,
|
|
1316
|
+
pr: null,
|
|
1317
|
+
branch: null
|
|
1318
|
+
} : null;
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
this.reportFailure(error instanceof Error ? error : new Error(String(error)));
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Publishes one stable session snapshot. Consent and provenance are
|
|
1326
|
+
* settled before any transcript is compressed, so nothing is sent for a
|
|
1327
|
+
* session that may not leave the machine.
|
|
1328
|
+
*/
|
|
1329
|
+
async publish(input) {
|
|
1330
|
+
const target = this.repositoryTarget;
|
|
1331
|
+
const expected = traceRepoName(input.repo);
|
|
1332
|
+
if (expected.toLowerCase() !== target.name.toLowerCase()) throw new Error(`This checkout is ${target.name}, not ${expected}. A session is published only to its own repository's store.`);
|
|
1333
|
+
await requireTraceConsent(target, this.devHome);
|
|
1334
|
+
await requireTraceSessionProvenance(input.sessionId, target, this.devHome);
|
|
1335
|
+
const files = [];
|
|
1336
|
+
const omittedSubagents = [];
|
|
1337
|
+
for (const file of input.files) {
|
|
1338
|
+
const name = traceObjectName(file.name);
|
|
1339
|
+
if (file.name !== "main" && (files.length >= 64 || !traceObjectNameSchema.safeParse(name).success)) {
|
|
1340
|
+
omittedSubagents.push(file.name.replace(/\.jsonl$/, ""));
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
files.push({
|
|
1344
|
+
name,
|
|
1345
|
+
path: file.path
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
const compressed = [];
|
|
1349
|
+
try {
|
|
1350
|
+
for (const file of files) {
|
|
1351
|
+
const gzipped = await gzipToTemp(file.path);
|
|
1352
|
+
compressed.push({
|
|
1353
|
+
name: file.name,
|
|
1354
|
+
...gzipped
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
const manifest = compressed.map((object) => ({
|
|
1358
|
+
name: object.name,
|
|
1359
|
+
size: object.size,
|
|
1360
|
+
sha256: object.sha256
|
|
1361
|
+
}));
|
|
1362
|
+
const allCommits = [...new Set(input.commits?.filter((commit) => commitShaSchema.safeParse(commit).success) ?? await commitsForTraceSession(input.cwd, input.sessionId))];
|
|
1363
|
+
const commits = allCommits.slice(0, 200);
|
|
1364
|
+
const omitted = {
|
|
1365
|
+
subagents: omittedSubagents,
|
|
1366
|
+
commits: allCommits.length - commits.length
|
|
1367
|
+
};
|
|
1368
|
+
const labels = {
|
|
1369
|
+
branch: input.branch,
|
|
1370
|
+
author: input.author
|
|
1371
|
+
};
|
|
1372
|
+
this.lookups.delete(input.sessionId);
|
|
1373
|
+
const current = await this.lookupSessionLive(input.sessionId, true);
|
|
1374
|
+
if (current.status === "found" && sameObjects(manifest, current.session.objects)) {
|
|
1375
|
+
const completed = await this.completeUploadOnce(input.sessionId, current.session.uploadId, commits, labels);
|
|
1376
|
+
this.lookups.delete(input.sessionId);
|
|
1377
|
+
await clearTraceSyncFailure(input.sessionId, this.devHome).catch(() => void 0);
|
|
1378
|
+
return publishResult(compressed, "unchanged", target, completed, omitted);
|
|
1379
|
+
}
|
|
1380
|
+
const begun = await this.transport.beginUpload(target.repositoryId, input.sessionId, {
|
|
1381
|
+
harness: input.harness,
|
|
1382
|
+
objects: manifest
|
|
1383
|
+
});
|
|
1384
|
+
if (begun.storeId !== target.storeId) throw new Error(`The trace store changed while this session was being resolved. Run \`whiteboard trace allow .\` again.`);
|
|
1385
|
+
const mismatch = uploadManifestMismatch(manifest, begun.uploads);
|
|
1386
|
+
if (mismatch) throw new Error(mismatch);
|
|
1387
|
+
for (const upload of begun.uploads) {
|
|
1388
|
+
const object = compressed.find((entry) => entry.name === upload.name);
|
|
1389
|
+
if (!object) throw new Error(`The store offered ${upload.name} twice.`);
|
|
1390
|
+
await this.transport.putObject(upload, object.path);
|
|
1391
|
+
}
|
|
1392
|
+
const completed = await this.completeUploadOnce(input.sessionId, begun.uploadId, commits, labels);
|
|
1393
|
+
this.lookups.delete(input.sessionId);
|
|
1394
|
+
await clearTraceSyncFailure(input.sessionId, this.devHome).catch(() => void 0);
|
|
1395
|
+
return publishResult(compressed, "uploaded", target, completed, omitted);
|
|
1396
|
+
} finally {
|
|
1397
|
+
for (const object of compressed) await object.cleanup();
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/** Commits travel with the published snapshot; there is no separate index. */
|
|
1401
|
+
async associateCommits(_input) {
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
async requireSession(sessionId) {
|
|
1405
|
+
const lookup = await this.lookupSession(sessionId);
|
|
1406
|
+
if (lookup.status === "unreachable") throw new TraceStorageUnavailableError(lookup.error?.message ?? "The trace store did not answer.");
|
|
1407
|
+
if (lookup.status === "denied") throw new TraceStorageDeniedError(lookup.error.message);
|
|
1408
|
+
return lookup.status === "found" ? lookup.session : null;
|
|
1409
|
+
}
|
|
1410
|
+
lookupSession(sessionId) {
|
|
1411
|
+
const pending = this.lookups.get(sessionId);
|
|
1412
|
+
if (pending) return pending;
|
|
1413
|
+
const lookup = this.lookupSessionLive(sessionId);
|
|
1414
|
+
this.lookups.set(sessionId, lookup);
|
|
1415
|
+
return lookup;
|
|
1416
|
+
}
|
|
1417
|
+
async lookupSessionLive(sessionId, publishing = false) {
|
|
1418
|
+
if (this.offline) return {
|
|
1419
|
+
status: "unreachable",
|
|
1420
|
+
error: null
|
|
1421
|
+
};
|
|
1422
|
+
try {
|
|
1423
|
+
const session = (await this.transport.listSessions(this.repositoryTarget.repositoryId, { session: sessionId })).sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1424
|
+
return session ? {
|
|
1425
|
+
status: "found",
|
|
1426
|
+
session
|
|
1427
|
+
} : { status: "absent" };
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
1430
|
+
if (!(publishing && cause instanceof StoreApiError && cause.code === "forbidden")) this.reportFailure(cause);
|
|
1431
|
+
if (isStoreUnreachable(cause)) return {
|
|
1432
|
+
status: "unreachable",
|
|
1433
|
+
error: cause
|
|
1434
|
+
};
|
|
1435
|
+
if (cause instanceof StoreApiError && cause.code === "not_found") return { status: "absent" };
|
|
1436
|
+
return {
|
|
1437
|
+
status: "denied",
|
|
1438
|
+
error: cause
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Completes the upload, and once more when the first answer was lost on
|
|
1444
|
+
* the network: completion is idempotent, so a repeat returns the same
|
|
1445
|
+
* receipt. A store answer is never retried; `stale_upload` means another
|
|
1446
|
+
* upload of this session published first.
|
|
1447
|
+
*/
|
|
1448
|
+
async completeUploadOnce(sessionId, uploadId, commits, labels) {
|
|
1449
|
+
const complete = () => this.transport.completeUpload(this.repositoryTarget.repositoryId, sessionId, uploadId, {
|
|
1450
|
+
commits,
|
|
1451
|
+
branch: labels.branch,
|
|
1452
|
+
author: labels.author
|
|
1453
|
+
});
|
|
1454
|
+
try {
|
|
1455
|
+
return await complete();
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
if (error instanceof StoreApiError) throw syncStoreError(error, sessionId);
|
|
1458
|
+
try {
|
|
1459
|
+
return await complete();
|
|
1460
|
+
} catch (retryError) {
|
|
1461
|
+
throw retryError instanceof StoreApiError ? syncStoreError(retryError, sessionId) : retryError;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
reportFailure(cause) {
|
|
1466
|
+
const message = storeReadWarning(cause);
|
|
1467
|
+
if (message) this.warn(message);
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
/** A session lookup, or null when the store could not be reached or refused. */
|
|
1471
|
+
async function reachableSession(lookup) {
|
|
1472
|
+
try {
|
|
1473
|
+
return await lookup();
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
if (error instanceof TraceStorageUnavailableError || error instanceof TraceStorageDeniedError) return null;
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/** Most listing pages one commit lookup walks. */
|
|
1480
|
+
const MAX_COMMIT_LISTING_PAGES = 10;
|
|
1481
|
+
/** Whether the manifest names exactly the stored objects, byte for byte. */
|
|
1482
|
+
function sameObjects(manifest, stored) {
|
|
1483
|
+
if (manifest.length !== stored.length) return false;
|
|
1484
|
+
return manifest.every((object) => stored.some((candidate) => candidate.name === object.name && candidate.size === object.size && candidate.sha256 === object.sha256));
|
|
1485
|
+
}
|
|
1486
|
+
function publishResult(compressed, status, target, completed, omitted) {
|
|
1487
|
+
return {
|
|
1488
|
+
uploads: compressed.map((object) => ({
|
|
1489
|
+
blob: object.name === "main.jsonl.gz" ? "trace.jsonl" : `subagents/${traceNameFromObject(object.name)}.jsonl`,
|
|
1490
|
+
bytes_stored: object.size,
|
|
1491
|
+
status
|
|
1492
|
+
})),
|
|
1493
|
+
hosted: {
|
|
1494
|
+
repositoryId: target.repositoryId,
|
|
1495
|
+
storeId: target.storeId,
|
|
1496
|
+
uploadId: completed.uploadId,
|
|
1497
|
+
generation: completed.generation,
|
|
1498
|
+
complete: omitted.subagents.length === 0 && omitted.commits === 0,
|
|
1499
|
+
objects: completed.objects.map((object) => object.name),
|
|
1500
|
+
commits: completed.commits,
|
|
1501
|
+
omitted
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function syncStoreError(error, sessionId) {
|
|
1506
|
+
if (error.code === "stale_upload") return /* @__PURE__ */ new Error(`Another upload of this session finished first. Run \`whiteboard trace sync ${sessionId}\` again to publish the newer transcript.`);
|
|
1507
|
+
return error;
|
|
1508
|
+
}
|
|
1509
|
+
const FIELD_SEPARATOR$1 = "";
|
|
1510
|
+
/** The commits whose Agent-Session trailers name this session. */
|
|
1511
|
+
async function commitsForTraceSession(cwd, sessionId) {
|
|
1512
|
+
const result = await git(cwd, [
|
|
1513
|
+
"log",
|
|
1514
|
+
"--all",
|
|
1515
|
+
"--no-show-signature",
|
|
1516
|
+
`--format=%H${FIELD_SEPARATOR$1}%(trailers:key=Agent-Session,valueonly,separator=${FIELD_SEPARATOR$1})`
|
|
1517
|
+
], { allowFailure: true });
|
|
1518
|
+
if (!result.ok) return [];
|
|
1519
|
+
const commits = [];
|
|
1520
|
+
for (const line of result.stdout.split("\n")) {
|
|
1521
|
+
const [sha, ...trailers] = line.trim().split(FIELD_SEPARATOR$1);
|
|
1522
|
+
if (!commitShaSchema.safeParse(sha).success) continue;
|
|
1523
|
+
if (trailers.flatMap((value) => value.split("\n")).some((value) => value.trim() === sessionId) && !commits.includes(sha)) commits.push(sha);
|
|
1524
|
+
}
|
|
1525
|
+
return commits;
|
|
1526
|
+
}
|
|
1527
|
+
//#endregion
|
|
1528
|
+
//#region ../trace-core/src/trace-storage/resolve.ts
|
|
1529
|
+
function selectTraceStorage(scope = {}) {
|
|
1530
|
+
const env = scope.env ?? process.env;
|
|
1531
|
+
const config = readTraceConfigFile(scope);
|
|
1532
|
+
if (config.error) return {
|
|
1533
|
+
mode: "none",
|
|
1534
|
+
explicit: false,
|
|
1535
|
+
config,
|
|
1536
|
+
s3: null,
|
|
1537
|
+
hosted: null,
|
|
1538
|
+
error: config.error
|
|
1539
|
+
};
|
|
1540
|
+
let s3;
|
|
1541
|
+
try {
|
|
1542
|
+
s3 = resolveS3Setup(scope);
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
return {
|
|
1545
|
+
mode: "none",
|
|
1546
|
+
explicit: false,
|
|
1547
|
+
config,
|
|
1548
|
+
s3: null,
|
|
1549
|
+
hosted: null,
|
|
1550
|
+
error: errorMessage(error)
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
const mock = isS3MockMode(env);
|
|
1554
|
+
const file = config.config;
|
|
1555
|
+
const pointer = currentStore(file);
|
|
1556
|
+
const hosted = hasHostedStore(file) ? { origin: hostedOrigin(file) } : null;
|
|
1557
|
+
const hasS3 = s3Store(file) !== null || s3.credentials !== null || mock;
|
|
1558
|
+
if (pointer === "hosted") return {
|
|
1559
|
+
mode: "hosted",
|
|
1560
|
+
explicit: true,
|
|
1561
|
+
config,
|
|
1562
|
+
s3,
|
|
1563
|
+
hosted: hosted ?? { origin: hostedOrigin(file) }
|
|
1564
|
+
};
|
|
1565
|
+
if (pointer === "s3") {
|
|
1566
|
+
if (!hasS3) return {
|
|
1567
|
+
mode: "s3",
|
|
1568
|
+
explicit: true,
|
|
1569
|
+
config,
|
|
1570
|
+
s3,
|
|
1571
|
+
hosted,
|
|
1572
|
+
error: `S3 trace storage is selected but no bucket credentials are configured. Run \`whiteboard trace storage use s3 --endpoint <url> --bucket <name> --key <id> --secret <secret>\`.`
|
|
1573
|
+
};
|
|
1574
|
+
return {
|
|
1575
|
+
mode: "s3",
|
|
1576
|
+
explicit: true,
|
|
1577
|
+
config,
|
|
1578
|
+
s3,
|
|
1579
|
+
hosted
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
if (hasS3) return {
|
|
1583
|
+
mode: "s3",
|
|
1584
|
+
explicit: false,
|
|
1585
|
+
config,
|
|
1586
|
+
s3,
|
|
1587
|
+
hosted
|
|
1588
|
+
};
|
|
1589
|
+
if (hosted) return {
|
|
1590
|
+
mode: "hosted",
|
|
1591
|
+
explicit: false,
|
|
1592
|
+
config,
|
|
1593
|
+
s3,
|
|
1594
|
+
hosted
|
|
1595
|
+
};
|
|
1596
|
+
return {
|
|
1597
|
+
mode: "none",
|
|
1598
|
+
explicit: false,
|
|
1599
|
+
config,
|
|
1600
|
+
s3,
|
|
1601
|
+
hosted
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* The store to use, or null when no remote storage is configured. Throws a
|
|
1606
|
+
* TraceConfigurationError instead of silently selecting another destination
|
|
1607
|
+
* when the configuration is malformed or incomplete.
|
|
1608
|
+
*/
|
|
1609
|
+
async function resolveTraceStorage(input = {}) {
|
|
1610
|
+
const selection = selectTraceStorage(input);
|
|
1611
|
+
if (selection.error) throw new TraceConfigurationError(selection.error);
|
|
1612
|
+
const mode = input.override ?? selection.mode;
|
|
1613
|
+
if (mode === "none") return null;
|
|
1614
|
+
if (mode === "s3") return s3Storage(selection, input);
|
|
1615
|
+
return hostedStorage(selection, input);
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Names the destination a capture attempt was started for, so a detached
|
|
1619
|
+
* trace sync can refuse to run once the selection changed.
|
|
1620
|
+
*/
|
|
1621
|
+
function traceStorageExpectation(scope = {}) {
|
|
1622
|
+
const selection = selectTraceStorage(scope);
|
|
1623
|
+
if (selection.error || selection.mode === "none") return "none";
|
|
1624
|
+
if (selection.mode === "hosted") return `hosted:${selection.hosted?.origin ?? ""}`;
|
|
1625
|
+
const env = scope.env ?? process.env;
|
|
1626
|
+
if (isS3MockMode(env)) return `s3:${s3CacheIdentity(null, s3MockRoot(env))}`;
|
|
1627
|
+
return `s3:${s3CacheIdentity(requireS3Credentials(selection), null)}`;
|
|
1628
|
+
}
|
|
1629
|
+
function requireS3Credentials(selection) {
|
|
1630
|
+
const credentials = selection.s3?.credentials;
|
|
1631
|
+
if (!credentials) throw new TraceConfigurationError("S3 trace storage was requested but no bucket credentials are configured.");
|
|
1632
|
+
return credentials;
|
|
1633
|
+
}
|
|
1634
|
+
/** One human line for `trace status` and `trace storage use`. */
|
|
1635
|
+
function describeSelection(selection) {
|
|
1636
|
+
if (selection.error) return `error (${selection.error})`;
|
|
1637
|
+
if (selection.mode === "hosted") return `hosted (${selection.hosted?.origin ?? "unknown origin"})`;
|
|
1638
|
+
if (selection.mode === "none") return "none configured";
|
|
1639
|
+
const setup = selection.s3;
|
|
1640
|
+
const credentials = setup?.credentials;
|
|
1641
|
+
const where = credentials ? `bucket "${credentials.bucket}" at ${credentials.endpoint}` : "mock bucket";
|
|
1642
|
+
const source = setup?.source === "profile" ? "config.json profile" : setup?.source === "legacy-file" ? `legacy env file ${setup.envPath}` : setup?.source === "process-env" ? "process environment" : "test mode";
|
|
1643
|
+
const overrides = setup && setup.overrides.length > 0 && setup.source !== "process-env" ? `; environment overrides: ${setup.overrides.join(", ")}` : "";
|
|
1644
|
+
return `S3/R2 ${where} (${selection.explicit ? "selected" : "legacy configuration"}; credentials from ${source}${overrides})`;
|
|
1645
|
+
}
|
|
1646
|
+
/** The bucket store; its module loads only when a bucket is selected. */
|
|
1647
|
+
async function s3Storage(selection, scope) {
|
|
1648
|
+
const env = scope.env ?? process.env;
|
|
1649
|
+
const S3TraceStorage = await loadS3TraceStorage();
|
|
1650
|
+
if (isS3MockMode(env)) return S3TraceStorage.fromEnvironment(scope);
|
|
1651
|
+
return S3TraceStorage.fromCredentials(requireS3Credentials(selection), env);
|
|
1652
|
+
}
|
|
1653
|
+
async function hostedStorage(selection, input) {
|
|
1654
|
+
const origin = selection.hosted?.origin;
|
|
1655
|
+
if (!origin) throw new TraceConfigurationError(`Hosted trace storage is not configured. Run \`whiteboard trace allow .\` or \`whiteboard trace storage use hosted\`.`);
|
|
1656
|
+
const write = input.purpose === "write";
|
|
1657
|
+
const storage = await HostedTraceStorage.resolve({
|
|
1658
|
+
cwd: input.cwd ?? process.cwd(),
|
|
1659
|
+
origin,
|
|
1660
|
+
write,
|
|
1661
|
+
env: input.env,
|
|
1662
|
+
homeDir: input.homeDir,
|
|
1663
|
+
onWarning: input.onWarning
|
|
1664
|
+
});
|
|
1665
|
+
if (!storage) throw new TraceConfigurationError(`Hosted trace storage needs a login and a GitHub checkout with a trace store. Run \`whiteboard login\`, \`whiteboard trace store create\`, and \`whiteboard trace allow .\`.`);
|
|
1666
|
+
return storage;
|
|
1667
|
+
}
|
|
1668
|
+
/** Whether the selected store can be used; a configuration error counts as no. */
|
|
1669
|
+
function isTraceStorageConfigured(scope = {}) {
|
|
1670
|
+
const selection = selectTraceStorage(scope);
|
|
1671
|
+
return !selection.error && selection.mode !== "none";
|
|
1672
|
+
}
|
|
1673
|
+
/** Loads the direct-store implementation on demand, without opening a store. */
|
|
1674
|
+
async function loadS3TraceStorage() {
|
|
1675
|
+
return (await import("./s3-SzLpeCG6.js")).S3TraceStorage;
|
|
1676
|
+
}
|
|
1677
|
+
//#endregion
|
|
1678
|
+
//#region ../trace-core/src/agent-trace-parser.ts
|
|
1679
|
+
function extractTraceEventText(event) {
|
|
1680
|
+
return extractTraceEventText$1(event);
|
|
1681
|
+
}
|
|
1682
|
+
const ACTIVE_GAP_LIMIT_MS = 6e5;
|
|
1683
|
+
function computeActiveMs(events) {
|
|
1684
|
+
let total = 0;
|
|
1685
|
+
let previous = null;
|
|
1686
|
+
let sawTimestamp = false;
|
|
1687
|
+
for (const event of events) {
|
|
1688
|
+
const at = timestampOf(event);
|
|
1689
|
+
if (at === null) continue;
|
|
1690
|
+
sawTimestamp = true;
|
|
1691
|
+
if (previous !== null && at > previous) total += Math.min(at - previous, ACTIVE_GAP_LIMIT_MS);
|
|
1692
|
+
previous = at;
|
|
1693
|
+
}
|
|
1694
|
+
return sawTimestamp ? total : null;
|
|
1695
|
+
}
|
|
1696
|
+
const OUTPUT_LIMIT = 2e4;
|
|
1697
|
+
const INPUT_LIMIT = 4e3;
|
|
1698
|
+
const TITLE_LIMIT = 160;
|
|
1699
|
+
function parseAgentTraceJsonl(jsonl, options) {
|
|
1700
|
+
const records = [];
|
|
1701
|
+
for (const line of jsonl.split("\n")) {
|
|
1702
|
+
const trimmed = line.trim();
|
|
1703
|
+
if (!trimmed) continue;
|
|
1704
|
+
try {
|
|
1705
|
+
records.push(parseJsonText(trimmed));
|
|
1706
|
+
} catch {}
|
|
1707
|
+
}
|
|
1708
|
+
const firstType = jsonObject(records[0])?.type;
|
|
1709
|
+
const parsed = firstType === "session_meta" ? parseCodexRecords(records) : firstType === "session" ? parsePiRecords(records) : firstType === "opencode_session" ? parseOpenCodeRecords(records) : parseClaudeRecords(records, options);
|
|
1710
|
+
parsed.activeMs = computeActiveMs(parsed.events);
|
|
1711
|
+
return parsed;
|
|
1712
|
+
}
|
|
1713
|
+
function truncate(text, limit) {
|
|
1714
|
+
if (text.length <= limit) return text;
|
|
1715
|
+
const half = Math.floor(limit / 2);
|
|
1716
|
+
return `${text.slice(0, half)}\n… (+${text.length - limit} characters omitted) …\n${text.slice(-half)}`;
|
|
1717
|
+
}
|
|
1718
|
+
function compactLine(text, limit = TITLE_LIMIT) {
|
|
1719
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
1720
|
+
if (collapsed.length <= limit) return collapsed;
|
|
1721
|
+
return `${collapsed.slice(0, limit - 1)}…`;
|
|
1722
|
+
}
|
|
1723
|
+
/** File titles keep their tail: the filename matters more than the prefix. */
|
|
1724
|
+
function compactFileLine(text, limit = TITLE_LIMIT) {
|
|
1725
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
1726
|
+
if (collapsed.length <= limit) return collapsed;
|
|
1727
|
+
return `…${collapsed.slice(-(limit - 1))}`;
|
|
1728
|
+
}
|
|
1729
|
+
function relativizePath(filePath, cwd) {
|
|
1730
|
+
if (!cwd) return filePath;
|
|
1731
|
+
const relative = path.relative(cwd, filePath);
|
|
1732
|
+
if (!relative || relative.startsWith("..")) return filePath;
|
|
1733
|
+
return relative;
|
|
1734
|
+
}
|
|
1735
|
+
function timestampOf(event) {
|
|
1736
|
+
if (!event || event.kind === "separator" || !event.at) return null;
|
|
1737
|
+
const value = Date.parse(event.at);
|
|
1738
|
+
return Number.isFinite(value) ? value : null;
|
|
1739
|
+
}
|
|
1740
|
+
function claudeRecord(value) {
|
|
1741
|
+
const message = jsonObject(value.message);
|
|
1742
|
+
const toolUseResult = jsonObject(value.toolUseResult);
|
|
1743
|
+
return {
|
|
1744
|
+
type: jsonString(value.type),
|
|
1745
|
+
isSidechain: jsonBoolean(value.isSidechain),
|
|
1746
|
+
isMeta: jsonBoolean(value.isMeta),
|
|
1747
|
+
timestamp: jsonString(value.timestamp),
|
|
1748
|
+
cwd: jsonString(value.cwd),
|
|
1749
|
+
aiTitle: jsonString(value.aiTitle),
|
|
1750
|
+
customTitle: jsonString(value.customTitle),
|
|
1751
|
+
message: message && {
|
|
1752
|
+
role: jsonString(message.role),
|
|
1753
|
+
content: message.content
|
|
1754
|
+
},
|
|
1755
|
+
toolUseResult: toolUseResult && {
|
|
1756
|
+
filePath: jsonString(toolUseResult.filePath),
|
|
1757
|
+
structuredPatch: toolUseResult.structuredPatch
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
function claudeContentBlock(value) {
|
|
1762
|
+
if (!isJsonObject(value)) return void 0;
|
|
1763
|
+
return {
|
|
1764
|
+
type: jsonString(value.type),
|
|
1765
|
+
text: jsonString(value.text),
|
|
1766
|
+
thinking: jsonString(value.thinking),
|
|
1767
|
+
id: jsonString(value.id),
|
|
1768
|
+
name: jsonString(value.name),
|
|
1769
|
+
input: value.input,
|
|
1770
|
+
tool_use_id: jsonString(value.tool_use_id),
|
|
1771
|
+
content: value.content,
|
|
1772
|
+
is_error: jsonBoolean(value.is_error)
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function claudeContentBlocks(content) {
|
|
1776
|
+
const text = jsonString(content);
|
|
1777
|
+
if (text !== void 0) return [{
|
|
1778
|
+
type: "text",
|
|
1779
|
+
text
|
|
1780
|
+
}];
|
|
1781
|
+
return jsonArray(content)?.flatMap((block) => claudeContentBlock(block) ?? []) ?? [];
|
|
1782
|
+
}
|
|
1783
|
+
const CLAUDE_NOISE_PATTERNS = [
|
|
1784
|
+
/^\[SYSTEM NOTIFICATION/,
|
|
1785
|
+
/^\[Request interrupted/,
|
|
1786
|
+
/^This session is being continued/,
|
|
1787
|
+
/^<task-notification>/,
|
|
1788
|
+
/^<local-command-stdout>/
|
|
1789
|
+
];
|
|
1790
|
+
function cleanClaudeUserText(text) {
|
|
1791
|
+
let cleaned = text;
|
|
1792
|
+
cleaned = cleaned.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "");
|
|
1793
|
+
cleaned = cleaned.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "");
|
|
1794
|
+
const commandName = /<command-name>([\s\S]*?)<\/command-name>/.exec(cleaned)?.[1];
|
|
1795
|
+
if (commandName) {
|
|
1796
|
+
const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(cleaned)?.[1] ?? "";
|
|
1797
|
+
return `${commandName.trim().startsWith("/") ? "" : "/"}${commandName.trim()} ${args.trim()}`.trim();
|
|
1798
|
+
}
|
|
1799
|
+
cleaned = cleaned.trim();
|
|
1800
|
+
if (!cleaned) return null;
|
|
1801
|
+
for (const pattern of CLAUDE_NOISE_PATTERNS) if (pattern.test(cleaned)) return null;
|
|
1802
|
+
return cleaned;
|
|
1803
|
+
}
|
|
1804
|
+
function claudeResultText(content) {
|
|
1805
|
+
const text = jsonString(content);
|
|
1806
|
+
if (text !== void 0) return text;
|
|
1807
|
+
const parts = [];
|
|
1808
|
+
for (const block of claudeContentBlocks(content)) if (block.type === "text" && block.text !== void 0) parts.push(block.text);
|
|
1809
|
+
else if (block.type === "image") parts.push("[image]");
|
|
1810
|
+
return parts.join("\n");
|
|
1811
|
+
}
|
|
1812
|
+
function patchCounts(patch) {
|
|
1813
|
+
const hunks = jsonArray(patch);
|
|
1814
|
+
if (!hunks) return null;
|
|
1815
|
+
let additions = 0;
|
|
1816
|
+
let deletions = 0;
|
|
1817
|
+
for (const hunk of hunks) for (const line of jsonArray(jsonObject(hunk)?.lines) ?? []) {
|
|
1818
|
+
const text = jsonString(line);
|
|
1819
|
+
if (text === void 0) continue;
|
|
1820
|
+
if (text.startsWith("+")) additions += 1;
|
|
1821
|
+
else if (text.startsWith("-")) deletions += 1;
|
|
1822
|
+
}
|
|
1823
|
+
return {
|
|
1824
|
+
additions,
|
|
1825
|
+
deletions
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
function claudeToolEvent(block, at, cwd) {
|
|
1829
|
+
const name = block.name ?? "tool";
|
|
1830
|
+
const input = isJsonObject(block.input) ? block.input : {};
|
|
1831
|
+
const event = {
|
|
1832
|
+
kind: "tool",
|
|
1833
|
+
tool: name,
|
|
1834
|
+
verb: "Called",
|
|
1835
|
+
title: name,
|
|
1836
|
+
at
|
|
1837
|
+
};
|
|
1838
|
+
const inputText = (key) => jsonString(input[key]) ?? null;
|
|
1839
|
+
const filePath = inputText("file_path") ?? inputText("path") ?? inputText("notebook_path");
|
|
1840
|
+
switch (name) {
|
|
1841
|
+
case "Bash": {
|
|
1842
|
+
const command = inputText("command") ?? "";
|
|
1843
|
+
event.verb = "Ran";
|
|
1844
|
+
event.title = compactLine(command);
|
|
1845
|
+
event.command = truncate(command, INPUT_LIMIT);
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
case "Edit":
|
|
1849
|
+
case "MultiEdit":
|
|
1850
|
+
case "NotebookEdit":
|
|
1851
|
+
event.verb = "Edited";
|
|
1852
|
+
event.title = filePath ? relativizePath(filePath, cwd) : name;
|
|
1853
|
+
if (filePath) event.filePath = relativizePath(filePath, cwd);
|
|
1854
|
+
break;
|
|
1855
|
+
case "Write":
|
|
1856
|
+
event.verb = "Wrote";
|
|
1857
|
+
event.title = filePath ? relativizePath(filePath, cwd) : name;
|
|
1858
|
+
if (filePath) event.filePath = relativizePath(filePath, cwd);
|
|
1859
|
+
break;
|
|
1860
|
+
case "Read":
|
|
1861
|
+
event.verb = "Read";
|
|
1862
|
+
event.title = filePath ? relativizePath(filePath, cwd) : name;
|
|
1863
|
+
if (filePath) event.filePath = relativizePath(filePath, cwd);
|
|
1864
|
+
break;
|
|
1865
|
+
case "Grep":
|
|
1866
|
+
case "Glob": {
|
|
1867
|
+
const pattern = inputText("pattern") ?? "";
|
|
1868
|
+
event.verb = "Searched";
|
|
1869
|
+
event.title = compactLine(inputText("path") ? `${pattern} in ${relativizePath(inputText("path") ?? "", cwd)}` : pattern);
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
case "Task":
|
|
1873
|
+
case "Agent":
|
|
1874
|
+
event.verb = "Ran agent";
|
|
1875
|
+
event.title = compactLine(inputText("description") ?? inputText("prompt") ?? "subagent");
|
|
1876
|
+
break;
|
|
1877
|
+
case "WebFetch":
|
|
1878
|
+
event.verb = "Fetched";
|
|
1879
|
+
event.title = compactLine(inputText("url") ?? "");
|
|
1880
|
+
break;
|
|
1881
|
+
case "WebSearch":
|
|
1882
|
+
event.verb = "Searched web";
|
|
1883
|
+
event.title = compactLine(inputText("query") ?? "");
|
|
1884
|
+
break;
|
|
1885
|
+
case "TodoWrite":
|
|
1886
|
+
event.verb = "Updated";
|
|
1887
|
+
event.title = "task list";
|
|
1888
|
+
break;
|
|
1889
|
+
case "Skill":
|
|
1890
|
+
event.verb = "Loaded skill";
|
|
1891
|
+
event.title = compactLine(inputText("skill") ?? "");
|
|
1892
|
+
break;
|
|
1893
|
+
default: if (name.startsWith("mcp__")) {
|
|
1894
|
+
event.verb = "Called";
|
|
1895
|
+
event.title = name.replace(/^mcp__/, "").replace(/__/g, " · ");
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (!event.command) {
|
|
1899
|
+
const pretty = JSON.stringify(input, null, 2);
|
|
1900
|
+
if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
|
|
1901
|
+
}
|
|
1902
|
+
return event;
|
|
1903
|
+
}
|
|
1904
|
+
function parseClaudeRecords(records, options) {
|
|
1905
|
+
const events = [];
|
|
1906
|
+
const pendingTools = /* @__PURE__ */ new Map();
|
|
1907
|
+
let title = null;
|
|
1908
|
+
let startedAt = null;
|
|
1909
|
+
let endedAt = null;
|
|
1910
|
+
let cwd = null;
|
|
1911
|
+
let userTurns = 0;
|
|
1912
|
+
let toolCalls = 0;
|
|
1913
|
+
for (const raw of records) {
|
|
1914
|
+
if (!isJsonObject(raw)) continue;
|
|
1915
|
+
const record = claudeRecord(raw);
|
|
1916
|
+
if (record.type === "ai-title" && record.aiTitle !== void 0) {
|
|
1917
|
+
title ??= record.aiTitle;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (record.type === "custom-title" && record.customTitle !== void 0) {
|
|
1921
|
+
title = record.customTitle;
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
if (record.type !== "user" && record.type !== "assistant") continue;
|
|
1925
|
+
if (!options?.isSubagent && record.isSidechain) continue;
|
|
1926
|
+
cwd ??= record.cwd ?? null;
|
|
1927
|
+
const at = record.timestamp;
|
|
1928
|
+
if (at) {
|
|
1929
|
+
startedAt ??= at;
|
|
1930
|
+
endedAt = at;
|
|
1931
|
+
}
|
|
1932
|
+
const blocks = claudeContentBlocks(record.message?.content);
|
|
1933
|
+
if (record.type === "user") {
|
|
1934
|
+
let sawToolResult = false;
|
|
1935
|
+
for (const block of blocks) {
|
|
1936
|
+
if (block.type !== "tool_result") continue;
|
|
1937
|
+
sawToolResult = true;
|
|
1938
|
+
const pending = block.tool_use_id ? pendingTools.get(block.tool_use_id) : void 0;
|
|
1939
|
+
if (!pending) continue;
|
|
1940
|
+
const resultText = claudeResultText(block.content).trim();
|
|
1941
|
+
if (resultText) pending.output = truncate(resultText, OUTPUT_LIMIT);
|
|
1942
|
+
if (block.is_error) pending.error = true;
|
|
1943
|
+
const counts = patchCounts(record.toolUseResult?.structuredPatch);
|
|
1944
|
+
if (counts) {
|
|
1945
|
+
pending.additions = counts.additions;
|
|
1946
|
+
pending.deletions = counts.deletions;
|
|
1947
|
+
}
|
|
1948
|
+
if (record.toolUseResult?.filePath && !pending.filePath) pending.filePath = relativizePath(record.toolUseResult.filePath, cwd);
|
|
1949
|
+
if (block.tool_use_id) pendingTools.delete(block.tool_use_id);
|
|
1950
|
+
}
|
|
1951
|
+
if (sawToolResult || record.isMeta) continue;
|
|
1952
|
+
const cleaned = cleanClaudeUserText(blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n"));
|
|
1953
|
+
if (!cleaned) continue;
|
|
1954
|
+
userTurns += 1;
|
|
1955
|
+
events.push({
|
|
1956
|
+
kind: "user",
|
|
1957
|
+
text: cleaned,
|
|
1958
|
+
at
|
|
1959
|
+
});
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
for (const block of blocks) if (block.type === "thinking" && block.thinking !== void 0) {
|
|
1963
|
+
const trimmed = block.thinking.trim();
|
|
1964
|
+
if (trimmed) events.push({
|
|
1965
|
+
kind: "assistant",
|
|
1966
|
+
markdown: trimmed,
|
|
1967
|
+
thinking: true,
|
|
1968
|
+
at
|
|
1969
|
+
});
|
|
1970
|
+
} else if (block.type === "text" && block.text !== void 0) {
|
|
1971
|
+
const trimmed = block.text.trim();
|
|
1972
|
+
if (trimmed) events.push({
|
|
1973
|
+
kind: "assistant",
|
|
1974
|
+
markdown: trimmed,
|
|
1975
|
+
at
|
|
1976
|
+
});
|
|
1977
|
+
} else if (block.type === "tool_use") {
|
|
1978
|
+
const event = claudeToolEvent(block, at, cwd);
|
|
1979
|
+
toolCalls += 1;
|
|
1980
|
+
events.push(event);
|
|
1981
|
+
if (block.id) pendingTools.set(block.id, event);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return {
|
|
1985
|
+
harness: "claude-code",
|
|
1986
|
+
title,
|
|
1987
|
+
events,
|
|
1988
|
+
startedAt,
|
|
1989
|
+
endedAt,
|
|
1990
|
+
activeMs: null,
|
|
1991
|
+
userTurns,
|
|
1992
|
+
toolCalls
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
function codexTextBlocks(value) {
|
|
1996
|
+
return jsonArray(value)?.flatMap((block) => {
|
|
1997
|
+
const record = jsonObject(block);
|
|
1998
|
+
return record ? [{
|
|
1999
|
+
type: jsonString(record.type),
|
|
2000
|
+
text: jsonString(record.text)
|
|
2001
|
+
}] : [];
|
|
2002
|
+
}) ?? [];
|
|
2003
|
+
}
|
|
2004
|
+
function codexPayload(value) {
|
|
2005
|
+
return {
|
|
2006
|
+
type: jsonString(value.type),
|
|
2007
|
+
role: jsonString(value.role),
|
|
2008
|
+
content: codexTextBlocks(value.content),
|
|
2009
|
+
name: jsonString(value.name),
|
|
2010
|
+
arguments: value.arguments,
|
|
2011
|
+
input: value.input,
|
|
2012
|
+
call_id: jsonString(value.call_id),
|
|
2013
|
+
output: value.output,
|
|
2014
|
+
cwd: jsonString(value.cwd),
|
|
2015
|
+
timestamp: jsonString(value.timestamp),
|
|
2016
|
+
message: jsonString(value.message),
|
|
2017
|
+
query: jsonString(value.query),
|
|
2018
|
+
stdout: jsonString(value.stdout),
|
|
2019
|
+
success: jsonBoolean(value.success),
|
|
2020
|
+
changes: jsonObject(value.changes),
|
|
2021
|
+
invocation: jsonObject(value.invocation),
|
|
2022
|
+
result: value.result,
|
|
2023
|
+
command: value.command,
|
|
2024
|
+
aggregated_output: jsonString(value.aggregated_output),
|
|
2025
|
+
exit_code: jsonNumber(value.exit_code)
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
function codexRecord(value) {
|
|
2029
|
+
const payload = jsonObject(value.payload);
|
|
2030
|
+
return {
|
|
2031
|
+
type: jsonString(value.type),
|
|
2032
|
+
timestamp: jsonString(value.timestamp),
|
|
2033
|
+
payload: payload && codexPayload(payload)
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
const CODEX_USER_NOISE_PREFIXES = [
|
|
2037
|
+
"<recommended_plugins",
|
|
2038
|
+
"<permissions",
|
|
2039
|
+
"<environment_context",
|
|
2040
|
+
"<environments_instructions",
|
|
2041
|
+
"<user_instructions",
|
|
2042
|
+
"<user_shell_command",
|
|
2043
|
+
"<turn_aborted",
|
|
2044
|
+
"<subagent_notification",
|
|
2045
|
+
"<codex_internal_context",
|
|
2046
|
+
"<goal_context",
|
|
2047
|
+
"<codex_delegation",
|
|
2048
|
+
"<external_",
|
|
2049
|
+
"<skills_instructions",
|
|
2050
|
+
"<skill>",
|
|
2051
|
+
"<apps_instructions",
|
|
2052
|
+
"<plugins_instructions",
|
|
2053
|
+
"<tools>",
|
|
2054
|
+
"<collaboration_mode",
|
|
2055
|
+
"<multi_agent_mode",
|
|
2056
|
+
"<multi_agent_role",
|
|
2057
|
+
"<realtime_conversation",
|
|
2058
|
+
"<context_window",
|
|
2059
|
+
"<turn_context",
|
|
2060
|
+
"<model_switch",
|
|
2061
|
+
"<personality_spec",
|
|
2062
|
+
"<token_budget",
|
|
2063
|
+
"<rollout_budget",
|
|
2064
|
+
"<git_attribution",
|
|
2065
|
+
"<ENVIRONMENT",
|
|
2066
|
+
"# AGENTS"
|
|
2067
|
+
];
|
|
2068
|
+
function codexText(payload) {
|
|
2069
|
+
return (payload.content ?? []).flatMap((block) => (block.type === "input_text" || block.type === "output_text") && block.text !== void 0 ? [block.text] : []).join("\n").trim();
|
|
2070
|
+
}
|
|
2071
|
+
function codexPatchSummary(patch, cwd) {
|
|
2072
|
+
const files = [];
|
|
2073
|
+
let additions = 0;
|
|
2074
|
+
let deletions = 0;
|
|
2075
|
+
for (const line of patch.split("\n")) {
|
|
2076
|
+
const header = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line);
|
|
2077
|
+
if (header) {
|
|
2078
|
+
files.push(relativizePath(header[1].trim(), cwd));
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
if (line.startsWith("***")) continue;
|
|
2082
|
+
if (line.startsWith("+")) additions += 1;
|
|
2083
|
+
else if (line.startsWith("-")) deletions += 1;
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
filePath: files[0],
|
|
2087
|
+
additions,
|
|
2088
|
+
deletions,
|
|
2089
|
+
files
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
const CODE_MODE_FILE_PATTERN = /\*\*\* (?:Add|Update|Delete) File: ([^\n\\"`]+)/g;
|
|
2093
|
+
function unifiedDiffCounts(diff) {
|
|
2094
|
+
let additions = 0;
|
|
2095
|
+
let deletions = 0;
|
|
2096
|
+
for (const line of diff.split("\n")) if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
|
|
2097
|
+
else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
|
|
2098
|
+
return {
|
|
2099
|
+
additions,
|
|
2100
|
+
deletions
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
function codexValueText(value) {
|
|
2104
|
+
const text = jsonString(value);
|
|
2105
|
+
if (text !== void 0) return text;
|
|
2106
|
+
if (value === null || value === void 0) return "";
|
|
2107
|
+
return JSON.stringify(value);
|
|
2108
|
+
}
|
|
2109
|
+
function codexPatchEvent(payload, at, cwd) {
|
|
2110
|
+
const changes = payload.changes ?? {};
|
|
2111
|
+
const paths = Object.keys(changes);
|
|
2112
|
+
if (paths.length === 0) return null;
|
|
2113
|
+
let additions = 0;
|
|
2114
|
+
let deletions = 0;
|
|
2115
|
+
const relativePaths = paths.map((filePath) => relativizePath(filePath, cwd));
|
|
2116
|
+
for (const filePath of paths) {
|
|
2117
|
+
const change = jsonObject(changes[filePath]) ?? {};
|
|
2118
|
+
const content = jsonString(change.content);
|
|
2119
|
+
const unifiedDiff = jsonString(change.unified_diff);
|
|
2120
|
+
if (change.type === "add" && content !== void 0) additions += content.split("\n").length;
|
|
2121
|
+
else if (change.type === "delete" && content !== void 0) deletions += content.split("\n").length;
|
|
2122
|
+
else if (unifiedDiff !== void 0) {
|
|
2123
|
+
const counts = unifiedDiffCounts(unifiedDiff);
|
|
2124
|
+
additions += counts.additions;
|
|
2125
|
+
deletions += counts.deletions;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
const single = paths.length === 1 ? jsonObject(changes[paths[0]]) : null;
|
|
2129
|
+
const event = {
|
|
2130
|
+
kind: "tool",
|
|
2131
|
+
tool: "apply_patch",
|
|
2132
|
+
verb: single?.type === "add" ? "Added" : single?.type === "delete" ? "Deleted" : "Edited",
|
|
2133
|
+
title: compactFileLine(relativePaths.join(", ")),
|
|
2134
|
+
filePath: relativePaths[0],
|
|
2135
|
+
additions,
|
|
2136
|
+
deletions,
|
|
2137
|
+
at
|
|
2138
|
+
};
|
|
2139
|
+
const diffs = paths.flatMap((filePath) => jsonString(jsonObject(changes[filePath])?.unified_diff) ?? []);
|
|
2140
|
+
if (diffs.length > 0) event.input = truncate(diffs.join("\n"), INPUT_LIMIT);
|
|
2141
|
+
if (payload.stdout) event.output = truncate(payload.stdout, OUTPUT_LIMIT);
|
|
2142
|
+
if (payload.success === false) event.error = true;
|
|
2143
|
+
return event;
|
|
2144
|
+
}
|
|
2145
|
+
function codexMcpEvent(payload, at) {
|
|
2146
|
+
const invocation = payload.invocation ?? {};
|
|
2147
|
+
const server = jsonString(invocation.server) ?? "mcp";
|
|
2148
|
+
const tool = jsonString(invocation.tool) ?? "tool";
|
|
2149
|
+
const argumentsTitle = jsonString(jsonObject(invocation.arguments)?.title);
|
|
2150
|
+
const title = argumentsTitle === void 0 ? `${server} · ${tool}` : `${server} · ${tool} — ${argumentsTitle}`;
|
|
2151
|
+
const event = {
|
|
2152
|
+
kind: "tool",
|
|
2153
|
+
tool: `${server}.${tool}`,
|
|
2154
|
+
verb: "Called",
|
|
2155
|
+
title: compactLine(title),
|
|
2156
|
+
at
|
|
2157
|
+
};
|
|
2158
|
+
const input = codexValueText(invocation.arguments);
|
|
2159
|
+
if (input && input !== "{}") event.input = truncate(input, INPUT_LIMIT);
|
|
2160
|
+
const result = jsonObject(payload.result);
|
|
2161
|
+
const ok = jsonObject(result?.Ok);
|
|
2162
|
+
if (ok?.content) {
|
|
2163
|
+
const text = codexTextBlocks(ok.content).flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n").trim();
|
|
2164
|
+
if (text) event.output = truncate(text, OUTPUT_LIMIT);
|
|
2165
|
+
if (ok.isError) event.error = true;
|
|
2166
|
+
} else if (result && "Err" in result) {
|
|
2167
|
+
event.error = true;
|
|
2168
|
+
event.output = truncate(codexValueText(result.Err), OUTPUT_LIMIT);
|
|
2169
|
+
}
|
|
2170
|
+
return event;
|
|
2171
|
+
}
|
|
2172
|
+
function codexCodeModeEvent(event, source, cwd, skipPatches) {
|
|
2173
|
+
const cmdMatch = /"cmd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(source);
|
|
2174
|
+
if (cmdMatch) {
|
|
2175
|
+
let command = cmdMatch[1];
|
|
2176
|
+
try {
|
|
2177
|
+
command = jsonString(parseJsonText(`"${cmdMatch[1]}"`)) ?? command;
|
|
2178
|
+
} catch {}
|
|
2179
|
+
event.verb = "Ran";
|
|
2180
|
+
event.title = compactLine(command);
|
|
2181
|
+
event.command = truncate(command, INPUT_LIMIT);
|
|
2182
|
+
return event;
|
|
2183
|
+
}
|
|
2184
|
+
const files = [];
|
|
2185
|
+
for (const match of source.matchAll(CODE_MODE_FILE_PATTERN)) {
|
|
2186
|
+
const file = relativizePath(match[1].trim(), cwd);
|
|
2187
|
+
if (!files.includes(file)) files.push(file);
|
|
2188
|
+
}
|
|
2189
|
+
if (files.length > 0) {
|
|
2190
|
+
if (skipPatches) return null;
|
|
2191
|
+
event.verb = "Edited";
|
|
2192
|
+
event.title = compactFileLine(files.join(", "));
|
|
2193
|
+
event.filePath = files[0];
|
|
2194
|
+
event.input = truncate(source, INPUT_LIMIT);
|
|
2195
|
+
return event;
|
|
2196
|
+
}
|
|
2197
|
+
const inner = /tools\.(\w+)\(/.exec(source);
|
|
2198
|
+
if (inner) {
|
|
2199
|
+
if (inner[1] === "wait") return null;
|
|
2200
|
+
if (inner[1].startsWith("mcp__")) return null;
|
|
2201
|
+
event.verb = "Called";
|
|
2202
|
+
event.title = inner[1].replace(/^mcp__/, "").replace(/__/g, " · ");
|
|
2203
|
+
event.input = truncate(source, INPUT_LIMIT);
|
|
2204
|
+
return event;
|
|
2205
|
+
}
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
2208
|
+
function codexToolEvent(payload, at, cwd, flags) {
|
|
2209
|
+
const name = payload.name ?? "tool";
|
|
2210
|
+
if (name === "wait") return null;
|
|
2211
|
+
const event = {
|
|
2212
|
+
kind: "tool",
|
|
2213
|
+
tool: name,
|
|
2214
|
+
verb: "Called",
|
|
2215
|
+
title: name,
|
|
2216
|
+
at
|
|
2217
|
+
};
|
|
2218
|
+
if (name === "exec" || name === "exec_command" || name === "shell" || name === "local_shell") {
|
|
2219
|
+
const source = codexValueText(payload.arguments ?? payload.input);
|
|
2220
|
+
if (source.includes("tools.")) return codexCodeModeEvent(event, source, cwd, flags.hasPatchEvents);
|
|
2221
|
+
let command = source;
|
|
2222
|
+
try {
|
|
2223
|
+
const parsed = jsonObject(parseJsonText(source || "{}"));
|
|
2224
|
+
command = jsonString(parsed?.cmd) ?? jsonArray(parsed?.command)?.join(" ") ?? jsonString(parsed?.command) ?? command;
|
|
2225
|
+
} catch {}
|
|
2226
|
+
event.verb = "Ran";
|
|
2227
|
+
event.title = compactLine(command);
|
|
2228
|
+
event.command = truncate(command, INPUT_LIMIT);
|
|
2229
|
+
return event;
|
|
2230
|
+
}
|
|
2231
|
+
if (name === "apply_patch") {
|
|
2232
|
+
if (flags.hasPatchEvents) return null;
|
|
2233
|
+
const patchInput = codexValueText(payload.input);
|
|
2234
|
+
const summary = codexPatchSummary(patchInput, cwd);
|
|
2235
|
+
event.verb = "Edited";
|
|
2236
|
+
event.title = compactFileLine(summary.files.join(", ") || "files");
|
|
2237
|
+
event.filePath = summary.filePath;
|
|
2238
|
+
event.additions = summary.additions;
|
|
2239
|
+
event.deletions = summary.deletions;
|
|
2240
|
+
event.input = truncate(patchInput, INPUT_LIMIT);
|
|
2241
|
+
return event;
|
|
2242
|
+
}
|
|
2243
|
+
const inputSource = codexValueText(payload.arguments ?? payload.input);
|
|
2244
|
+
if (inputSource) event.input = truncate(inputSource, INPUT_LIMIT);
|
|
2245
|
+
return event;
|
|
2246
|
+
}
|
|
2247
|
+
function codexOutputText(output) {
|
|
2248
|
+
const text = jsonString(output);
|
|
2249
|
+
if (text !== void 0) {
|
|
2250
|
+
try {
|
|
2251
|
+
const inner = jsonString(jsonObject(parseJsonText(text))?.output);
|
|
2252
|
+
if (inner !== void 0) return inner;
|
|
2253
|
+
} catch {}
|
|
2254
|
+
return text;
|
|
2255
|
+
}
|
|
2256
|
+
const inner = jsonString(jsonObject(output)?.output);
|
|
2257
|
+
if (inner !== void 0) return inner;
|
|
2258
|
+
return isJsonObject(output) || isJsonArray(output) ? codexValueText(output) : "";
|
|
2259
|
+
}
|
|
2260
|
+
function parseCodexRecords(records) {
|
|
2261
|
+
const events = [];
|
|
2262
|
+
const pendingTools = /* @__PURE__ */ new Map();
|
|
2263
|
+
let startedAt = null;
|
|
2264
|
+
let endedAt = null;
|
|
2265
|
+
let cwd = null;
|
|
2266
|
+
let userTurns = 0;
|
|
2267
|
+
let toolCalls = 0;
|
|
2268
|
+
let firstUserText = null;
|
|
2269
|
+
let hasUserMessageEvent = false;
|
|
2270
|
+
let hasAgentMessageEvent = false;
|
|
2271
|
+
let hasPatchEvents = false;
|
|
2272
|
+
let hasMcpEvents = false;
|
|
2273
|
+
const codexRecords = records.flatMap((raw) => isJsonObject(raw) ? [codexRecord(raw)] : []);
|
|
2274
|
+
for (const record of codexRecords) {
|
|
2275
|
+
if (record.type !== "event_msg") continue;
|
|
2276
|
+
const eventType = record.payload?.type;
|
|
2277
|
+
if (eventType === "user_message") hasUserMessageEvent = true;
|
|
2278
|
+
else if (eventType === "agent_message") hasAgentMessageEvent = true;
|
|
2279
|
+
else if (eventType === "patch_apply_end") hasPatchEvents = true;
|
|
2280
|
+
else if (eventType === "mcp_tool_call_end") hasMcpEvents = true;
|
|
2281
|
+
}
|
|
2282
|
+
const flags = {
|
|
2283
|
+
hasPatchEvents,
|
|
2284
|
+
hasMcpEvents
|
|
2285
|
+
};
|
|
2286
|
+
for (const record of codexRecords) {
|
|
2287
|
+
const payload = record.payload;
|
|
2288
|
+
if (record.type === "session_meta") {
|
|
2289
|
+
cwd = payload?.cwd ?? null;
|
|
2290
|
+
startedAt ??= payload?.timestamp ?? record.timestamp ?? null;
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
const at = record.timestamp;
|
|
2294
|
+
if (record.type === "event_msg" && payload) {
|
|
2295
|
+
if (at) {
|
|
2296
|
+
startedAt ??= at;
|
|
2297
|
+
endedAt = at;
|
|
2298
|
+
}
|
|
2299
|
+
switch (payload.type) {
|
|
2300
|
+
case "user_message": {
|
|
2301
|
+
const text = (payload.message ?? "").trim();
|
|
2302
|
+
if (!text) break;
|
|
2303
|
+
const lowered = text.trimStart().toLowerCase();
|
|
2304
|
+
if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) break;
|
|
2305
|
+
userTurns += 1;
|
|
2306
|
+
firstUserText ??= text;
|
|
2307
|
+
events.push({
|
|
2308
|
+
kind: "user",
|
|
2309
|
+
text,
|
|
2310
|
+
at
|
|
2311
|
+
});
|
|
2312
|
+
break;
|
|
2313
|
+
}
|
|
2314
|
+
case "agent_message": {
|
|
2315
|
+
const text = (payload.message ?? "").trim();
|
|
2316
|
+
if (text) events.push({
|
|
2317
|
+
kind: "assistant",
|
|
2318
|
+
markdown: text,
|
|
2319
|
+
at
|
|
2320
|
+
});
|
|
2321
|
+
break;
|
|
2322
|
+
}
|
|
2323
|
+
case "patch_apply_end": {
|
|
2324
|
+
const event = codexPatchEvent(payload, at, cwd);
|
|
2325
|
+
if (event) {
|
|
2326
|
+
toolCalls += 1;
|
|
2327
|
+
events.push(event);
|
|
2328
|
+
}
|
|
2329
|
+
break;
|
|
2330
|
+
}
|
|
2331
|
+
case "mcp_tool_call_end":
|
|
2332
|
+
toolCalls += 1;
|
|
2333
|
+
events.push(codexMcpEvent(payload, at));
|
|
2334
|
+
break;
|
|
2335
|
+
case "web_search_end":
|
|
2336
|
+
toolCalls += 1;
|
|
2337
|
+
events.push({
|
|
2338
|
+
kind: "tool",
|
|
2339
|
+
tool: "web_search",
|
|
2340
|
+
verb: "Searched web",
|
|
2341
|
+
title: compactLine(payload.query ?? ""),
|
|
2342
|
+
at
|
|
2343
|
+
});
|
|
2344
|
+
break;
|
|
2345
|
+
case "exec_command_end": {
|
|
2346
|
+
const command = jsonArray(payload.command)?.join(" ") ?? codexValueText(payload.command);
|
|
2347
|
+
if (!command) break;
|
|
2348
|
+
toolCalls += 1;
|
|
2349
|
+
const event = {
|
|
2350
|
+
kind: "tool",
|
|
2351
|
+
tool: "exec",
|
|
2352
|
+
verb: "Ran",
|
|
2353
|
+
title: compactLine(command),
|
|
2354
|
+
command: truncate(command, INPUT_LIMIT),
|
|
2355
|
+
at
|
|
2356
|
+
};
|
|
2357
|
+
if (payload.aggregated_output) event.output = truncate(payload.aggregated_output, OUTPUT_LIMIT);
|
|
2358
|
+
if (payload.exit_code !== void 0 && payload.exit_code !== 0) event.error = true;
|
|
2359
|
+
events.push(event);
|
|
2360
|
+
break;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
if (record.type !== "response_item" || !payload) continue;
|
|
2366
|
+
if (at) {
|
|
2367
|
+
startedAt ??= at;
|
|
2368
|
+
endedAt = at;
|
|
2369
|
+
}
|
|
2370
|
+
switch (payload.type) {
|
|
2371
|
+
case "message": {
|
|
2372
|
+
const text = codexText(payload);
|
|
2373
|
+
if (!text) break;
|
|
2374
|
+
if (payload.role === "user") {
|
|
2375
|
+
if (hasUserMessageEvent) break;
|
|
2376
|
+
const lowered = text.trimStart().toLowerCase();
|
|
2377
|
+
if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) break;
|
|
2378
|
+
userTurns += 1;
|
|
2379
|
+
firstUserText ??= text;
|
|
2380
|
+
events.push({
|
|
2381
|
+
kind: "user",
|
|
2382
|
+
text,
|
|
2383
|
+
at
|
|
2384
|
+
});
|
|
2385
|
+
} else if (payload.role === "assistant") {
|
|
2386
|
+
if (hasAgentMessageEvent) break;
|
|
2387
|
+
events.push({
|
|
2388
|
+
kind: "assistant",
|
|
2389
|
+
markdown: text,
|
|
2390
|
+
at
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
break;
|
|
2394
|
+
}
|
|
2395
|
+
case "function_call":
|
|
2396
|
+
case "custom_tool_call": {
|
|
2397
|
+
const event = codexToolEvent(payload, at, cwd, flags);
|
|
2398
|
+
if (!event) break;
|
|
2399
|
+
toolCalls += 1;
|
|
2400
|
+
events.push(event);
|
|
2401
|
+
if (payload.call_id) pendingTools.set(payload.call_id, event);
|
|
2402
|
+
break;
|
|
2403
|
+
}
|
|
2404
|
+
case "function_call_output":
|
|
2405
|
+
case "custom_tool_call_output": {
|
|
2406
|
+
const pending = payload.call_id ? pendingTools.get(payload.call_id) : void 0;
|
|
2407
|
+
if (!pending) break;
|
|
2408
|
+
const text = codexOutputText(payload.output).trim();
|
|
2409
|
+
if (text && !pending.output) pending.output = truncate(text, OUTPUT_LIMIT);
|
|
2410
|
+
if (payload.call_id) pendingTools.delete(payload.call_id);
|
|
2411
|
+
break;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return {
|
|
2416
|
+
harness: "codex",
|
|
2417
|
+
title: firstUserText ? compactLine(firstUserText) : null,
|
|
2418
|
+
events,
|
|
2419
|
+
startedAt,
|
|
2420
|
+
endedAt,
|
|
2421
|
+
activeMs: null,
|
|
2422
|
+
userTurns,
|
|
2423
|
+
toolCalls
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
function piRecord(value) {
|
|
2427
|
+
const message = jsonObject(value.message);
|
|
2428
|
+
return {
|
|
2429
|
+
type: jsonString(value.type),
|
|
2430
|
+
timestamp: jsonString(value.timestamp),
|
|
2431
|
+
cwd: jsonString(value.cwd),
|
|
2432
|
+
message: message && {
|
|
2433
|
+
role: jsonString(message.role),
|
|
2434
|
+
toolCallId: jsonString(message.toolCallId),
|
|
2435
|
+
content: message.content
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
function piContentBlocks(content) {
|
|
2440
|
+
return jsonArray(content)?.flatMap((block) => {
|
|
2441
|
+
const record = jsonObject(block);
|
|
2442
|
+
return record ? [{
|
|
2443
|
+
type: jsonString(record.type),
|
|
2444
|
+
text: jsonString(record.text),
|
|
2445
|
+
thinking: jsonString(record.thinking),
|
|
2446
|
+
id: jsonString(record.id),
|
|
2447
|
+
name: jsonString(record.name),
|
|
2448
|
+
arguments: jsonObject(record.arguments)
|
|
2449
|
+
}] : [];
|
|
2450
|
+
}) ?? [];
|
|
2451
|
+
}
|
|
2452
|
+
function piText(content) {
|
|
2453
|
+
const text = jsonString(content);
|
|
2454
|
+
if (text !== void 0) return text;
|
|
2455
|
+
return piContentBlocks(content).flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n").trim();
|
|
2456
|
+
}
|
|
2457
|
+
function piToolEvent(block, at, cwd) {
|
|
2458
|
+
const name = block.name ?? "tool";
|
|
2459
|
+
const args = block.arguments ?? {};
|
|
2460
|
+
const argText = (key) => jsonString(args[key]) ?? null;
|
|
2461
|
+
const event = {
|
|
2462
|
+
kind: "tool",
|
|
2463
|
+
tool: name,
|
|
2464
|
+
verb: "Called",
|
|
2465
|
+
title: name,
|
|
2466
|
+
at
|
|
2467
|
+
};
|
|
2468
|
+
const pathValue = argText("path");
|
|
2469
|
+
switch (name) {
|
|
2470
|
+
case "bash": {
|
|
2471
|
+
const command = argText("command") ?? "";
|
|
2472
|
+
event.verb = "Ran";
|
|
2473
|
+
event.title = compactLine(command);
|
|
2474
|
+
event.command = truncate(command, INPUT_LIMIT);
|
|
2475
|
+
break;
|
|
2476
|
+
}
|
|
2477
|
+
case "read":
|
|
2478
|
+
event.verb = "Read";
|
|
2479
|
+
event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
|
|
2480
|
+
if (pathValue) event.filePath = relativizePath(pathValue, cwd);
|
|
2481
|
+
break;
|
|
2482
|
+
case "edit":
|
|
2483
|
+
event.verb = "Edited";
|
|
2484
|
+
event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
|
|
2485
|
+
if (pathValue) event.filePath = relativizePath(pathValue, cwd);
|
|
2486
|
+
break;
|
|
2487
|
+
case "write":
|
|
2488
|
+
event.verb = "Wrote";
|
|
2489
|
+
event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
|
|
2490
|
+
if (pathValue) event.filePath = relativizePath(pathValue, cwd);
|
|
2491
|
+
const content = argText("content");
|
|
2492
|
+
if (content !== null) event.additions = content.split("\n").length;
|
|
2493
|
+
break;
|
|
2494
|
+
case "subagent":
|
|
2495
|
+
event.verb = "Ran agent";
|
|
2496
|
+
event.title = compactLine(codexValueText(args.action));
|
|
2497
|
+
break;
|
|
2498
|
+
case "web_search":
|
|
2499
|
+
event.verb = "Searched web";
|
|
2500
|
+
event.title = compactLine(argText("query") ?? "");
|
|
2501
|
+
}
|
|
2502
|
+
if (!event.command) {
|
|
2503
|
+
const pretty = codexValueText(args);
|
|
2504
|
+
if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
|
|
2505
|
+
}
|
|
2506
|
+
return event;
|
|
2507
|
+
}
|
|
2508
|
+
function parsePiRecords(records) {
|
|
2509
|
+
const events = [];
|
|
2510
|
+
const pendingTools = /* @__PURE__ */ new Map();
|
|
2511
|
+
let startedAt = null;
|
|
2512
|
+
let endedAt = null;
|
|
2513
|
+
let cwd = null;
|
|
2514
|
+
let userTurns = 0;
|
|
2515
|
+
let toolCalls = 0;
|
|
2516
|
+
let firstUserText = null;
|
|
2517
|
+
for (const raw of records) {
|
|
2518
|
+
if (!isJsonObject(raw)) continue;
|
|
2519
|
+
const record = piRecord(raw);
|
|
2520
|
+
if (record.type === "session") {
|
|
2521
|
+
cwd = record.cwd ?? null;
|
|
2522
|
+
startedAt ??= record.timestamp ?? null;
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
if (record.type !== "message" || !record.message) continue;
|
|
2526
|
+
const at = record.timestamp;
|
|
2527
|
+
if (at) {
|
|
2528
|
+
startedAt ??= at;
|
|
2529
|
+
endedAt = at;
|
|
2530
|
+
}
|
|
2531
|
+
const message = record.message;
|
|
2532
|
+
if (message.role === "user") {
|
|
2533
|
+
const text = piText(message.content);
|
|
2534
|
+
if (!text) continue;
|
|
2535
|
+
const lowered = text.trimStart().toLowerCase();
|
|
2536
|
+
if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) continue;
|
|
2537
|
+
userTurns += 1;
|
|
2538
|
+
firstUserText ??= text;
|
|
2539
|
+
events.push({
|
|
2540
|
+
kind: "user",
|
|
2541
|
+
text,
|
|
2542
|
+
at
|
|
2543
|
+
});
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
if (message.role === "assistant") {
|
|
2547
|
+
const contentText = jsonString(message.content);
|
|
2548
|
+
if (contentText !== void 0) {
|
|
2549
|
+
const trimmed = contentText.trim();
|
|
2550
|
+
if (trimmed) events.push({
|
|
2551
|
+
kind: "assistant",
|
|
2552
|
+
markdown: trimmed,
|
|
2553
|
+
at
|
|
2554
|
+
});
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
for (const block of piContentBlocks(message.content)) if (block.type === "thinking" && block.thinking !== void 0) {
|
|
2558
|
+
const trimmed = block.thinking.trim();
|
|
2559
|
+
if (trimmed) events.push({
|
|
2560
|
+
kind: "assistant",
|
|
2561
|
+
markdown: trimmed,
|
|
2562
|
+
thinking: true,
|
|
2563
|
+
at
|
|
2564
|
+
});
|
|
2565
|
+
} else if (block.type === "text" && block.text !== void 0) {
|
|
2566
|
+
const trimmed = block.text.trim();
|
|
2567
|
+
if (trimmed) events.push({
|
|
2568
|
+
kind: "assistant",
|
|
2569
|
+
markdown: trimmed,
|
|
2570
|
+
at
|
|
2571
|
+
});
|
|
2572
|
+
} else if (block.type === "toolCall") {
|
|
2573
|
+
if (block.name === "subagent_wait") continue;
|
|
2574
|
+
const event = piToolEvent(block, at, cwd);
|
|
2575
|
+
toolCalls += 1;
|
|
2576
|
+
events.push(event);
|
|
2577
|
+
if (block.id) pendingTools.set(block.id, event);
|
|
2578
|
+
}
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
if (message.role === "toolResult") {
|
|
2582
|
+
const pending = message.toolCallId ? pendingTools.get(message.toolCallId) : void 0;
|
|
2583
|
+
if (!pending) continue;
|
|
2584
|
+
const text = piText(message.content);
|
|
2585
|
+
if (text && !pending.output) pending.output = truncate(text, OUTPUT_LIMIT);
|
|
2586
|
+
if (message.toolCallId) pendingTools.delete(message.toolCallId);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
return {
|
|
2590
|
+
harness: "pi",
|
|
2591
|
+
title: firstUserText ? compactLine(firstUserText) : null,
|
|
2592
|
+
events,
|
|
2593
|
+
startedAt,
|
|
2594
|
+
endedAt,
|
|
2595
|
+
activeMs: null,
|
|
2596
|
+
userTurns,
|
|
2597
|
+
toolCalls
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
function openCodeIso(value) {
|
|
2601
|
+
const millis = jsonNumber(value);
|
|
2602
|
+
return millis === void 0 ? void 0 : new Date(millis).toISOString();
|
|
2603
|
+
}
|
|
2604
|
+
function openCodeRecord(value) {
|
|
2605
|
+
const info = jsonObject(value.info);
|
|
2606
|
+
const infoTime = jsonObject(info?.time);
|
|
2607
|
+
const time = jsonObject(value.time);
|
|
2608
|
+
return {
|
|
2609
|
+
type: jsonString(value.type),
|
|
2610
|
+
directory: jsonString(value.directory),
|
|
2611
|
+
title: jsonString(value.title),
|
|
2612
|
+
createdAt: openCodeIso(time?.created),
|
|
2613
|
+
role: jsonString(info?.role),
|
|
2614
|
+
messageCreatedAt: openCodeIso(infoTime?.created),
|
|
2615
|
+
completedAt: openCodeIso(infoTime?.completed),
|
|
2616
|
+
parts: (jsonArray(value.parts) ?? []).flatMap((part) => isJsonObject(part) ? [openCodePart(part)] : [])
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
function openCodePart(value) {
|
|
2620
|
+
const state = jsonObject(value.state);
|
|
2621
|
+
return {
|
|
2622
|
+
type: jsonString(value.type),
|
|
2623
|
+
text: jsonString(value.text),
|
|
2624
|
+
ignored: jsonBoolean(value.ignored) === true,
|
|
2625
|
+
synthetic: jsonBoolean(value.synthetic) === true,
|
|
2626
|
+
startedAt: openCodeIso(jsonObject(value.time)?.start),
|
|
2627
|
+
tool: jsonString(value.tool),
|
|
2628
|
+
state: {
|
|
2629
|
+
status: jsonString(state?.status),
|
|
2630
|
+
input: jsonObject(state?.input) ?? {},
|
|
2631
|
+
output: jsonString(state?.output),
|
|
2632
|
+
error: jsonString(state?.error),
|
|
2633
|
+
title: jsonString(state?.title),
|
|
2634
|
+
startedAt: openCodeIso(jsonObject(state?.time)?.start)
|
|
2635
|
+
}
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
function openCodeText(parts) {
|
|
2639
|
+
return parts.flatMap((part) => part.type === "text" && part.text !== void 0 && !part.ignored && !part.synthetic ? [part.text] : []).join("\n").trim();
|
|
2640
|
+
}
|
|
2641
|
+
function openCodeToolEvent(part, at, cwd) {
|
|
2642
|
+
const name = part.tool ?? "tool";
|
|
2643
|
+
const state = part.state;
|
|
2644
|
+
const args = state.input;
|
|
2645
|
+
const argText = (key) => jsonString(args[key]) ?? null;
|
|
2646
|
+
const lineCount = (key) => jsonString(args[key])?.split("\n").length;
|
|
2647
|
+
const event = {
|
|
2648
|
+
kind: "tool",
|
|
2649
|
+
tool: name,
|
|
2650
|
+
verb: "Called",
|
|
2651
|
+
title: state.title || name,
|
|
2652
|
+
at
|
|
2653
|
+
};
|
|
2654
|
+
const filePath = argText("filePath");
|
|
2655
|
+
const fileTitle = () => {
|
|
2656
|
+
if (!filePath) return;
|
|
2657
|
+
event.filePath = relativizePath(filePath, cwd);
|
|
2658
|
+
event.title = compactFileLine(event.filePath);
|
|
2659
|
+
};
|
|
2660
|
+
switch (name) {
|
|
2661
|
+
case "bash": {
|
|
2662
|
+
const command = argText("command") ?? "";
|
|
2663
|
+
event.verb = "Ran";
|
|
2664
|
+
event.title = compactLine(command);
|
|
2665
|
+
event.command = truncate(command, INPUT_LIMIT);
|
|
2666
|
+
break;
|
|
2667
|
+
}
|
|
2668
|
+
case "read":
|
|
2669
|
+
event.verb = "Read";
|
|
2670
|
+
fileTitle();
|
|
2671
|
+
break;
|
|
2672
|
+
case "edit": {
|
|
2673
|
+
event.verb = "Edited";
|
|
2674
|
+
fileTitle();
|
|
2675
|
+
const additions = lineCount("newString");
|
|
2676
|
+
const deletions = lineCount("oldString");
|
|
2677
|
+
if (additions !== void 0) event.additions = additions;
|
|
2678
|
+
if (deletions !== void 0) event.deletions = deletions;
|
|
2679
|
+
break;
|
|
2680
|
+
}
|
|
2681
|
+
case "write": {
|
|
2682
|
+
event.verb = "Wrote";
|
|
2683
|
+
fileTitle();
|
|
2684
|
+
const additions = lineCount("content");
|
|
2685
|
+
if (additions !== void 0) event.additions = additions;
|
|
2686
|
+
break;
|
|
2687
|
+
}
|
|
2688
|
+
case "apply_patch": {
|
|
2689
|
+
const summary = codexPatchSummary(argText("patchText") ?? "", cwd);
|
|
2690
|
+
event.verb = "Edited";
|
|
2691
|
+
event.title = compactFileLine(summary.files.join(", ") || name);
|
|
2692
|
+
if (summary.filePath) event.filePath = summary.filePath;
|
|
2693
|
+
event.additions = summary.additions;
|
|
2694
|
+
event.deletions = summary.deletions;
|
|
2695
|
+
break;
|
|
2696
|
+
}
|
|
2697
|
+
case "glob":
|
|
2698
|
+
case "grep":
|
|
2699
|
+
event.verb = "Searched";
|
|
2700
|
+
event.title = compactLine(argText("pattern") ?? name);
|
|
2701
|
+
break;
|
|
2702
|
+
case "list": {
|
|
2703
|
+
const listPath = argText("path");
|
|
2704
|
+
event.verb = "Listed";
|
|
2705
|
+
event.title = compactFileLine(listPath ? relativizePath(listPath, cwd) : name);
|
|
2706
|
+
break;
|
|
2707
|
+
}
|
|
2708
|
+
case "task":
|
|
2709
|
+
event.verb = "Ran agent";
|
|
2710
|
+
event.title = compactLine(argText("description") ?? name);
|
|
2711
|
+
break;
|
|
2712
|
+
case "webfetch":
|
|
2713
|
+
event.verb = "Fetched";
|
|
2714
|
+
event.title = compactLine(argText("url") ?? name);
|
|
2715
|
+
break;
|
|
2716
|
+
case "websearch":
|
|
2717
|
+
event.verb = "Searched web";
|
|
2718
|
+
event.title = compactLine(argText("query") ?? name);
|
|
2719
|
+
break;
|
|
2720
|
+
case "skill":
|
|
2721
|
+
event.verb = "Loaded skill";
|
|
2722
|
+
event.title = compactLine(argText("name") ?? name);
|
|
2723
|
+
}
|
|
2724
|
+
if (!event.command) {
|
|
2725
|
+
const pretty = codexValueText(args);
|
|
2726
|
+
if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
|
|
2727
|
+
}
|
|
2728
|
+
const output = state.status === "error" && state.error !== void 0 ? state.error : state.output ?? "";
|
|
2729
|
+
if (output.trim()) event.output = truncate(output, OUTPUT_LIMIT);
|
|
2730
|
+
return event;
|
|
2731
|
+
}
|
|
2732
|
+
function parseOpenCodeRecords(records) {
|
|
2733
|
+
const events = [];
|
|
2734
|
+
let title = null;
|
|
2735
|
+
let startedAt = null;
|
|
2736
|
+
let endedAt = null;
|
|
2737
|
+
let cwd = null;
|
|
2738
|
+
let userTurns = 0;
|
|
2739
|
+
let toolCalls = 0;
|
|
2740
|
+
let firstUserText = null;
|
|
2741
|
+
for (const raw of records) {
|
|
2742
|
+
if (!isJsonObject(raw)) continue;
|
|
2743
|
+
const record = openCodeRecord(raw);
|
|
2744
|
+
if (record.type === "opencode_session") {
|
|
2745
|
+
cwd = record.directory ?? null;
|
|
2746
|
+
title = record.title?.trim() || null;
|
|
2747
|
+
startedAt ??= record.createdAt ?? null;
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
if (record.type !== "opencode_message") continue;
|
|
2751
|
+
const createdAt = record.messageCreatedAt;
|
|
2752
|
+
if (createdAt) {
|
|
2753
|
+
startedAt ??= createdAt;
|
|
2754
|
+
endedAt = record.completedAt ?? createdAt;
|
|
2755
|
+
}
|
|
2756
|
+
if (record.role === "user") {
|
|
2757
|
+
const text = openCodeText(record.parts);
|
|
2758
|
+
if (!text) continue;
|
|
2759
|
+
userTurns += 1;
|
|
2760
|
+
firstUserText ??= text;
|
|
2761
|
+
events.push({
|
|
2762
|
+
kind: "user",
|
|
2763
|
+
text,
|
|
2764
|
+
at: createdAt
|
|
2765
|
+
});
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
if (record.role !== "assistant") continue;
|
|
2769
|
+
for (const part of record.parts) if (part.type === "reasoning" && part.text !== void 0) {
|
|
2770
|
+
const trimmed = part.text.trim();
|
|
2771
|
+
if (trimmed) events.push({
|
|
2772
|
+
kind: "assistant",
|
|
2773
|
+
markdown: trimmed,
|
|
2774
|
+
thinking: true,
|
|
2775
|
+
at: part.startedAt ?? createdAt
|
|
2776
|
+
});
|
|
2777
|
+
} else if (part.type === "text" && part.text !== void 0) {
|
|
2778
|
+
const trimmed = part.text.trim();
|
|
2779
|
+
if (trimmed) events.push({
|
|
2780
|
+
kind: "assistant",
|
|
2781
|
+
markdown: trimmed,
|
|
2782
|
+
at: createdAt
|
|
2783
|
+
});
|
|
2784
|
+
} else if (part.type === "tool") {
|
|
2785
|
+
toolCalls += 1;
|
|
2786
|
+
events.push(openCodeToolEvent(part, part.state.startedAt ?? createdAt, cwd));
|
|
2787
|
+
} else if (part.type === "compaction") events.push({
|
|
2788
|
+
kind: "separator",
|
|
2789
|
+
label: "Context compacted"
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
return {
|
|
2793
|
+
harness: "opencode",
|
|
2794
|
+
title: title ?? (firstUserText ? compactLine(firstUserText) : null),
|
|
2795
|
+
events,
|
|
2796
|
+
startedAt,
|
|
2797
|
+
endedAt,
|
|
2798
|
+
activeMs: null,
|
|
2799
|
+
userTurns,
|
|
2800
|
+
toolCalls
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
//#endregion
|
|
2804
|
+
//#region ../trace-core/src/trace-corpus.ts
|
|
2805
|
+
const RECORD_SEPARATOR = "";
|
|
2806
|
+
const FIELD_SEPARATOR = "";
|
|
2807
|
+
/** Atomically replaces a normalized JSONL copy without changing its version-1 record format. */
|
|
2808
|
+
function writeNormalizedTraceAtomic(targetPath, trace) {
|
|
2809
|
+
const content = [trace.metadata, ...trace.events].map((record) => JSON.stringify(record)).join("\n");
|
|
2810
|
+
writeFileAtomic(targetPath, `${content}\n`, "utf8");
|
|
2811
|
+
}
|
|
2812
|
+
/**
|
|
2813
|
+
* A saved copy, or null when the file is absent, written by another parser
|
|
2814
|
+
* version, or made for another store. A copy that names its store is served
|
|
2815
|
+
* only to that store; a copy from before the field is trusted only by s3
|
|
2816
|
+
* storage, which wrote every such file.
|
|
2817
|
+
*/
|
|
2818
|
+
function readNormalizedTrace(filePath, storage) {
|
|
2819
|
+
try {
|
|
2820
|
+
const records = readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => parseJsonText(line));
|
|
2821
|
+
const metadata = records[0];
|
|
2822
|
+
if (!metadata || metadata.type !== "metadata" || metadata.version !== 1 || metadata.parserVersion !== "1" || !Number.isFinite(metadata.source?.bytes)) return null;
|
|
2823
|
+
if (storage) {
|
|
2824
|
+
const owner = metadata.source.storage;
|
|
2825
|
+
if (owner !== void 0 && owner !== storage.cacheIdentity()) return null;
|
|
2826
|
+
if (owner === void 0 && storage.kind !== "s3") return null;
|
|
2827
|
+
}
|
|
2828
|
+
const events = records.slice(1);
|
|
2829
|
+
if (events.some((record, index) => record.type !== "event" || record.index !== index || record.kind !== record.event?.kind || record.text !== extractTraceEventText(record.event))) return null;
|
|
2830
|
+
return {
|
|
2831
|
+
metadata,
|
|
2832
|
+
events
|
|
2833
|
+
};
|
|
2834
|
+
} catch {
|
|
2835
|
+
return null;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
/** Creates and returns the configured local search corpus directory; it performs no remote lookup. */
|
|
2839
|
+
function traceSearchCorpusDir() {
|
|
2840
|
+
const dir = process.env.REVIEW_TEST_TRACE_SEARCH_DIR ?? path.join(devReviewHome(), "trace-search");
|
|
2841
|
+
mkdirSync(dir, { recursive: true });
|
|
2842
|
+
return dir;
|
|
2843
|
+
}
|
|
2844
|
+
/** Parses a repository slug or returns the supplied owner/repo pair without consulting Git. */
|
|
2845
|
+
function normalizeRepo(repo) {
|
|
2846
|
+
return isRepoSlug(repo) ? parseRepo(repo) : repo;
|
|
2847
|
+
}
|
|
2848
|
+
/** Whether a repo input is the "owner/repo" slug form. */
|
|
2849
|
+
function isRepoSlug(repo) {
|
|
2850
|
+
return isStringValue(repo);
|
|
2851
|
+
}
|
|
2852
|
+
/** Builds a validated corpus path and ensures the corpus root exists; it does not write a trace. */
|
|
2853
|
+
function normalizedTracePath(repo, sessionId, traceName) {
|
|
2854
|
+
return path.join(traceSearchCorpusDir(), corpusPathSegment(repo.owner, "owner"), corpusPathSegment(repo.repo, "repository"), corpusPathSegment(sessionId, "session"), `${corpusPathSegment(traceName.replace(/\.jsonl$/, ""), "trace")}.jsonl`);
|
|
2855
|
+
}
|
|
2856
|
+
/** Finds a local copy compatible with the supplied store, restricting scoped stores to their own paths; it never downloads content. */
|
|
2857
|
+
function findNormalizedTraceFile(sessionId, traceName, storage) {
|
|
2858
|
+
const fileName = `${corpusPathSegment(traceName.replace(/\.jsonl$/, ""), "trace")}.jsonl`;
|
|
2859
|
+
const own = storage?.cacheScope(null);
|
|
2860
|
+
if (own) {
|
|
2861
|
+
const candidate = path.join(path.dirname(normalizedTracePath(own, sessionId, "main")), fileName);
|
|
2862
|
+
return isFile(candidate) ? candidate : null;
|
|
2863
|
+
}
|
|
2864
|
+
for (const sessionDir of findNormalizedSessionDirs(sessionId)) {
|
|
2865
|
+
const candidate = path.join(sessionDir, fileName);
|
|
2866
|
+
if (!isFile(candidate)) continue;
|
|
2867
|
+
if (!storage || readNormalizedTrace(candidate, storage) !== null) return candidate;
|
|
2868
|
+
}
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
/** Returns sorted local corpus directories for a session, or an empty list on traversal failure. */
|
|
2872
|
+
function findNormalizedSessionDirs(sessionId) {
|
|
2873
|
+
const root = traceSearchCorpusDir();
|
|
2874
|
+
const session = corpusPathSegment(sessionId, "session");
|
|
2875
|
+
const results = [];
|
|
2876
|
+
try {
|
|
2877
|
+
for (const owner of readdirSync(root, { withFileTypes: true })) {
|
|
2878
|
+
if (!owner.isDirectory()) continue;
|
|
2879
|
+
const ownerDir = path.join(root, owner.name);
|
|
2880
|
+
for (const repo of readdirSync(ownerDir, { withFileTypes: true })) {
|
|
2881
|
+
if (!repo.isDirectory()) continue;
|
|
2882
|
+
const candidate = path.join(ownerDir, repo.name, session);
|
|
2883
|
+
if (isDirectory(candidate)) results.push(candidate);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
} catch {
|
|
2887
|
+
return [];
|
|
2888
|
+
}
|
|
2889
|
+
return results.sort();
|
|
2890
|
+
}
|
|
2891
|
+
function corpusPathSegment(value, label) {
|
|
2892
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error(`Invalid ${label} path segment: ${value}`);
|
|
2893
|
+
return value;
|
|
2894
|
+
}
|
|
2895
|
+
/** Returns the version-1 bucket object label for cache metadata without accessing storage. */
|
|
2896
|
+
function legacyObjectKey(sessionId, traceName) {
|
|
2897
|
+
if (traceName === "main") return `by-session/${sessionId}/trace.jsonl`;
|
|
2898
|
+
const base = path.basename(traceName);
|
|
2899
|
+
return `by-session/${sessionId}/subagents/${base.endsWith(".jsonl") ? base : `${base}.jsonl`}`;
|
|
2900
|
+
}
|
|
2901
|
+
/** Returns distinct strings in first-seen order without modifying the input. */
|
|
2902
|
+
function deduplicateStrings(items) {
|
|
2903
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2904
|
+
const out = [];
|
|
2905
|
+
for (const item of items) if (!seen.has(item)) {
|
|
2906
|
+
seen.add(item);
|
|
2907
|
+
out.push(item);
|
|
2908
|
+
}
|
|
2909
|
+
return out;
|
|
2910
|
+
}
|
|
2911
|
+
/** Resolves a Git revision to its SHA, retaining the supplied revision when resolution fails. */
|
|
2912
|
+
async function resolveCommitSha(cwd, rev) {
|
|
2913
|
+
const result = await git(cwd, [
|
|
2914
|
+
"rev-parse",
|
|
2915
|
+
"--verify",
|
|
2916
|
+
"--end-of-options",
|
|
2917
|
+
rev
|
|
2918
|
+
], { allowFailure: true });
|
|
2919
|
+
return result.ok && result.stdout.trim() ? result.stdout.trim() : rev;
|
|
2920
|
+
}
|
|
2921
|
+
/** Returns valid, unique Agent-Session trailers for a revision, or an empty list when Git fails. */
|
|
2922
|
+
async function readTrailerSessions(cwd, rev) {
|
|
2923
|
+
const result = await git(cwd, [
|
|
2924
|
+
"show",
|
|
2925
|
+
"-s",
|
|
2926
|
+
"--format=%(trailers:key=Agent-Session,valueonly)",
|
|
2927
|
+
"--end-of-options",
|
|
2928
|
+
rev
|
|
2929
|
+
], { allowFailure: true });
|
|
2930
|
+
if (!result.ok) return [];
|
|
2931
|
+
const sessions = [];
|
|
2932
|
+
for (const line of result.stdout.split("\n")) {
|
|
2933
|
+
const value = line.trim();
|
|
2934
|
+
if (value && sessionIdSchema.safeParse(value).success && !sessions.includes(value)) sessions.push(value);
|
|
2935
|
+
}
|
|
2936
|
+
return sessions;
|
|
2937
|
+
}
|
|
2938
|
+
/** Reads valid, unique session trailers from all repository refs without consulting trace storage. */
|
|
2939
|
+
async function listRepositoryTraceSessionIds(cwd) {
|
|
2940
|
+
const result = await gitAt(cwd, [
|
|
2941
|
+
"log",
|
|
2942
|
+
"--all",
|
|
2943
|
+
"--no-show-signature",
|
|
2944
|
+
"--format=%(trailers:key=Agent-Session,valueonly,separator=%x1f)"
|
|
2945
|
+
], { allowFailure: true });
|
|
2946
|
+
if (!result.ok) return [];
|
|
2947
|
+
return deduplicateStrings(result.stdout.split(/[\n\x1f]+/).map((value) => value.trim()).filter((value) => sessionIdSchema.safeParse(value).success));
|
|
2948
|
+
}
|
|
2949
|
+
/** Reads a revision subject and extracts its trailing pull request number, or returns null. */
|
|
2950
|
+
async function readSubjectPullNumber(cwd, rev) {
|
|
2951
|
+
const result = await git(cwd, [
|
|
2952
|
+
"show",
|
|
2953
|
+
"-s",
|
|
2954
|
+
"--format=%s",
|
|
2955
|
+
"--end-of-options",
|
|
2956
|
+
rev
|
|
2957
|
+
], { allowFailure: true });
|
|
2958
|
+
if (!result.ok) return null;
|
|
2959
|
+
return subjectPullNumber(result.stdout.trim());
|
|
2960
|
+
}
|
|
2961
|
+
/** Extracts a trailing (#number) from a subject without consulting Git. */
|
|
2962
|
+
function subjectPullNumber(subject) {
|
|
2963
|
+
const match = /\(#(\d+)\)$/.exec(subject);
|
|
2964
|
+
return match ? Number(match[1]) : null;
|
|
2965
|
+
}
|
|
2966
|
+
/** Reads the repository author email and current branch, returning null fields outside a worktree. */
|
|
2967
|
+
async function readRepoMetaFields(cwd) {
|
|
2968
|
+
const insideResult = await git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFailure: true });
|
|
2969
|
+
if (!insideResult.ok || insideResult.stdout.trim() !== "true") return {
|
|
2970
|
+
author: null,
|
|
2971
|
+
branch: null
|
|
2972
|
+
};
|
|
2973
|
+
const branchResult = await git(cwd, ["branch", "--show-current"], { allowFailure: true });
|
|
2974
|
+
const branch = branchResult.ok ? branchResult.stdout.trim() : null;
|
|
2975
|
+
const authorResult = await git(cwd, ["config", "user.email"], { allowFailure: true });
|
|
2976
|
+
return {
|
|
2977
|
+
author: (authorResult.ok ? authorResult.stdout.trim() : null) || null,
|
|
2978
|
+
branch: branch || null
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
/** Reads commit subjects and valid session trailers in the requested range without consulting trace storage. */
|
|
2982
|
+
async function commitsWithTrailers(input) {
|
|
2983
|
+
if (input.baseCommit === input.headCommit) return [];
|
|
2984
|
+
const format = [
|
|
2985
|
+
"%H",
|
|
2986
|
+
"%s",
|
|
2987
|
+
"%(trailers:key=Agent-Session,valueonly,separator=%x1f)"
|
|
2988
|
+
].join("%x1f");
|
|
2989
|
+
const result = await git(input.rootPath, [
|
|
2990
|
+
"log",
|
|
2991
|
+
"--no-show-signature",
|
|
2992
|
+
`--format=${format}${RECORD_SEPARATOR}`,
|
|
2993
|
+
`${input.baseCommit}..${input.headCommit}`
|
|
2994
|
+
], { allowFailure: true });
|
|
2995
|
+
if (!result.ok) return [];
|
|
2996
|
+
const commits = [];
|
|
2997
|
+
for (const chunk of result.stdout.split(RECORD_SEPARATOR)) {
|
|
2998
|
+
const record = chunk.replace(/^\s+/, "");
|
|
2999
|
+
if (!record) continue;
|
|
3000
|
+
const [sha, subject, ...trailerFields] = record.split(FIELD_SEPARATOR);
|
|
3001
|
+
if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) continue;
|
|
3002
|
+
const sessions = new Set(trailerFields.flatMap((field) => field.split("\n"))).values().map((value) => value.trim()).filter((value) => sessionIdSchema.safeParse(value).success).toArray();
|
|
3003
|
+
commits.push({
|
|
3004
|
+
sha,
|
|
3005
|
+
subject: subject ?? "",
|
|
3006
|
+
sessions
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
return commits;
|
|
3010
|
+
}
|
|
3011
|
+
/** Fetches a pull request head and collects its unique session trailers, returning an empty list if Git cannot resolve the branch. */
|
|
3012
|
+
async function prScanTrailerSessions(cwd, commit, pr) {
|
|
3013
|
+
if (!(await gitAt(cwd, [
|
|
3014
|
+
"fetch",
|
|
3015
|
+
"--quiet",
|
|
3016
|
+
"origin",
|
|
3017
|
+
`refs/pull/${pr}/head`
|
|
3018
|
+
], { allowFailure: true })).ok) return [];
|
|
3019
|
+
let revListRes = await gitAt(cwd, [
|
|
3020
|
+
"rev-list",
|
|
3021
|
+
"FETCH_HEAD",
|
|
3022
|
+
"--not",
|
|
3023
|
+
`${commit}^`
|
|
3024
|
+
], { allowFailure: true });
|
|
3025
|
+
if (!revListRes.ok) revListRes = await gitAt(cwd, [
|
|
3026
|
+
"rev-list",
|
|
3027
|
+
"FETCH_HEAD",
|
|
3028
|
+
"--not",
|
|
3029
|
+
`${commit}~1`
|
|
3030
|
+
], { allowFailure: true });
|
|
3031
|
+
if (!revListRes.ok) revListRes = await gitAt(cwd, ["rev-list", "FETCH_HEAD"], { allowFailure: true });
|
|
3032
|
+
if (!revListRes.ok) return [];
|
|
3033
|
+
const branchShas = revListRes.stdout.trim().split(/\s+/).filter(Boolean);
|
|
3034
|
+
const prSessions = [];
|
|
3035
|
+
for (const branchSha of branchShas) {
|
|
3036
|
+
const sessionsOnSha = await readTrailerSessions(cwd, branchSha);
|
|
3037
|
+
for (const s of sessionsOnSha) if (!prSessions.includes(s)) prSessions.push(s);
|
|
3038
|
+
}
|
|
3039
|
+
return prSessions;
|
|
3040
|
+
}
|
|
3041
|
+
/** Reports whether a path resolves to a regular file, returning false for filesystem errors. */
|
|
3042
|
+
function isFile(targetPath) {
|
|
3043
|
+
try {
|
|
3044
|
+
return statSync(targetPath).isFile();
|
|
3045
|
+
} catch {
|
|
3046
|
+
return false;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
function isDirectory(targetPath) {
|
|
3050
|
+
try {
|
|
3051
|
+
return statSync(targetPath).isDirectory();
|
|
3052
|
+
} catch {
|
|
3053
|
+
return false;
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
/** Collects regular files recursively, retaining files found before a directory read fails. */
|
|
3057
|
+
function listFilesRecursive(dirPath) {
|
|
3058
|
+
const files = [];
|
|
3059
|
+
try {
|
|
3060
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
3061
|
+
for (const entry of entries) {
|
|
3062
|
+
const full = path.join(dirPath, entry.name);
|
|
3063
|
+
if (entry.isDirectory()) files.push(...listFilesRecursive(full));
|
|
3064
|
+
else if (entry.isFile()) files.push(full);
|
|
3065
|
+
}
|
|
3066
|
+
} catch {}
|
|
3067
|
+
return files;
|
|
3068
|
+
}
|
|
3069
|
+
//#endregion
|
|
3070
|
+
//#region ../trace-core/src/opencode-trace-export.ts
|
|
3071
|
+
const OPENCODE_SESSION_RECORD = "opencode_session";
|
|
3072
|
+
const OPENCODE_MESSAGE_RECORD = "opencode_message";
|
|
3073
|
+
/** OpenCode session ids are `ses_` followed by a sortable id. */
|
|
3074
|
+
function isOpenCodeSessionId(sessionId) {
|
|
3075
|
+
return sessionId.startsWith("ses_");
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* OpenCode keeps sessions in its own database rather than a transcript file.
|
|
3079
|
+
* `opencode export` renders one as JSON; this writes that as the JSONL trace
|
|
3080
|
+
* the rest of the pipeline expects: one session header record, then one
|
|
3081
|
+
* record per message with its parts stripped of provider metadata and inline
|
|
3082
|
+
* file data. Returns null when OpenCode has no such session.
|
|
3083
|
+
*/
|
|
3084
|
+
async function exportOpenCodeTrace(input) {
|
|
3085
|
+
mkdirSync(input.root, { recursive: true });
|
|
3086
|
+
const destination = path.join(input.root, `${input.sessionId}.jsonl`);
|
|
3087
|
+
const staging = `${destination}.tmp-${process.pid}`;
|
|
3088
|
+
const raw = await runOpenCodeExport(input.sessionId, `${staging}.json`);
|
|
3089
|
+
if (raw === null) return null;
|
|
3090
|
+
const exported = jsonObject(parseJsonText(raw));
|
|
3091
|
+
const info = jsonObject(exported?.info);
|
|
3092
|
+
if (!exported || !info) throw new Error(`opencode export ${input.sessionId} returned no session info.`);
|
|
3093
|
+
const messages = jsonArray(exported.messages);
|
|
3094
|
+
if (!messages) throw new Error(`opencode export ${input.sessionId} returned no message list.`);
|
|
3095
|
+
const header = {
|
|
3096
|
+
type: OPENCODE_SESSION_RECORD,
|
|
3097
|
+
...pick(info, [
|
|
3098
|
+
"id",
|
|
3099
|
+
"parentID",
|
|
3100
|
+
"directory",
|
|
3101
|
+
"title",
|
|
3102
|
+
"version",
|
|
3103
|
+
"time"
|
|
3104
|
+
])
|
|
3105
|
+
};
|
|
3106
|
+
const lines = [JSON.stringify(header), ...messages.map((message) => JSON.stringify(traceMessageRecord(message)))];
|
|
3107
|
+
writeFileAtomic(destination, `${lines.join("\n")}\n`, "utf8");
|
|
3108
|
+
return destination;
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Runs `opencode export` with stdout on a file. OpenCode exits before a pipe
|
|
3112
|
+
* drains, so a piped export is cut off at 128 KiB; a file descriptor is not.
|
|
3113
|
+
* Resolves to the exported JSON, or null when OpenCode has no such session.
|
|
3114
|
+
*/
|
|
3115
|
+
function runOpenCodeExport(sessionId, stdoutPath) {
|
|
3116
|
+
const fd = openSync(stdoutPath, "w");
|
|
3117
|
+
return new Promise((resolve, reject) => {
|
|
3118
|
+
const child = spawn("opencode", [
|
|
3119
|
+
"export",
|
|
3120
|
+
sessionId,
|
|
3121
|
+
"--pure"
|
|
3122
|
+
], {
|
|
3123
|
+
stdio: [
|
|
3124
|
+
"ignore",
|
|
3125
|
+
fd,
|
|
3126
|
+
"pipe"
|
|
3127
|
+
],
|
|
3128
|
+
windowsHide: true
|
|
3129
|
+
});
|
|
3130
|
+
let stderr = "";
|
|
3131
|
+
const stderrStream = child.stderr;
|
|
3132
|
+
if (!stderrStream) throw new Error("opencode export did not open a stderr pipe.");
|
|
3133
|
+
stderrStream.setEncoding("utf8");
|
|
3134
|
+
stderrStream.on("data", (chunk) => {
|
|
3135
|
+
stderr += chunk;
|
|
3136
|
+
});
|
|
3137
|
+
child.once("error", (error) => {
|
|
3138
|
+
closeSync(fd);
|
|
3139
|
+
rmSync(stdoutPath, { force: true });
|
|
3140
|
+
reject(/* @__PURE__ */ new Error(`opencode export ${sessionId} failed: ${error.message}`));
|
|
3141
|
+
});
|
|
3142
|
+
child.once("exit", (code) => {
|
|
3143
|
+
closeSync(fd);
|
|
3144
|
+
try {
|
|
3145
|
+
if (code === 0) resolve(readFileSync(stdoutPath, "utf8"));
|
|
3146
|
+
else if (stderr.includes("Session not found")) resolve(null);
|
|
3147
|
+
else reject(/* @__PURE__ */ new Error(`opencode export ${sessionId} exited with ${code}: ${stderr.trim()}`));
|
|
3148
|
+
} finally {
|
|
3149
|
+
rmSync(stdoutPath, { force: true });
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3152
|
+
});
|
|
3153
|
+
}
|
|
3154
|
+
function traceMessageRecord(message) {
|
|
3155
|
+
const record = jsonObject(message);
|
|
3156
|
+
const info = jsonObject(record?.info);
|
|
3157
|
+
if (!record || !info) throw new Error("opencode export returned a message without info.");
|
|
3158
|
+
const parts = jsonArray(record.parts) ?? [];
|
|
3159
|
+
return {
|
|
3160
|
+
type: OPENCODE_MESSAGE_RECORD,
|
|
3161
|
+
info: pick(info, [
|
|
3162
|
+
"id",
|
|
3163
|
+
"sessionID",
|
|
3164
|
+
"role",
|
|
3165
|
+
"time",
|
|
3166
|
+
"error",
|
|
3167
|
+
"agent",
|
|
3168
|
+
"modelID",
|
|
3169
|
+
"providerID"
|
|
3170
|
+
]),
|
|
3171
|
+
parts: parts.flatMap((part) => {
|
|
3172
|
+
const object = jsonObject(part);
|
|
3173
|
+
return object ? [tracePart(object)] : [];
|
|
3174
|
+
})
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
function tracePart(part) {
|
|
3178
|
+
const common = pick(part, ["id", "type"]);
|
|
3179
|
+
switch (part.type) {
|
|
3180
|
+
case "text": return {
|
|
3181
|
+
...common,
|
|
3182
|
+
...pick(part, [
|
|
3183
|
+
"text",
|
|
3184
|
+
"ignored",
|
|
3185
|
+
"synthetic"
|
|
3186
|
+
])
|
|
3187
|
+
};
|
|
3188
|
+
case "reasoning": return {
|
|
3189
|
+
...common,
|
|
3190
|
+
...pick(part, ["text", "time"])
|
|
3191
|
+
};
|
|
3192
|
+
case "tool": {
|
|
3193
|
+
const state = jsonObject(part.state);
|
|
3194
|
+
return {
|
|
3195
|
+
...common,
|
|
3196
|
+
...pick(part, ["tool", "callID"]),
|
|
3197
|
+
state: state ? pick(state, [
|
|
3198
|
+
"status",
|
|
3199
|
+
"input",
|
|
3200
|
+
"output",
|
|
3201
|
+
"error",
|
|
3202
|
+
"title",
|
|
3203
|
+
"time"
|
|
3204
|
+
]) : {}
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
case "file": return {
|
|
3208
|
+
...common,
|
|
3209
|
+
...pick(part, ["mime", "filename"])
|
|
3210
|
+
};
|
|
3211
|
+
case "patch": return {
|
|
3212
|
+
...common,
|
|
3213
|
+
...pick(part, ["hash", "files"])
|
|
3214
|
+
};
|
|
3215
|
+
case "compaction": return {
|
|
3216
|
+
...common,
|
|
3217
|
+
...pick(part, ["auto"])
|
|
3218
|
+
};
|
|
3219
|
+
default: return common;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
function pick(value, keys) {
|
|
3223
|
+
const picked = {};
|
|
3224
|
+
for (const key of keys) {
|
|
3225
|
+
const entry = value[key];
|
|
3226
|
+
if (entry !== void 0) picked[key] = entry;
|
|
3227
|
+
}
|
|
3228
|
+
return picked;
|
|
3229
|
+
}
|
|
3230
|
+
//#endregion
|
|
3231
|
+
//#region ../trace-core/src/trace-local-sessions.ts
|
|
3232
|
+
/** Finds a local harness transcript and subagents, exporting OpenCode data when needed; it never publishes traces. */
|
|
3233
|
+
async function findLocalTrace(sessionId) {
|
|
3234
|
+
if (!sessionIdSchema.safeParse(sessionId).success) return null;
|
|
3235
|
+
const claudeRoot = traceEnvValue("TRACE_LOCAL_TRACE_ROOT") || path.join(homedir(), ".claude", "projects");
|
|
3236
|
+
const codexRoot = codexSessionsRoot();
|
|
3237
|
+
const piRoot = traceEnvValue("TRACE_PI_SESSIONS_ROOT") || path.join(homedir(), ".pi", "agent", "sessions");
|
|
3238
|
+
let harness = "claude";
|
|
3239
|
+
let tracePath = findClaudeTrace(claudeRoot, sessionId);
|
|
3240
|
+
if (!tracePath) {
|
|
3241
|
+
tracePath = findCodexTrace(codexRoot, sessionId);
|
|
3242
|
+
if (tracePath) harness = "codex";
|
|
3243
|
+
}
|
|
3244
|
+
if (!tracePath) {
|
|
3245
|
+
tracePath = findPiTrace(piRoot, sessionId);
|
|
3246
|
+
if (tracePath) harness = "pi";
|
|
3247
|
+
}
|
|
3248
|
+
if (!tracePath) {
|
|
3249
|
+
if (existsSync(claudeRoot) && isFile(path.join(claudeRoot, `${sessionId}.jsonl`))) {
|
|
3250
|
+
tracePath = path.join(claudeRoot, `${sessionId}.jsonl`);
|
|
3251
|
+
harness = "claude";
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
if (!tracePath && isOpenCodeSessionId(sessionId)) {
|
|
3255
|
+
tracePath = await exportOpenCodeTrace({
|
|
3256
|
+
sessionId,
|
|
3257
|
+
root: process.env.TRACE_OPENCODE_TRACES_ROOT || path.join(devReviewHome(), "opencode-traces")
|
|
3258
|
+
});
|
|
3259
|
+
if (tracePath) harness = "opencode";
|
|
3260
|
+
}
|
|
3261
|
+
if (!tracePath) return null;
|
|
3262
|
+
const subagentPaths = findSubagentBlobs(tracePath);
|
|
3263
|
+
return {
|
|
3264
|
+
tracePath,
|
|
3265
|
+
harness,
|
|
3266
|
+
subagentPaths
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
function findClaudeTrace(root, sessionId) {
|
|
3270
|
+
const fileName = `${sessionId}.jsonl`;
|
|
3271
|
+
if (!existsSync(root)) return null;
|
|
3272
|
+
const direct = path.join(root, fileName);
|
|
3273
|
+
if (isFile(direct)) return direct;
|
|
3274
|
+
try {
|
|
3275
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
3276
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
3277
|
+
const candidate = path.join(root, entry.name, fileName);
|
|
3278
|
+
if (isFile(candidate)) return candidate;
|
|
3279
|
+
}
|
|
3280
|
+
} catch {}
|
|
3281
|
+
return null;
|
|
3282
|
+
}
|
|
3283
|
+
function findCodexTrace(root, sessionId) {
|
|
3284
|
+
if (!existsSync(root)) return null;
|
|
3285
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
3286
|
+
return listFilesRecursive(root).sort().find((entry) => {
|
|
3287
|
+
const name = path.basename(entry);
|
|
3288
|
+
return name.startsWith("rollout-") && name.endsWith(suffix);
|
|
3289
|
+
}) ?? null;
|
|
3290
|
+
}
|
|
3291
|
+
function findPiTrace(root, sessionId) {
|
|
3292
|
+
if (!existsSync(root)) return null;
|
|
3293
|
+
try {
|
|
3294
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
3295
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
3296
|
+
const subDir = path.join(root, entry.name);
|
|
3297
|
+
for (const file of readdirSync(subDir)) if (file.endsWith(`_${sessionId}.jsonl`) || file === `${sessionId}.jsonl`) {
|
|
3298
|
+
const candidate = path.join(subDir, file);
|
|
3299
|
+
if (isFile(candidate)) return candidate;
|
|
3300
|
+
}
|
|
3301
|
+
} else if (entry.isFile()) {
|
|
3302
|
+
if (entry.name.endsWith(`_${sessionId}.jsonl`) || entry.name === `${sessionId}.jsonl`) return path.join(root, entry.name);
|
|
3303
|
+
}
|
|
3304
|
+
} catch {}
|
|
3305
|
+
return null;
|
|
3306
|
+
}
|
|
3307
|
+
function findSubagentBlobs(tracePath) {
|
|
3308
|
+
const results = [];
|
|
3309
|
+
const stem = tracePath.endsWith(".jsonl") ? tracePath.slice(0, -6) : tracePath;
|
|
3310
|
+
const subagentsDir = path.join(stem, "subagents");
|
|
3311
|
+
if (existsSync(subagentsDir)) try {
|
|
3312
|
+
for (const name of readdirSync(subagentsDir)) if (name.endsWith(".jsonl")) results.push({
|
|
3313
|
+
name,
|
|
3314
|
+
path: path.join(subagentsDir, name)
|
|
3315
|
+
});
|
|
3316
|
+
} catch {}
|
|
3317
|
+
if (existsSync(stem)) try {
|
|
3318
|
+
for (const childEntry of readdirSync(stem, { withFileTypes: true })) if (childEntry.isDirectory() && childEntry.name !== "subagents") {
|
|
3319
|
+
const childDir = path.join(stem, childEntry.name);
|
|
3320
|
+
for (const runEntry of readdirSync(childDir, { withFileTypes: true })) if (runEntry.isDirectory() && runEntry.name.startsWith("run-")) {
|
|
3321
|
+
const runFile = path.join(childDir, runEntry.name, "session.jsonl");
|
|
3322
|
+
if (isFile(runFile)) {
|
|
3323
|
+
const shortChild = childEntry.name.slice(0, 8);
|
|
3324
|
+
results.push({
|
|
3325
|
+
name: `pi-${runEntry.name}-${shortChild}.jsonl`,
|
|
3326
|
+
path: runFile
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
} catch {}
|
|
3332
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
3333
|
+
}
|
|
3334
|
+
/** Reads a trace setting through the existing environment and saved-file resolver without changing configuration. */
|
|
3335
|
+
function traceEnvValue(name) {
|
|
3336
|
+
return traceEnvValue$1(name);
|
|
3337
|
+
}
|
|
3338
|
+
/** Resolves the Codex session directory using the trace override or process CODEX_HOME, without reading CODEX_HOME from the trace env file. */
|
|
3339
|
+
function codexSessionsRoot() {
|
|
3340
|
+
const codexHome = process.env.CODEX_HOME;
|
|
3341
|
+
return traceEnvValue("TRACE_CODEX_SESSIONS_ROOT") || path.join(codexHome ? path.resolve(codexHome) : path.join(homedir(), ".codex"), "sessions");
|
|
3342
|
+
}
|
|
3343
|
+
//#endregion
|
|
3344
|
+
//#region ../trace-core/src/trace-read.ts
|
|
3345
|
+
const STORE_COMMIT_LOOKUP_LIMIT = 30;
|
|
3346
|
+
const REMOTE_HEAD_TTL_MS = 15e3;
|
|
3347
|
+
const TUTORIAL_TRACE_SESSION_ID = "review-tutorial-checkout";
|
|
3348
|
+
const lastCheckedTimes = /* @__PURE__ */ new Map();
|
|
3349
|
+
/**
|
|
3350
|
+
* The store to read from or publish to: an explicit override, or the
|
|
3351
|
+
* machine's selected store. Null means no remote storage is configured.
|
|
3352
|
+
*/
|
|
3353
|
+
async function storageFor(storage, cwd) {
|
|
3354
|
+
return storage === void 0 ? resolveTraceStorage({ cwd }) : storage;
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
3357
|
+
* A remote lookup, or null when the store could not be reached. A refusal
|
|
3358
|
+
* propagates: the caller must show nothing, not "nothing here".
|
|
3359
|
+
*/
|
|
3360
|
+
async function reachable(lookup) {
|
|
3361
|
+
try {
|
|
3362
|
+
return await lookup();
|
|
3363
|
+
} catch (error) {
|
|
3364
|
+
if (error instanceof TraceStorageUnavailableError) return null;
|
|
3365
|
+
throw error;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
function freshnessKey(storage, sessionId, traceName) {
|
|
3369
|
+
return `${storage.cacheIdentity()}/${sessionId}/${traceName}`;
|
|
3370
|
+
}
|
|
3371
|
+
/** Describes sessions for a change range using trailers, then bounded store-index and pull-request fallbacks when none are found. */
|
|
3372
|
+
async function listReviewTraceSessions(input) {
|
|
3373
|
+
const storage = await storageFor(input.storage, input.rootPath);
|
|
3374
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
3375
|
+
const commits = await commitsWithTrailers(input);
|
|
3376
|
+
for (const commit of commits) for (const sessionId of commit.sessions) {
|
|
3377
|
+
const existing = sessions.get(sessionId);
|
|
3378
|
+
if (existing) existing.commits.push({
|
|
3379
|
+
sha: commit.sha,
|
|
3380
|
+
subject: commit.subject
|
|
3381
|
+
});
|
|
3382
|
+
else sessions.set(sessionId, {
|
|
3383
|
+
sessionId,
|
|
3384
|
+
commits: [{
|
|
3385
|
+
sha: commit.sha,
|
|
3386
|
+
subject: commit.subject
|
|
3387
|
+
}]
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
if (sessions.size === 0 && commits.length <= STORE_COMMIT_LOOKUP_LIMIT) await addSessionsFromStoreIndex(storage, commits, sessions);
|
|
3391
|
+
if (sessions.size === 0 && commits.length <= STORE_COMMIT_LOOKUP_LIMIT) await addSessionsFromPrScan(input.rootPath, commits, sessions);
|
|
3392
|
+
const descriptors = [];
|
|
3393
|
+
for (const ref of sessions.values()) {
|
|
3394
|
+
const desc = await describeTraceSession(ref, storage);
|
|
3395
|
+
descriptors.push(desc);
|
|
3396
|
+
}
|
|
3397
|
+
return descriptors;
|
|
3398
|
+
}
|
|
3399
|
+
/** Describes availability from compatible local copies and the selected store; store refusals propagate. */
|
|
3400
|
+
async function describeTraceSession(ref, storage) {
|
|
3401
|
+
const store = await storageFor(storage);
|
|
3402
|
+
const local = findNormalizedTraceFile(ref.sessionId, "main", store);
|
|
3403
|
+
const normalized = local ? readNormalizedTrace(local, store) : null;
|
|
3404
|
+
const remote = store ? await reachable(() => store.describeObject(ref.sessionId, "main")) : null;
|
|
3405
|
+
const available = normalized !== null || remote !== null && remote.size > 0;
|
|
3406
|
+
const harness = normalized?.metadata.harness ?? "unknown";
|
|
3407
|
+
const subagents = await listSessionSubagents(ref.sessionId, store);
|
|
3408
|
+
return {
|
|
3409
|
+
sessionId: ref.sessionId,
|
|
3410
|
+
harness,
|
|
3411
|
+
available,
|
|
3412
|
+
source: available ? "r2" : null,
|
|
3413
|
+
notSynced: !available,
|
|
3414
|
+
subagents,
|
|
3415
|
+
commits: ref.commits
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
/** Loads the offline tutorial or a store-compatible normalized trace, preserving freshness and offline fallback status; store refusals propagate. */
|
|
3419
|
+
async function loadReviewAgentTrace(input) {
|
|
3420
|
+
const { sessionId, trace } = input;
|
|
3421
|
+
if (sessionId === "review-tutorial-checkout") {
|
|
3422
|
+
if (trace && trace !== "main") return null;
|
|
3423
|
+
const { loadTutorialTrace } = await import("./tutorial-trace-DrBvkaeL.js");
|
|
3424
|
+
return loadTutorialTrace();
|
|
3425
|
+
}
|
|
3426
|
+
if (!sessionIdSchema.safeParse(sessionId).success) return null;
|
|
3427
|
+
const traceName = trace ?? "main";
|
|
3428
|
+
const storage = await storageFor(input.storage, input.cwd);
|
|
3429
|
+
const requestedRepo = input.repo ? normalizeRepo(input.repo) : null;
|
|
3430
|
+
let scope = storage ? storage.cacheScope(requestedRepo) : requestedRepo;
|
|
3431
|
+
let normalizedPath = scope ? normalizedTracePath(scope, sessionId, traceName) : findNormalizedTraceFile(sessionId, traceName, storage);
|
|
3432
|
+
let normalized = normalizedPath ? readNormalizedTrace(normalizedPath, storage) : null;
|
|
3433
|
+
const now = Date.now();
|
|
3434
|
+
const checkKey = storage ? freshnessKey(storage, sessionId, traceName) : null;
|
|
3435
|
+
const lastChecked = checkKey ? lastCheckedTimes.get(checkKey) ?? 0 : 0;
|
|
3436
|
+
const canUseWithoutCheck = normalized && !input.refresh && now - lastChecked < REMOTE_HEAD_TTL_MS;
|
|
3437
|
+
if (storage && checkKey && !canUseWithoutCheck) {
|
|
3438
|
+
if (storage.kind === "hosted") lastCheckedTimes.delete(checkKey);
|
|
3439
|
+
let remote;
|
|
3440
|
+
try {
|
|
3441
|
+
remote = await storage.describeObject(sessionId, traceName);
|
|
3442
|
+
} catch (error) {
|
|
3443
|
+
if (error instanceof TraceStorageDeniedError) return null;
|
|
3444
|
+
if (!(error instanceof TraceStorageUnavailableError)) throw error;
|
|
3445
|
+
return normalized ? loadedNormalizedTrace(normalized, input.commits, "offline") : null;
|
|
3446
|
+
}
|
|
3447
|
+
if (storage.kind === "hosted" && remote === null) {
|
|
3448
|
+
if (normalized && normalizedPath) rmSync(normalizedPath, { force: true });
|
|
3449
|
+
lastCheckedTimes.delete(checkKey);
|
|
3450
|
+
return null;
|
|
3451
|
+
}
|
|
3452
|
+
if (storage.kind !== "hosted") lastCheckedTimes.set(checkKey, now);
|
|
3453
|
+
if (remote !== null && (!normalized || !cacheIsCurrent(storage, normalized, remote))) {
|
|
3454
|
+
if (!scope) {
|
|
3455
|
+
let repo = requestedRepo;
|
|
3456
|
+
if (!repo && input.cwd) repo = await inferRepoFromGit(input.cwd).catch(() => null);
|
|
3457
|
+
if (!repo) {
|
|
3458
|
+
const meta = await storage.sessionMeta(sessionId);
|
|
3459
|
+
if (meta?.repo) try {
|
|
3460
|
+
repo = parseRepo(meta.repo);
|
|
3461
|
+
} catch {
|
|
3462
|
+
repo = null;
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
scope = storage.cacheScope(repo);
|
|
3466
|
+
}
|
|
3467
|
+
if (!scope) return normalized ? loadedNormalizedTrace(normalized, input.commits, "stale") : null;
|
|
3468
|
+
normalizedPath = normalizedTracePath(scope, sessionId, traceName);
|
|
3469
|
+
const fresh = await materializeNormalizedTrace({
|
|
3470
|
+
storage,
|
|
3471
|
+
sessionId,
|
|
3472
|
+
traceName,
|
|
3473
|
+
normalizedPath,
|
|
3474
|
+
repository: requestedRepo ? traceRepoName(requestedRepo) : normalized?.metadata.repository ?? traceRepoName(scope)
|
|
3475
|
+
});
|
|
3476
|
+
if (!fresh) return normalized ? loadedNormalizedTrace(normalized, input.commits, "stale") : null;
|
|
3477
|
+
normalized = fresh;
|
|
3478
|
+
}
|
|
3479
|
+
if (storage.kind === "hosted") lastCheckedTimes.set(checkKey, now);
|
|
3480
|
+
}
|
|
3481
|
+
if (!normalized) return null;
|
|
3482
|
+
return loadedNormalizedTrace(normalized, input.commits, storage?.kind === "hosted" && (await storage.readiness()).ready === false ? "offline" : "current");
|
|
3483
|
+
}
|
|
3484
|
+
/**
|
|
3485
|
+
* Whether a saved copy still matches the stored object. S3 storage can
|
|
3486
|
+
* only compare sizes and objects only grow; hosted storage names content
|
|
3487
|
+
* exactly, so an equal-size or smaller replacement is still seen.
|
|
3488
|
+
*/
|
|
3489
|
+
function cacheIsCurrent(storage, normalized, remote) {
|
|
3490
|
+
if (storage.kind === "s3") return remote.size <= normalized.metadata.source.bytes;
|
|
3491
|
+
return normalized.metadata.source.contentId === remote.contentId;
|
|
3492
|
+
}
|
|
3493
|
+
function loadedNormalizedTrace(normalized, commits, cacheStatus) {
|
|
3494
|
+
const metadata = normalized.metadata;
|
|
3495
|
+
const descriptor = {
|
|
3496
|
+
sessionId: metadata.session,
|
|
3497
|
+
harness: metadata.harness,
|
|
3498
|
+
available: true,
|
|
3499
|
+
source: "r2",
|
|
3500
|
+
subagents: metadata.subagents,
|
|
3501
|
+
commits: commits ?? []
|
|
3502
|
+
};
|
|
3503
|
+
return {
|
|
3504
|
+
parserVersion: metadata.parserVersion,
|
|
3505
|
+
descriptor,
|
|
3506
|
+
trace: {
|
|
3507
|
+
harness: metadata.harness,
|
|
3508
|
+
title: metadata.title,
|
|
3509
|
+
events: normalized.events.map((record) => record.event),
|
|
3510
|
+
startedAt: metadata.startedAt,
|
|
3511
|
+
endedAt: metadata.endedAt,
|
|
3512
|
+
activeMs: metadata.activeMs,
|
|
3513
|
+
userTurns: metadata.userTurns,
|
|
3514
|
+
toolCalls: metadata.toolCalls
|
|
3515
|
+
},
|
|
3516
|
+
subagents: metadata.subagents,
|
|
3517
|
+
traceName: metadata.trace === "main" ? null : metadata.trace,
|
|
3518
|
+
cacheStatus
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
async function materializeNormalizedTrace(input) {
|
|
3522
|
+
const rawTempPath = path.join(tmpdir(), `review-trace-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
|
|
3523
|
+
try {
|
|
3524
|
+
let downloaded;
|
|
3525
|
+
try {
|
|
3526
|
+
downloaded = await input.storage.downloadObject(input.sessionId, input.traceName, rawTempPath);
|
|
3527
|
+
} catch (error) {
|
|
3528
|
+
if (error instanceof TraceStorageDeniedError) throw error;
|
|
3529
|
+
if (error instanceof TraceStorageUnavailableError) return null;
|
|
3530
|
+
process.stderr.write(`Trace store download failed for ${input.traceName}: ${errorMessage(error)}\n`);
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
if (!downloaded) return null;
|
|
3534
|
+
const parsed = parseAgentTraceJsonl(readFileSync(rawTempPath, "utf8"), { isSubagent: input.traceName !== "main" });
|
|
3535
|
+
const subagents = await listSessionSubagents(input.sessionId, input.storage);
|
|
3536
|
+
const normalized = {
|
|
3537
|
+
metadata: {
|
|
3538
|
+
type: "metadata",
|
|
3539
|
+
version: 1,
|
|
3540
|
+
parserVersion: "1",
|
|
3541
|
+
repository: input.repository,
|
|
3542
|
+
session: input.sessionId,
|
|
3543
|
+
trace: input.traceName,
|
|
3544
|
+
harness: parsed.harness,
|
|
3545
|
+
title: parsed.title,
|
|
3546
|
+
startedAt: parsed.startedAt,
|
|
3547
|
+
endedAt: parsed.endedAt,
|
|
3548
|
+
activeMs: parsed.activeMs,
|
|
3549
|
+
userTurns: parsed.userTurns,
|
|
3550
|
+
toolCalls: parsed.toolCalls,
|
|
3551
|
+
subagents,
|
|
3552
|
+
source: {
|
|
3553
|
+
r2Key: legacyObjectKey(input.sessionId, input.traceName),
|
|
3554
|
+
bytes: downloaded.size,
|
|
3555
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3556
|
+
contentId: downloaded.contentId,
|
|
3557
|
+
storage: input.storage.cacheIdentity()
|
|
3558
|
+
}
|
|
3559
|
+
},
|
|
3560
|
+
events: parsed.events.map((event, index) => ({
|
|
3561
|
+
type: "event",
|
|
3562
|
+
index,
|
|
3563
|
+
kind: event.kind,
|
|
3564
|
+
text: extractTraceEventText(event),
|
|
3565
|
+
event
|
|
3566
|
+
}))
|
|
3567
|
+
};
|
|
3568
|
+
writeNormalizedTraceAtomic(input.normalizedPath, normalized);
|
|
3569
|
+
return normalized;
|
|
3570
|
+
} finally {
|
|
3571
|
+
rmSync(rawTempPath, { force: true });
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
/** Refreshes requested sessions into the local corpus and reports available paths and unavailable sessions without publishing traces. */
|
|
3575
|
+
async function pullReviewTraceCorpus(input) {
|
|
3576
|
+
const repository = `${input.repo.owner}/${input.repo.repo}`;
|
|
3577
|
+
const corpusRoot = traceSearchCorpusDir();
|
|
3578
|
+
const storage = await storageFor(input.storage, input.cwd);
|
|
3579
|
+
const scope = (storage ? storage.cacheScope(input.repo) : null) ?? input.repo;
|
|
3580
|
+
const sessions = [];
|
|
3581
|
+
const unavailableSessions = [];
|
|
3582
|
+
const paths = [];
|
|
3583
|
+
for (const sessionRef of input.sessions) {
|
|
3584
|
+
const main = await loadReviewAgentTrace({
|
|
3585
|
+
sessionId: sessionRef.id,
|
|
3586
|
+
repo: input.repo,
|
|
3587
|
+
refresh: true,
|
|
3588
|
+
storage
|
|
3589
|
+
});
|
|
3590
|
+
if (!main) {
|
|
3591
|
+
unavailableSessions.push(sessionRef.id);
|
|
3592
|
+
continue;
|
|
3593
|
+
}
|
|
3594
|
+
paths.push(normalizedTracePath(scope, sessionRef.id, "main"));
|
|
3595
|
+
let traceCount = 1;
|
|
3596
|
+
let eventCount = main.trace.events.length;
|
|
3597
|
+
if (!input.mainOnly) for (const traceName of sessionRef.traces ?? main.subagents) {
|
|
3598
|
+
const subagent = await loadReviewAgentTrace({
|
|
3599
|
+
sessionId: sessionRef.id,
|
|
3600
|
+
trace: traceName,
|
|
3601
|
+
repo: input.repo,
|
|
3602
|
+
refresh: true,
|
|
3603
|
+
storage
|
|
3604
|
+
});
|
|
3605
|
+
if (subagent) {
|
|
3606
|
+
paths.push(normalizedTracePath(scope, sessionRef.id, traceName));
|
|
3607
|
+
traceCount += 1;
|
|
3608
|
+
eventCount += subagent.trace.events.length;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
sessions.push({
|
|
3612
|
+
session: sessionRef.id,
|
|
3613
|
+
traces: traceCount,
|
|
3614
|
+
events: eventCount,
|
|
3615
|
+
files: traceCount
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
return {
|
|
3619
|
+
corpusRoot,
|
|
3620
|
+
repository,
|
|
3621
|
+
sessions,
|
|
3622
|
+
unavailableSessions,
|
|
3623
|
+
events: sessions.reduce((total, session) => total + session.events, 0),
|
|
3624
|
+
files: sessions.reduce((total, session) => total + session.files, 0),
|
|
3625
|
+
paths
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
/** Resolves sessions by commit trailers, then the store index, then a pull-request scan, retaining the lookup source. */
|
|
3629
|
+
async function lookupReviewTraceCommit(input) {
|
|
3630
|
+
const storage = await storageFor(input.storage, input.cwd);
|
|
3631
|
+
const commit = await resolveCommitSha(input.cwd, input.sha);
|
|
3632
|
+
const trailerSessions = await readTrailerSessions(input.cwd, commit);
|
|
3633
|
+
const pr = await readSubjectPullNumber(input.cwd, commit);
|
|
3634
|
+
if (trailerSessions.length > 0) {
|
|
3635
|
+
const sessionMeta = await enrichSessionMeta(trailerSessions, storage);
|
|
3636
|
+
const result = {
|
|
3637
|
+
commit,
|
|
3638
|
+
sessions: trailerSessions,
|
|
3639
|
+
pr,
|
|
3640
|
+
branch: null,
|
|
3641
|
+
source: "trailer"
|
|
3642
|
+
};
|
|
3643
|
+
if (sessionMeta) result.session_meta = sessionMeta;
|
|
3644
|
+
return result;
|
|
3645
|
+
}
|
|
3646
|
+
const indexed = storage ? await storage.sessionsForCommit(commit) : null;
|
|
3647
|
+
if (indexed && indexed.sessions.length > 0) {
|
|
3648
|
+
const sessions = deduplicateStrings(indexed.sessions);
|
|
3649
|
+
const sessionMeta = await enrichSessionMeta(sessions, storage);
|
|
3650
|
+
const result = {
|
|
3651
|
+
commit,
|
|
3652
|
+
sessions,
|
|
3653
|
+
pr: indexed.pr ?? pr,
|
|
3654
|
+
branch: indexed.branch ?? null,
|
|
3655
|
+
source: "index"
|
|
3656
|
+
};
|
|
3657
|
+
if (sessionMeta) result.session_meta = sessionMeta;
|
|
3658
|
+
return result;
|
|
3659
|
+
}
|
|
3660
|
+
if (pr !== null) {
|
|
3661
|
+
const prSessions = await prScanTrailerSessions(input.cwd, commit, pr);
|
|
3662
|
+
if (prSessions.length > 0) {
|
|
3663
|
+
const sessionMeta = await enrichSessionMeta(prSessions, storage);
|
|
3664
|
+
const result = {
|
|
3665
|
+
commit,
|
|
3666
|
+
sessions: prSessions,
|
|
3667
|
+
pr,
|
|
3668
|
+
branch: null,
|
|
3669
|
+
source: "pr-scan"
|
|
3670
|
+
};
|
|
3671
|
+
if (sessionMeta) result.session_meta = sessionMeta;
|
|
3672
|
+
return result;
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
return {
|
|
3676
|
+
commit,
|
|
3677
|
+
sessions: [],
|
|
3678
|
+
pr,
|
|
3679
|
+
branch: null,
|
|
3680
|
+
source: "none"
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
async function enrichSessionMeta(sessions, storage) {
|
|
3684
|
+
if (!storage || sessions.length === 0) return void 0;
|
|
3685
|
+
const detail = {};
|
|
3686
|
+
for (const session of sessions) {
|
|
3687
|
+
const meta = await storage.sessionMeta(session);
|
|
3688
|
+
if (meta) detail[session] = {
|
|
3689
|
+
repo: meta.repo,
|
|
3690
|
+
branch: meta.branch,
|
|
3691
|
+
pr: meta.pr,
|
|
3692
|
+
author: meta.author
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
return Object.keys(detail).length > 0 ? detail : void 0;
|
|
3696
|
+
}
|
|
3697
|
+
/** Resolves sessions for file blame or history commits, preserving their resolution order and lookup sources. */
|
|
3698
|
+
async function lookupReviewTraceBlame(input) {
|
|
3699
|
+
if (!input.file) throw new Error("File path is required.");
|
|
3700
|
+
if (input.lines) {
|
|
3701
|
+
const match = /^(\d+)(?:,(\d+))?$/.exec(input.lines.trim());
|
|
3702
|
+
if (!match) throw new Error(`Invalid line range "${input.lines}". Expected start,end or single line number.`);
|
|
3703
|
+
const start = parseInt(match[1], 10);
|
|
3704
|
+
const end = match[2] ? parseInt(match[2], 10) : start;
|
|
3705
|
+
if (start <= 0 || end < start) throw new Error(`Invalid line range "${input.lines}". Start must be >= 1 and end >= start.`);
|
|
3706
|
+
}
|
|
3707
|
+
let shas = [];
|
|
3708
|
+
if (input.history) {
|
|
3709
|
+
const spec = input.lines ? `${input.lines}:${input.file}` : `1,$:${input.file}`;
|
|
3710
|
+
const res = await gitAt(input.cwd, [
|
|
3711
|
+
"log",
|
|
3712
|
+
"-L",
|
|
3713
|
+
spec,
|
|
3714
|
+
"--format=%H",
|
|
3715
|
+
"-s"
|
|
3716
|
+
], { allowFailure: true });
|
|
3717
|
+
if (!res.ok) throw new Error(res.stderr.trim() || `git log -L failed for ${input.file}`);
|
|
3718
|
+
shas = deduplicateStrings(res.stdout.trim().split(/\s+/).filter(Boolean));
|
|
3719
|
+
} else {
|
|
3720
|
+
const args = ["blame", "--line-porcelain"];
|
|
3721
|
+
if (input.lines) args.push("-L", input.lines);
|
|
3722
|
+
args.push("--", input.file);
|
|
3723
|
+
const res = await gitAt(input.cwd, args, { allowFailure: true });
|
|
3724
|
+
if (!res.ok) throw new Error(res.stderr.trim() || `git blame failed for ${input.file}`);
|
|
3725
|
+
const collected = [];
|
|
3726
|
+
for (const line of res.stdout.split("\n")) {
|
|
3727
|
+
const parts = line.trim().split(/\s+/);
|
|
3728
|
+
if (parts.length >= 3 && /^[0-9a-f]{40,64}$/i.test(parts[0])) {
|
|
3729
|
+
if (!collected.includes(parts[0])) collected.push(parts[0]);
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
shas = collected;
|
|
3733
|
+
}
|
|
3734
|
+
const storage = await storageFor(input.storage, input.cwd);
|
|
3735
|
+
const resolutions = [];
|
|
3736
|
+
for (const sha of shas) resolutions.push(await lookupReviewTraceCommit({
|
|
3737
|
+
cwd: input.cwd,
|
|
3738
|
+
sha,
|
|
3739
|
+
storage
|
|
3740
|
+
}));
|
|
3741
|
+
return {
|
|
3742
|
+
file: input.file,
|
|
3743
|
+
range: input.lines ?? null,
|
|
3744
|
+
history: Boolean(input.history),
|
|
3745
|
+
resolutions
|
|
3746
|
+
};
|
|
3747
|
+
}
|
|
3748
|
+
/** Clears saved trace environment values and remote freshness timestamps without deleting normalized copies. */
|
|
3749
|
+
function clearTraceEnvCache() {
|
|
3750
|
+
clearTraceEnvCache$1();
|
|
3751
|
+
lastCheckedTimes.clear();
|
|
3752
|
+
}
|
|
3753
|
+
/** Subagent traces known locally or in the store, by name without ".jsonl". */
|
|
3754
|
+
async function listSessionSubagents(sessionId, storage) {
|
|
3755
|
+
const subagents = /* @__PURE__ */ new Set();
|
|
3756
|
+
for (const localSessionDir of findNormalizedSessionDirs(sessionId)) try {
|
|
3757
|
+
for (const entry of readdirSync(localSessionDir)) if (entry.endsWith(".jsonl") && entry !== "main.jsonl") subagents.add(entry.slice(0, -6));
|
|
3758
|
+
} catch {}
|
|
3759
|
+
const store = await storageFor(storage);
|
|
3760
|
+
if (store) {
|
|
3761
|
+
const names = await reachable(() => store.listSubagents(sessionId));
|
|
3762
|
+
for (const name of names ?? []) subagents.add(name);
|
|
3763
|
+
}
|
|
3764
|
+
return [...subagents].sort();
|
|
3765
|
+
}
|
|
3766
|
+
async function addSessionsFromPrScan(rootPath, commits, sessions) {
|
|
3767
|
+
const scannedPrs = /* @__PURE__ */ new Set();
|
|
3768
|
+
for (const commit of commits) {
|
|
3769
|
+
const pr = subjectPullNumber(commit.subject);
|
|
3770
|
+
if (pr === null || scannedPrs.has(pr)) continue;
|
|
3771
|
+
scannedPrs.add(pr);
|
|
3772
|
+
const prSessions = await prScanTrailerSessions(rootPath, commit.sha, pr);
|
|
3773
|
+
for (const sessionId of prSessions) {
|
|
3774
|
+
const existing = sessions.get(sessionId);
|
|
3775
|
+
if (existing) existing.commits.push({
|
|
3776
|
+
sha: commit.sha,
|
|
3777
|
+
subject: commit.subject
|
|
3778
|
+
});
|
|
3779
|
+
else sessions.set(sessionId, {
|
|
3780
|
+
sessionId,
|
|
3781
|
+
commits: [{
|
|
3782
|
+
sha: commit.sha,
|
|
3783
|
+
subject: commit.subject
|
|
3784
|
+
}]
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
async function addSessionsFromStoreIndex(storage, commits, sessions) {
|
|
3790
|
+
if (!storage) return;
|
|
3791
|
+
for (const commit of commits) {
|
|
3792
|
+
const indexed = await storage.sessionsForCommit(commit.sha);
|
|
3793
|
+
if (!indexed) continue;
|
|
3794
|
+
for (const sessionId of indexed.sessions) {
|
|
3795
|
+
const existing = sessions.get(sessionId);
|
|
3796
|
+
if (existing) existing.commits.push({
|
|
3797
|
+
sha: commit.sha,
|
|
3798
|
+
subject: commit.subject
|
|
3799
|
+
});
|
|
3800
|
+
else sessions.set(sessionId, {
|
|
3801
|
+
sessionId,
|
|
3802
|
+
commits: [{
|
|
3803
|
+
sha: commit.sha,
|
|
3804
|
+
subject: commit.subject
|
|
3805
|
+
}]
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
//#endregion
|
|
3811
|
+
//#region ../trace-core/src/trace-sync.ts
|
|
3812
|
+
/** Publishes the discovered local transcript and subagents through the selected store, preserving its consent and provenance checks. */
|
|
3813
|
+
async function syncReviewTrace(input) {
|
|
3814
|
+
const sessionId = input.sessionId.trim();
|
|
3815
|
+
if (!sessionIdSchema.safeParse(sessionId).success) throw new Error("Session id must be 8-128 characters of letters, digits, dots, dashes, or underscores.");
|
|
3816
|
+
const workDir = input.cwd ?? process.cwd();
|
|
3817
|
+
const storage = input.storage === void 0 ? await resolveTraceStorage({
|
|
3818
|
+
cwd: workDir,
|
|
3819
|
+
purpose: "write"
|
|
3820
|
+
}) : input.storage;
|
|
3821
|
+
if (!storage) throw new Error(`S3/R2 storage is not configured. Run \`whiteboard trace allow .\` to configure trace capture.`);
|
|
3822
|
+
const repo = input.repo ? parseRepo(input.repo) : await inferRepoFromGit(workDir);
|
|
3823
|
+
const local = await findLocalTrace(sessionId);
|
|
3824
|
+
if (!local) throw new Error(`No local trace found for session ${sessionId}.`);
|
|
3825
|
+
const { author, branch } = await readRepoMetaFields(workDir);
|
|
3826
|
+
const published = await storage.publish({
|
|
3827
|
+
sessionId,
|
|
3828
|
+
cwd: workDir,
|
|
3829
|
+
repo,
|
|
3830
|
+
harness: local.harness,
|
|
3831
|
+
files: [{
|
|
3832
|
+
name: "main",
|
|
3833
|
+
path: local.tracePath
|
|
3834
|
+
}, ...local.subagentPaths.map((sub) => ({
|
|
3835
|
+
name: sub.name,
|
|
3836
|
+
path: sub.path
|
|
3837
|
+
}))],
|
|
3838
|
+
commits: input.commits,
|
|
3839
|
+
branch,
|
|
3840
|
+
author
|
|
3841
|
+
});
|
|
3842
|
+
const result = {
|
|
3843
|
+
session: sessionId,
|
|
3844
|
+
repo: `${repo.owner}/${repo.repo}`,
|
|
3845
|
+
uploads: published.uploads
|
|
3846
|
+
};
|
|
3847
|
+
if (published.hosted) result.hosted = published.hosted;
|
|
3848
|
+
return result;
|
|
3849
|
+
}
|
|
3850
|
+
/** Validates a commit and associates its sessions through the selected store, failing when storage is absent. */
|
|
3851
|
+
async function writeReviewTraceCommitMapping(input) {
|
|
3852
|
+
const commit = commitShaSchema.parse(input.commit);
|
|
3853
|
+
const storage = await storageFor(input.storage, input.cwd);
|
|
3854
|
+
if (!storage) throw new Error(`Failed to write by-commit/${commit}.json.`);
|
|
3855
|
+
return storage.associateCommits({
|
|
3856
|
+
commit,
|
|
3857
|
+
sessions: input.sessions,
|
|
3858
|
+
branch: input.branch,
|
|
3859
|
+
resolve: async () => ({
|
|
3860
|
+
repo: await inferRepoFromGit(input.cwd),
|
|
3861
|
+
pr: await readSubjectPullNumber(input.cwd, commit)
|
|
3862
|
+
})
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3865
|
+
//#endregion
|
|
3866
|
+
export { emitJsonEvent as $, traceCaptureIdentity as A, findTraceRepository as B, TraceStorageDeniedError as C, recordTraceSyncFailure as D, listTraceSyncFailures as E, inferRepoFromGit as F, readStoreAuth as G, DEFAULT_STORE_ORIGIN as H, parseRepo as I, runStoreLogout as J, requireStoreClient as K, traceRepoName as L, readCachedTraceRepositoryTarget as M, requireTraceConsent as N, TraceProvenanceError as O, resolveTraceRepositoryTarget as P, withStoreAuthorization as Q, allowTraceRepository as R, traceNameFromObject as S, describeTraceSyncFailure as T, clearStoreAuth as U, readTraceUserConfig as V, openUrlInBrowser as W, StoreApiError as X, runStoreWhoami as Y, StoreClient as Z, isTraceStorageConfigured as _, describeTraceSession as a, selectTraceStorage as b, lookupReviewTraceBlame as c, findLocalTrace as d, failWithJsonError as et, isOpenCodeSessionId as f, describeSelection as g, extractTraceEventText as h, clearTraceEnvCache as i, gitCommonDirectory as j, recordTraceSessionProvenance as k, lookupReviewTraceCommit as l, readTrailerSessions as m, writeReviewTraceCommitMapping as n, jsonRequestedInArgv as nt, listReviewTraceSessions as o, listRepositoryTraceSessionIds as p, runStoreLogin as q, TUTORIAL_TRACE_SESSION_ID as r, loadReviewAgentTrace as s, syncReviewTrace as t, humanStream as tt, pullReviewTraceCorpus as u, loadS3TraceStorage as v, clearTraceSyncFailure as w, traceStorageExpectation as x, resolveTraceStorage as y, denyTraceRepository as z };
|