@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.
@@ -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 };