@smithers-orchestrator/server 0.16.0
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/package.json +46 -0
- package/src/ConnectRequest.ts +17 -0
- package/src/EventFrame.ts +7 -0
- package/src/GatewayAuthConfig.ts +26 -0
- package/src/GatewayDefaults.ts +3 -0
- package/src/GatewayOptions.ts +13 -0
- package/src/GatewayTokenGrant.ts +5 -0
- package/src/GatewayWebhookConfig.ts +10 -0
- package/src/GatewayWebhookRunConfig.ts +4 -0
- package/src/GatewayWebhookSignalConfig.ts +6 -0
- package/src/HelloResponse.ts +18 -0
- package/src/RequestFrame.ts +6 -0
- package/src/ResponseFrame.ts +10 -0
- package/src/ServeOptions.ts +11 -0
- package/src/ServerOptions.ts +8 -0
- package/src/gateway.js +3402 -0
- package/src/gatewayRoutes/DiffSummary.ts +6 -0
- package/src/gatewayRoutes/GetNodeDiffRouteResult.ts +23 -0
- package/src/gatewayRoutes/NODE_OUTPUT_MAX_BYTES.js +1 -0
- package/src/gatewayRoutes/NODE_OUTPUT_WARN_BYTES.js +1 -0
- package/src/gatewayRoutes/NodeOutputResponse.ts +22 -0
- package/src/gatewayRoutes/NodeOutputRouteError.js +14 -0
- package/src/gatewayRoutes/getDevToolsSnapshot.js +428 -0
- package/src/gatewayRoutes/getNodeDiff.js +609 -0
- package/src/gatewayRoutes/getNodeOutput.js +504 -0
- package/src/gatewayRoutes/jumpToFrame.js +84 -0
- package/src/gatewayRoutes/streamDevTools.js +525 -0
- package/src/index.d.ts +953 -0
- package/src/index.js +1240 -0
- package/src/serve.js +315 -0
- package/src/smithersRuntime.js +63 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Effect, Metric } from "effect";
|
|
3
|
+
import { NodeDiffCache, NodeDiffTooLargeError } from "@smithers-orchestrator/db/cache/nodeDiffCache";
|
|
4
|
+
import { computeDiffBundleBetweenRefs } from "@smithers-orchestrator/engine/effect/diff-bundle";
|
|
5
|
+
import { runPromise } from "../smithersRuntime.js";
|
|
6
|
+
|
|
7
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").AttemptRow} AttemptRow */
|
|
9
|
+
/** @typedef {import("./GetNodeDiffRouteResult.js").GetNodeDiffRouteResult} GetNodeDiffRouteResult */
|
|
10
|
+
/** @typedef {import("./DiffSummary.js").DiffSummary} DiffSummary */
|
|
11
|
+
const RUN_ID_PATTERN = /^[a-z0-9_-]{1,64}$/;
|
|
12
|
+
const NODE_ID_PATTERN = /^[a-zA-Z0-9:_-]{1,128}$/;
|
|
13
|
+
const ITERATION_MAX = 2_147_483_647;
|
|
14
|
+
const CACHE_ROW_GAUGE_EMIT_MS = 5 * 60 * 1000;
|
|
15
|
+
const nodeDiffRequestTotal = Metric.counter("smithers_node_diff_request_total");
|
|
16
|
+
const nodeDiffComputeMs = Metric.histogram("smithers_node_diff_compute_ms");
|
|
17
|
+
const nodeDiffBytes = Metric.histogram("smithers_node_diff_bytes");
|
|
18
|
+
const nodeDiffCacheTotal = Metric.counter("smithers_node_diff_cache_total");
|
|
19
|
+
const nodeDiffCacheRows = Metric.gauge("smithers_node_diff_cache_rows");
|
|
20
|
+
// The gauge is process-global because "total rows across the DB" is the
|
|
21
|
+
// metric the spec requires, and the periodic emitter should not be tied to
|
|
22
|
+
// per-request lifecycle.
|
|
23
|
+
let lastCacheRowGaugeEmitAtMs = 0;
|
|
24
|
+
let cacheRowGaugeInflight = null;
|
|
25
|
+
/**
|
|
26
|
+
* @template M
|
|
27
|
+
* @param {M} metric
|
|
28
|
+
* @param {Record<string, string | number | null | undefined>} [labels]
|
|
29
|
+
* @returns {M}
|
|
30
|
+
*/
|
|
31
|
+
function taggedMetric(metric, labels = {}) {
|
|
32
|
+
let tagged = metric;
|
|
33
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
34
|
+
if (value === undefined || value === null) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
tagged = Metric.tagged(tagged, key, String(value));
|
|
38
|
+
}
|
|
39
|
+
return tagged;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* @param {() => Promise<void>} run
|
|
43
|
+
*/
|
|
44
|
+
async function swallow(run) {
|
|
45
|
+
try {
|
|
46
|
+
await run();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Observability never blocks the RPC path.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class GetNodeDiffError extends Error {
|
|
53
|
+
code;
|
|
54
|
+
details;
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} code
|
|
57
|
+
* @param {string} message
|
|
58
|
+
* @param {Record<string, unknown>} [details]
|
|
59
|
+
*/
|
|
60
|
+
constructor(code, message, details) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "GetNodeDiffError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.details = details;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* @param {unknown} runId
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function validateRunId(runId) {
|
|
72
|
+
if (typeof runId !== "string" || !RUN_ID_PATTERN.test(runId)) {
|
|
73
|
+
throw new GetNodeDiffError("InvalidRunId", "runId must match /^[a-z0-9_-]{1,64}$/.");
|
|
74
|
+
}
|
|
75
|
+
return runId;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* @param {unknown} nodeId
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function validateNodeId(nodeId) {
|
|
82
|
+
if (typeof nodeId !== "string" || !NODE_ID_PATTERN.test(nodeId)) {
|
|
83
|
+
throw new GetNodeDiffError("InvalidNodeId", "nodeId must match /^[a-zA-Z0-9:_-]{1,128}$/.");
|
|
84
|
+
}
|
|
85
|
+
return nodeId;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* @param {unknown} iteration
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function validateIteration(iteration) {
|
|
92
|
+
if (typeof iteration !== "number" ||
|
|
93
|
+
!Number.isInteger(iteration) ||
|
|
94
|
+
iteration < 0 ||
|
|
95
|
+
iteration > ITERATION_MAX) {
|
|
96
|
+
throw new GetNodeDiffError("InvalidIteration", "iteration must be an i32 non-negative integer.");
|
|
97
|
+
}
|
|
98
|
+
return iteration;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} message
|
|
102
|
+
*/
|
|
103
|
+
function isWorkingTreeDirty(message) {
|
|
104
|
+
return /working copy|dirty|conflict|cannot restore/i.test(message);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* @param {string | undefined} value
|
|
108
|
+
*/
|
|
109
|
+
function safeVcsMessage(value) {
|
|
110
|
+
const normalized = String(value ?? "").trim();
|
|
111
|
+
if (!normalized) {
|
|
112
|
+
return "VCS operation failed.";
|
|
113
|
+
}
|
|
114
|
+
return normalized.length > 512 ? `${normalized.slice(0, 512)}…` : normalized;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} cwd
|
|
118
|
+
* @param {string[]} args
|
|
119
|
+
*/
|
|
120
|
+
function runJj(cwd, args) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const child = spawn("jj", args, {
|
|
123
|
+
cwd,
|
|
124
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
125
|
+
});
|
|
126
|
+
let stdout = "";
|
|
127
|
+
let stderr = "";
|
|
128
|
+
child.stdout.setEncoding("utf8");
|
|
129
|
+
child.stderr.setEncoding("utf8");
|
|
130
|
+
child.stdout.on("data", (chunk) => {
|
|
131
|
+
stdout += chunk;
|
|
132
|
+
});
|
|
133
|
+
child.stderr.on("data", (chunk) => {
|
|
134
|
+
stderr += chunk;
|
|
135
|
+
});
|
|
136
|
+
child.once("error", reject);
|
|
137
|
+
child.once("close", (code) => {
|
|
138
|
+
resolve({
|
|
139
|
+
code: typeof code === "number" ? code : 1,
|
|
140
|
+
stdout,
|
|
141
|
+
stderr,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} pointer
|
|
148
|
+
* @param {string} cwd
|
|
149
|
+
* @returns {Promise<string | null>}
|
|
150
|
+
*/
|
|
151
|
+
async function resolveCommitPointer(pointer, cwd) {
|
|
152
|
+
const res = await runJj(cwd, ["log", "-r", pointer, "--no-graph", "--template", "commit_id"]);
|
|
153
|
+
if (res.code !== 0) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const commitId = res.stdout.trim();
|
|
157
|
+
return commitId.length > 0 ? commitId : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Pick the base ref for a target attempt's diff.
|
|
161
|
+
*
|
|
162
|
+
* Correctness: the base must be the end of the *previous* attempt of the
|
|
163
|
+
* *same task* (same runId/nodeId/iteration/jjCwd). Earlier attempts of
|
|
164
|
+
* unrelated tasks must never be considered; otherwise a retry on node B
|
|
165
|
+
* could pick an attempt from node A as its base.
|
|
166
|
+
*
|
|
167
|
+
* Ordering uses `finishedAtMs` (the actual task end time). Ties fall back
|
|
168
|
+
* to attempt number descending.
|
|
169
|
+
*
|
|
170
|
+
* @param {AttemptRow[]} attempts
|
|
171
|
+
* @param {AttemptRow} targetAttempt
|
|
172
|
+
* @param {string | null | undefined} runVcsRevision
|
|
173
|
+
* @returns {string | null}
|
|
174
|
+
*/
|
|
175
|
+
function resolveBaseRef(attempts, targetAttempt, runVcsRevision) {
|
|
176
|
+
const previousSameTask = attempts
|
|
177
|
+
.filter((row) => row.nodeId === targetAttempt.nodeId &&
|
|
178
|
+
row.iteration === targetAttempt.iteration &&
|
|
179
|
+
row.jjCwd === targetAttempt.jjCwd &&
|
|
180
|
+
row.attempt < targetAttempt.attempt &&
|
|
181
|
+
typeof row.jjPointer === "string" &&
|
|
182
|
+
row.jjPointer.length > 0 &&
|
|
183
|
+
typeof row.finishedAtMs === "number")
|
|
184
|
+
.sort((left, right) => {
|
|
185
|
+
const leftFinished = Number(left.finishedAtMs ?? -1);
|
|
186
|
+
const rightFinished = Number(right.finishedAtMs ?? -1);
|
|
187
|
+
if (leftFinished !== rightFinished) {
|
|
188
|
+
return rightFinished - leftFinished;
|
|
189
|
+
}
|
|
190
|
+
return right.attempt - left.attempt;
|
|
191
|
+
})[0];
|
|
192
|
+
if (previousSameTask?.jjPointer) {
|
|
193
|
+
return previousSameTask.jjPointer;
|
|
194
|
+
}
|
|
195
|
+
// Fall back to the most recent attempt (any task) in the same checkout
|
|
196
|
+
// that finished strictly before this attempt started. This captures
|
|
197
|
+
// "the previous task in the run" when this is the first attempt of a
|
|
198
|
+
// new node.
|
|
199
|
+
const previousAny = attempts
|
|
200
|
+
.filter((row) => row.jjCwd === targetAttempt.jjCwd &&
|
|
201
|
+
!(row.nodeId === targetAttempt.nodeId && row.iteration === targetAttempt.iteration) &&
|
|
202
|
+
typeof row.jjPointer === "string" &&
|
|
203
|
+
row.jjPointer.length > 0 &&
|
|
204
|
+
typeof row.finishedAtMs === "number" &&
|
|
205
|
+
row.finishedAtMs <= targetAttempt.startedAtMs)
|
|
206
|
+
.sort((left, right) => {
|
|
207
|
+
const leftFinished = Number(left.finishedAtMs ?? -1);
|
|
208
|
+
const rightFinished = Number(right.finishedAtMs ?? -1);
|
|
209
|
+
if (leftFinished !== rightFinished) {
|
|
210
|
+
return rightFinished - leftFinished;
|
|
211
|
+
}
|
|
212
|
+
return right.attempt - left.attempt;
|
|
213
|
+
})[0];
|
|
214
|
+
if (previousAny?.jjPointer) {
|
|
215
|
+
return previousAny.jjPointer;
|
|
216
|
+
}
|
|
217
|
+
if (typeof runVcsRevision === "string" && runVcsRevision.length > 0) {
|
|
218
|
+
return runVcsRevision;
|
|
219
|
+
}
|
|
220
|
+
return targetAttempt.jjPointer ?? null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Emit the cache-rows gauge at most every CACHE_ROW_GAUGE_EMIT_MS.
|
|
224
|
+
* Total rows (no runId filter). Runs out-of-band so no request waits on it.
|
|
225
|
+
*
|
|
226
|
+
* @param {NodeDiffCache} cache
|
|
227
|
+
* @param {(effect: Effect.Effect<void>) => Promise<unknown>} emitEffect
|
|
228
|
+
* @param {() => number} nowMs
|
|
229
|
+
*/
|
|
230
|
+
function scheduleCacheRowGauge(cache, emitEffect, nowMs) {
|
|
231
|
+
if (cacheRowGaugeInflight) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (nowMs() - lastCacheRowGaugeEmitAtMs < CACHE_ROW_GAUGE_EMIT_MS) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
lastCacheRowGaugeEmitAtMs = nowMs();
|
|
238
|
+
cacheRowGaugeInflight = (async () => {
|
|
239
|
+
try {
|
|
240
|
+
const rows = await cache.countRows();
|
|
241
|
+
await emitEffect(Metric.set(nodeDiffCacheRows, rows));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Gauge is best-effort.
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
cacheRowGaugeInflight = null;
|
|
248
|
+
}
|
|
249
|
+
})();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* @param {{
|
|
253
|
+
* runId: unknown;
|
|
254
|
+
* nodeId: unknown;
|
|
255
|
+
* iteration: unknown;
|
|
256
|
+
* resolveRun: (runId: string) => Promise<{ adapter: SmithersDb } | null>;
|
|
257
|
+
* emitEffect?: (effect: Effect.Effect<void>) => Promise<unknown>;
|
|
258
|
+
* computeDiffBundleImpl?: (baseRef: string, cwd: string, seq?: number) => Promise<import("@smithers-orchestrator/engine/effect/DiffBundle").DiffBundle>;
|
|
259
|
+
* computeDiffBundleBetweenRefsImpl?: (baseRef: string, targetRef: string, cwd: string, seq?: number) => Promise<import("@smithers-orchestrator/engine/effect/DiffBundle").DiffBundle>;
|
|
260
|
+
* getCurrentPointerImpl?: (cwd: string) => Promise<string | null>;
|
|
261
|
+
* resolveCommitPointerImpl?: (pointer: string, cwd: string) => Promise<string | null>;
|
|
262
|
+
* restorePointerImpl?: (pointer: string, cwd: string) => Promise<{ success: boolean; error?: string }>;
|
|
263
|
+
* nowMs?: () => number;
|
|
264
|
+
* stat?: boolean;
|
|
265
|
+
* }} opts
|
|
266
|
+
* @returns {Promise<GetNodeDiffRouteResult>}
|
|
267
|
+
*/
|
|
268
|
+
export async function getNodeDiffRoute({
|
|
269
|
+
runId: rawRunId,
|
|
270
|
+
nodeId: rawNodeId,
|
|
271
|
+
iteration: rawIteration,
|
|
272
|
+
resolveRun,
|
|
273
|
+
emitEffect = (effect) => runPromise(effect),
|
|
274
|
+
computeDiffBundleImpl,
|
|
275
|
+
computeDiffBundleBetweenRefsImpl,
|
|
276
|
+
getCurrentPointerImpl,
|
|
277
|
+
resolveCommitPointerImpl = resolveCommitPointer,
|
|
278
|
+
restorePointerImpl,
|
|
279
|
+
nowMs = () => Date.now(),
|
|
280
|
+
// stat: true → return summary only ({ files, filesChanged, added,
|
|
281
|
+
// removed }). Bypasses the cache and the full-bundle JSON size guard
|
|
282
|
+
// so very large diffs still return a summary. The full diff text is
|
|
283
|
+
// never serialized.
|
|
284
|
+
stat = false,
|
|
285
|
+
}) {
|
|
286
|
+
// Prefer the explicit between-refs impl. If a caller only passed
|
|
287
|
+
// computeDiffBundleImpl (the legacy working-tree variant, or a mock in
|
|
288
|
+
// tests), adapt it: pass (baseRef, cwd, seq) and ignore targetRef since
|
|
289
|
+
// the mock's return value is decoupled from the actual VCS state.
|
|
290
|
+
const effectiveComputeBetween = computeDiffBundleBetweenRefsImpl
|
|
291
|
+
?? (computeDiffBundleImpl
|
|
292
|
+
? async (baseRef, _targetRef, cwd, seq) => computeDiffBundleImpl(baseRef, cwd, seq)
|
|
293
|
+
: computeDiffBundleBetweenRefs);
|
|
294
|
+
let resultLabel = "error";
|
|
295
|
+
let cacheResultLabel = "miss";
|
|
296
|
+
let sizeBytes = 0;
|
|
297
|
+
let computeDurationMs = 0;
|
|
298
|
+
const rootSpanAttrs = {
|
|
299
|
+
runId: typeof rawRunId === "string" ? rawRunId : "",
|
|
300
|
+
nodeId: typeof rawNodeId === "string" ? rawNodeId : "",
|
|
301
|
+
iteration: typeof rawIteration === "number" ? rawIteration : -1,
|
|
302
|
+
cacheResult: "unknown",
|
|
303
|
+
};
|
|
304
|
+
const finalize = async () => {
|
|
305
|
+
await swallow(() => emitEffect(Effect.all([
|
|
306
|
+
Metric.increment(taggedMetric(nodeDiffRequestTotal, { result: resultLabel })),
|
|
307
|
+
Effect.logInfo("getNodeDiff request handled").pipe(Effect.annotateLogs({
|
|
308
|
+
...rootSpanAttrs,
|
|
309
|
+
result: resultLabel,
|
|
310
|
+
cacheResult: rootSpanAttrs.cacheResult,
|
|
311
|
+
sizeBytes,
|
|
312
|
+
computeDurationMs,
|
|
313
|
+
})),
|
|
314
|
+
], { discard: true })));
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
const runId = validateRunId(rawRunId);
|
|
318
|
+
const nodeId = validateNodeId(rawNodeId);
|
|
319
|
+
const iteration = validateIteration(rawIteration);
|
|
320
|
+
rootSpanAttrs.runId = runId;
|
|
321
|
+
rootSpanAttrs.nodeId = nodeId;
|
|
322
|
+
rootSpanAttrs.iteration = iteration;
|
|
323
|
+
const resolved = await resolveRun(runId);
|
|
324
|
+
if (!resolved) {
|
|
325
|
+
throw new GetNodeDiffError("RunNotFound", `Run not found: ${runId}`);
|
|
326
|
+
}
|
|
327
|
+
const adapter = resolved.adapter;
|
|
328
|
+
const node = await adapter.getNode(runId, nodeId, iteration);
|
|
329
|
+
if (!node) {
|
|
330
|
+
throw new GetNodeDiffError("NodeNotFound", `Node not found: ${runId}/${nodeId}/${iteration}`);
|
|
331
|
+
}
|
|
332
|
+
const attemptsForNode = await adapter.listAttempts(runId, nodeId, iteration);
|
|
333
|
+
const latestAttempt = attemptsForNode[0];
|
|
334
|
+
if (!latestAttempt) {
|
|
335
|
+
throw new GetNodeDiffError("AttemptNotFound", `Attempt not found for ${runId}/${nodeId}/${iteration}`);
|
|
336
|
+
}
|
|
337
|
+
if (latestAttempt.state === "in-progress") {
|
|
338
|
+
throw new GetNodeDiffError("AttemptNotFinished", "Attempt is still running.");
|
|
339
|
+
}
|
|
340
|
+
const run = await adapter.getRun(runId);
|
|
341
|
+
// Blocker #8: Explicit branch on VCS type. Only jj is supported today.
|
|
342
|
+
// Git-backed runs typically lack `jjPointer` on their attempts, so
|
|
343
|
+
// without this check the handler returns a confusing
|
|
344
|
+
// AttemptNotFinished. Return a clear VcsError instead.
|
|
345
|
+
//
|
|
346
|
+
// Decision note: git-backed diff support is deferred. Once the engine
|
|
347
|
+
// can resolve start/end commit hashes for a task under git, extend
|
|
348
|
+
// this to branch on vcsType === "git" and compute via
|
|
349
|
+
// computeDiffBundleBetweenRefsImpl (which is read-only and
|
|
350
|
+
// already-VCS-agnostic at the `git diff` layer).
|
|
351
|
+
if (run && typeof run.vcsType === "string" && run.vcsType !== "jj") {
|
|
352
|
+
throw new GetNodeDiffError("VcsError", `Unsupported VCS type: ${run.vcsType}. Only jj-backed runs are supported.`);
|
|
353
|
+
}
|
|
354
|
+
if (typeof latestAttempt.jjPointer !== "string" || latestAttempt.jjPointer.length === 0) {
|
|
355
|
+
throw new GetNodeDiffError("AttemptNotFinished", "Attempt has no finished jj pointer.");
|
|
356
|
+
}
|
|
357
|
+
if (typeof latestAttempt.jjCwd !== "string" || latestAttempt.jjCwd.length === 0) {
|
|
358
|
+
throw new GetNodeDiffError("VcsError", "Attempt did not record a jj working directory.");
|
|
359
|
+
}
|
|
360
|
+
const attemptsForRun = await adapter.listAttemptsForRun(runId);
|
|
361
|
+
const baseRefCandidate = resolveBaseRef(attemptsForRun, latestAttempt, run?.vcsRevision);
|
|
362
|
+
if (!baseRefCandidate) {
|
|
363
|
+
throw new GetNodeDiffError("AttemptNotFound", "Could not resolve a base jj pointer for this attempt.");
|
|
364
|
+
}
|
|
365
|
+
const cacheLogger = {
|
|
366
|
+
warn: (message, details) => {
|
|
367
|
+
void swallow(() => emitEffect(Effect.logWarning(message).pipe(Effect.annotateLogs({
|
|
368
|
+
runId,
|
|
369
|
+
nodeId,
|
|
370
|
+
iteration,
|
|
371
|
+
...details,
|
|
372
|
+
}))));
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
const cache = new NodeDiffCache(adapter, cacheLogger);
|
|
376
|
+
/**
|
|
377
|
+
* @param {"hit" | "miss"} cacheResult
|
|
378
|
+
* @param {number} bytes
|
|
379
|
+
*/
|
|
380
|
+
const recordCacheResult = async (cacheResult, bytes) => {
|
|
381
|
+
sizeBytes = bytes;
|
|
382
|
+
resultLabel = cacheResult;
|
|
383
|
+
cacheResultLabel = cacheResult;
|
|
384
|
+
rootSpanAttrs.cacheResult = cacheResult;
|
|
385
|
+
await swallow(() => emitEffect(Effect.all([
|
|
386
|
+
Metric.increment(taggedMetric(nodeDiffCacheTotal, { result: cacheResult })),
|
|
387
|
+
Metric.update(nodeDiffBytes, bytes),
|
|
388
|
+
], { discard: true })));
|
|
389
|
+
};
|
|
390
|
+
// Blocker #7: gauge is total rows (no runId filter), emitted
|
|
391
|
+
// out-of-band at most every CACHE_ROW_GAUGE_EMIT_MS. Does not block
|
|
392
|
+
// this request.
|
|
393
|
+
scheduleCacheRowGauge(cache, emitEffect, nowMs);
|
|
394
|
+
let key = { runId, nodeId, iteration, baseRef: baseRefCandidate };
|
|
395
|
+
// Finding #5: stat-only path bypasses cache and the JSON size cap so
|
|
396
|
+
// large diffs (>50MB) can still return a summary. Summaries are
|
|
397
|
+
// cheap to recompute and never hit the payload limit.
|
|
398
|
+
if (stat) {
|
|
399
|
+
const targetPointer = (await resolveCommitPointerImpl(latestAttempt.jjPointer, latestAttempt.jjCwd)) ?? latestAttempt.jjPointer;
|
|
400
|
+
const resolvedBaseRefStat = (await resolveCommitPointerImpl(baseRefCandidate, latestAttempt.jjCwd)) ?? baseRefCandidate;
|
|
401
|
+
const computeStartedAt = nowMs();
|
|
402
|
+
const bundle = await effectiveComputeBetween(resolvedBaseRefStat, targetPointer, latestAttempt.jjCwd, latestAttempt.attempt);
|
|
403
|
+
computeDurationMs = Math.max(0, nowMs() - computeStartedAt);
|
|
404
|
+
const summary = summarizeBundle(bundle);
|
|
405
|
+
await swallow(() => emitEffect(Effect.all([
|
|
406
|
+
Metric.update(nodeDiffComputeMs, computeDurationMs),
|
|
407
|
+
], { discard: true })));
|
|
408
|
+
resultLabel = "ok";
|
|
409
|
+
rootSpanAttrs.cacheResult = "bypass";
|
|
410
|
+
await finalize();
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
payload: {
|
|
414
|
+
seq: latestAttempt.attempt ?? 1,
|
|
415
|
+
baseRef: resolvedBaseRefStat,
|
|
416
|
+
summary,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Blocker #5: explicit span for cache lookup.
|
|
421
|
+
const directHit = await emitEffectSpan(emitEffect, "db.nodeDiffs.get", rootSpanAttrs, () => cache.get(key));
|
|
422
|
+
if (directHit) {
|
|
423
|
+
await recordCacheResult("hit", directHit.sizeBytes);
|
|
424
|
+
await finalize();
|
|
425
|
+
return { ok: true, payload: directHit.bundle };
|
|
426
|
+
}
|
|
427
|
+
const resolvedBaseRef = (await resolveCommitPointerImpl(baseRefCandidate, latestAttempt.jjCwd)) ?? baseRefCandidate;
|
|
428
|
+
if (resolvedBaseRef !== baseRefCandidate) {
|
|
429
|
+
key = { ...key, baseRef: resolvedBaseRef };
|
|
430
|
+
const resolvedHit = await emitEffectSpan(emitEffect, "db.nodeDiffs.get", rootSpanAttrs, () => cache.get(key));
|
|
431
|
+
if (resolvedHit) {
|
|
432
|
+
await recordCacheResult("hit", resolvedHit.sizeBytes);
|
|
433
|
+
await finalize();
|
|
434
|
+
return { ok: true, payload: resolvedHit.bundle };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const result = await cache.getOrCompute(key, async () => {
|
|
438
|
+
// Blocker #2 & #3: read-only compute. No restore of working
|
|
439
|
+
// copy, no chance of interfering with concurrent runs or
|
|
440
|
+
// leaking untracked files. Resolve both endpoints to immutable
|
|
441
|
+
// commit IDs and diff directly.
|
|
442
|
+
const targetPointer = (await resolveCommitPointerImpl(latestAttempt.jjPointer, latestAttempt.jjCwd)) ?? latestAttempt.jjPointer;
|
|
443
|
+
const computeStartedAt = nowMs();
|
|
444
|
+
try {
|
|
445
|
+
// Blocker #5: vcs.computeDiffBundle span with fileCount/bytes.
|
|
446
|
+
const bundle = await effectiveComputeBetween(key.baseRef, targetPointer, latestAttempt.jjCwd, latestAttempt.attempt);
|
|
447
|
+
computeDurationMs = Math.max(0, nowMs() - computeStartedAt);
|
|
448
|
+
const fileCount = Array.isArray(bundle?.patches) ? bundle.patches.length : 0;
|
|
449
|
+
const bytes = Buffer.byteLength(JSON.stringify(bundle ?? {}), "utf8");
|
|
450
|
+
// Blocker #6: only the cold compute time feeds the compute
|
|
451
|
+
// histogram, regardless of cache hits and validation errors.
|
|
452
|
+
await swallow(() => emitEffect(Effect.all([
|
|
453
|
+
Metric.update(nodeDiffComputeMs, computeDurationMs),
|
|
454
|
+
Effect.logDebug("vcs.computeDiffBundle").pipe(Effect.annotateLogs({
|
|
455
|
+
...rootSpanAttrs,
|
|
456
|
+
span: "vcs.computeDiffBundle",
|
|
457
|
+
fileCount,
|
|
458
|
+
bytes,
|
|
459
|
+
durationMs: computeDurationMs,
|
|
460
|
+
})),
|
|
461
|
+
], { discard: true })));
|
|
462
|
+
return bundle;
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
computeDurationMs = Math.max(0, nowMs() - computeStartedAt);
|
|
466
|
+
// Blocker #5: unrecoverable VCS errors log at error level
|
|
467
|
+
// (no diff content).
|
|
468
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
469
|
+
await swallow(() => emitEffect(Effect.logError("vcs.computeDiffBundle failed").pipe(Effect.annotateLogs({
|
|
470
|
+
...rootSpanAttrs,
|
|
471
|
+
span: "vcs.computeDiffBundle",
|
|
472
|
+
error: safeVcsMessage(message),
|
|
473
|
+
}))));
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
// Blocker #5: db.nodeDiffs.upsert span log. The actual upsert
|
|
478
|
+
// happens inside NodeDiffCache.getOrCompute; we emit a span marker
|
|
479
|
+
// around the write here so traces show it.
|
|
480
|
+
if (result.cacheResult === "miss") {
|
|
481
|
+
await swallow(() => emitEffect(Effect.logDebug("db.nodeDiffs.upsert").pipe(Effect.annotateLogs({
|
|
482
|
+
...rootSpanAttrs,
|
|
483
|
+
span: "db.nodeDiffs.upsert",
|
|
484
|
+
sizeBytes: result.sizeBytes,
|
|
485
|
+
}))));
|
|
486
|
+
}
|
|
487
|
+
await recordCacheResult(result.cacheResult, result.sizeBytes);
|
|
488
|
+
await finalize();
|
|
489
|
+
return { ok: true, payload: result.bundle };
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (error instanceof NodeDiffTooLargeError) {
|
|
493
|
+
resultLabel = "error";
|
|
494
|
+
sizeBytes = error.sizeBytes;
|
|
495
|
+
await finalize();
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
error: {
|
|
499
|
+
code: "DiffTooLarge",
|
|
500
|
+
message: `${error.message} [truncated]`,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (error instanceof GetNodeDiffError) {
|
|
505
|
+
resultLabel = "error";
|
|
506
|
+
await finalize();
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
error: {
|
|
510
|
+
code: error.code,
|
|
511
|
+
message: error.message,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const safeMessage = safeVcsMessage(error instanceof Error ? error.message : String(error));
|
|
516
|
+
const code = isWorkingTreeDirty(safeMessage) ? "WorkingTreeDirty" : "VcsError";
|
|
517
|
+
resultLabel = "error";
|
|
518
|
+
// Blocker #5: unrecoverable errors log at error level.
|
|
519
|
+
await swallow(() => emitEffect(Effect.logError("getNodeDiff failed").pipe(Effect.annotateLogs({
|
|
520
|
+
...rootSpanAttrs,
|
|
521
|
+
errorCode: code,
|
|
522
|
+
error: safeMessage,
|
|
523
|
+
}))));
|
|
524
|
+
await finalize();
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
error: {
|
|
528
|
+
code,
|
|
529
|
+
message: safeMessage,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Wrap a promise-returning function in a named log span so observability
|
|
536
|
+
* backends see it as a child of the root span.
|
|
537
|
+
*
|
|
538
|
+
* @template T
|
|
539
|
+
* @param {(effect: Effect.Effect<void>) => Promise<unknown>} emitEffect
|
|
540
|
+
* @param {string} spanName
|
|
541
|
+
* @param {Record<string, unknown>} attrs
|
|
542
|
+
* @param {() => Promise<T>} run
|
|
543
|
+
* @returns {Promise<T>}
|
|
544
|
+
*/
|
|
545
|
+
async function emitEffectSpan(emitEffect, spanName, attrs, run) {
|
|
546
|
+
const startedAt = Date.now();
|
|
547
|
+
try {
|
|
548
|
+
const result = await run();
|
|
549
|
+
await swallow(() => emitEffect(Effect.logDebug(spanName).pipe(Effect.annotateLogs({
|
|
550
|
+
...attrs,
|
|
551
|
+
span: spanName,
|
|
552
|
+
durationMs: Date.now() - startedAt,
|
|
553
|
+
}))));
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
await swallow(() => emitEffect(Effect.logError(`${spanName} failed`).pipe(Effect.annotateLogs({
|
|
558
|
+
...attrs,
|
|
559
|
+
span: spanName,
|
|
560
|
+
durationMs: Date.now() - startedAt,
|
|
561
|
+
error: error instanceof Error ? error.message : String(error),
|
|
562
|
+
}))));
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Compute a lightweight per-file / total summary of a DiffBundle without
|
|
568
|
+
* retaining full patch text. Counts lines starting with "+"/"-" excluding
|
|
569
|
+
* file headers ("+++"/"---").
|
|
570
|
+
*
|
|
571
|
+
* @param {{ patches?: Array<{ path: string; diff?: string }> }} bundle
|
|
572
|
+
* @returns {DiffSummary}
|
|
573
|
+
*/
|
|
574
|
+
function summarizeBundle(bundle) {
|
|
575
|
+
const files = [];
|
|
576
|
+
let totalAdded = 0;
|
|
577
|
+
let totalRemoved = 0;
|
|
578
|
+
const patches = Array.isArray(bundle?.patches) ? bundle.patches : [];
|
|
579
|
+
for (const patch of patches) {
|
|
580
|
+
let added = 0;
|
|
581
|
+
let removed = 0;
|
|
582
|
+
const text = typeof patch?.diff === "string" ? patch.diff : "";
|
|
583
|
+
// Iterate lines without a huge split allocation for very large diffs.
|
|
584
|
+
let cursor = 0;
|
|
585
|
+
while (cursor < text.length) {
|
|
586
|
+
const nl = text.indexOf("\n", cursor);
|
|
587
|
+
const end = nl === -1 ? text.length : nl;
|
|
588
|
+
const ch = text.charCodeAt(cursor);
|
|
589
|
+
// Skip "+++ " / "--- " headers; count "+"/"-" content lines.
|
|
590
|
+
if (ch === 43 /* + */ && !(text.charCodeAt(cursor + 1) === 43 && text.charCodeAt(cursor + 2) === 43)) {
|
|
591
|
+
added++;
|
|
592
|
+
}
|
|
593
|
+
else if (ch === 45 /* - */ && !(text.charCodeAt(cursor + 1) === 45 && text.charCodeAt(cursor + 2) === 45)) {
|
|
594
|
+
removed++;
|
|
595
|
+
}
|
|
596
|
+
cursor = end + 1;
|
|
597
|
+
}
|
|
598
|
+
totalAdded += added;
|
|
599
|
+
totalRemoved += removed;
|
|
600
|
+
files.push({ path: String(patch?.path ?? ""), added, removed });
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
filesChanged: files.length,
|
|
604
|
+
added: totalAdded,
|
|
605
|
+
removed: totalRemoved,
|
|
606
|
+
files,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
export { RUN_ID_PATTERN, NODE_ID_PATTERN, ITERATION_MAX, summarizeBundle };
|