@prismatic-io/lux 0.0.2-preview.4 → 0.0.2-preview.5

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.
Files changed (110) hide show
  1. package/lib/assertions/authoring.d.ts +2 -2
  2. package/lib/assertions/authoring.d.ts.map +1 -1
  3. package/lib/assertions/authoring.js.map +1 -1
  4. package/lib/assertions/core/index.d.ts.map +1 -1
  5. package/lib/assertions/core/index.js +2 -1
  6. package/lib/assertions/core/index.js.map +1 -1
  7. package/lib/assertions/core/resource-checks.d.ts +19 -0
  8. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  9. package/lib/assertions/core/resource-checks.js +32 -0
  10. package/lib/assertions/core/resource-checks.js.map +1 -1
  11. package/lib/cli/program.d.ts.map +1 -1
  12. package/lib/cli/program.js +6 -65
  13. package/lib/cli/program.js.map +1 -1
  14. package/lib/cli/view.d.ts +13 -0
  15. package/lib/cli/view.d.ts.map +1 -0
  16. package/lib/cli/view.js +126 -0
  17. package/lib/cli/view.js.map +1 -0
  18. package/lib/core/action-events.d.ts +29 -0
  19. package/lib/core/action-events.d.ts.map +1 -0
  20. package/lib/core/action-events.js +51 -0
  21. package/lib/core/action-events.js.map +1 -0
  22. package/lib/core/driver-metrics.d.ts +98 -0
  23. package/lib/core/driver-metrics.d.ts.map +1 -0
  24. package/lib/core/driver-metrics.js +36 -0
  25. package/lib/core/driver-metrics.js.map +1 -0
  26. package/lib/core/events.js +2 -2
  27. package/lib/core/experiment.d.ts +19 -0
  28. package/lib/core/experiment.d.ts.map +1 -1
  29. package/lib/core/experiment.js +2 -0
  30. package/lib/core/experiment.js.map +1 -1
  31. package/lib/core/index.d.ts +2 -0
  32. package/lib/core/index.d.ts.map +1 -1
  33. package/lib/core/index.js +2 -0
  34. package/lib/core/index.js.map +1 -1
  35. package/lib/core/run.d.ts +50 -0
  36. package/lib/core/run.d.ts.map +1 -1
  37. package/lib/core/run.js +6 -0
  38. package/lib/core/run.js.map +1 -1
  39. package/lib/index.d.ts +3 -1
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +3 -1
  42. package/lib/index.js.map +1 -1
  43. package/lib/orchestrator/compare.d.ts +9 -1
  44. package/lib/orchestrator/compare.d.ts.map +1 -1
  45. package/lib/orchestrator/compare.js +8 -0
  46. package/lib/orchestrator/compare.js.map +1 -1
  47. package/lib/orchestrator/experiment-contracts.d.ts +19 -0
  48. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  49. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  50. package/lib/orchestrator/experiment-evaluation.js +17 -0
  51. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  52. package/lib/orchestrator/experiment-report.d.ts +19 -0
  53. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  54. package/lib/orchestrator/experiment-runs.d.ts +38 -0
  55. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  56. package/lib/orchestrator/index.d.ts +1 -1
  57. package/lib/orchestrator/index.d.ts.map +1 -1
  58. package/lib/orchestrator/index.js +1 -1
  59. package/lib/orchestrator/index.js.map +1 -1
  60. package/lib/orchestrator/list-runs.d.ts +6 -1
  61. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  62. package/lib/orchestrator/list-runs.js +15 -1
  63. package/lib/orchestrator/list-runs.js.map +1 -1
  64. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  65. package/lib/orchestrator/run-execution.js +11 -1
  66. package/lib/orchestrator/run-execution.js.map +1 -1
  67. package/lib/orchestrator/suite-compare.d.ts +34 -0
  68. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  69. package/lib/orchestrator/suite-summary.d.ts +68 -0
  70. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  71. package/lib/orchestrator/suite-summary.js +22 -7
  72. package/lib/orchestrator/suite-summary.js.map +1 -1
  73. package/lib/previewer/artifacts.d.ts +25 -0
  74. package/lib/previewer/artifacts.d.ts.map +1 -0
  75. package/lib/previewer/artifacts.js +180 -0
  76. package/lib/previewer/artifacts.js.map +1 -0
  77. package/lib/previewer/server.d.ts +104 -0
  78. package/lib/previewer/server.d.ts.map +1 -0
  79. package/lib/previewer/server.js +541 -0
  80. package/lib/previewer/server.js.map +1 -0
  81. package/package.json +2 -1
  82. package/skills/lux/references/eval-authoring.md +4 -2
  83. package/skills/lux-answerer/SKILL.md +1 -1
  84. package/src/assertions/authoring.ts +2 -0
  85. package/src/assertions/core/index.ts +2 -0
  86. package/src/assertions/core/resource-checks.ts +35 -0
  87. package/src/cli/program.ts +5 -91
  88. package/src/cli/view.ts +168 -0
  89. package/src/core/action-events.ts +59 -0
  90. package/src/core/driver-metrics.ts +51 -0
  91. package/src/core/events.ts +2 -2
  92. package/src/core/experiment.ts +2 -0
  93. package/src/core/index.ts +2 -0
  94. package/src/core/run.ts +6 -0
  95. package/src/index.ts +25 -0
  96. package/src/orchestrator/compare.ts +12 -0
  97. package/src/orchestrator/experiment-evaluation.ts +23 -0
  98. package/src/orchestrator/index.ts +1 -0
  99. package/src/orchestrator/list-runs.ts +25 -0
  100. package/src/orchestrator/run-execution.ts +12 -0
  101. package/src/orchestrator/suite-summary.ts +36 -8
  102. package/src/previewer/artifacts.ts +221 -0
  103. package/src/previewer/server.ts +763 -0
  104. package/viewer/app.css +2022 -0
  105. package/viewer/app.js +601 -0
  106. package/viewer/artifact-view.js +48 -0
  107. package/viewer/charts.js +86 -0
  108. package/viewer/detail.js +1108 -0
  109. package/viewer/format.js +129 -0
  110. package/viewer/index.html +151 -0
@@ -2,6 +2,9 @@ import { existsSync } from "node:fs";
2
2
  import { constants, lstat, open, readdir } from "node:fs/promises";
3
3
  import { basename, join, relative, sep } from "node:path";
4
4
  import {
5
+ ActionProgressPayloadSchema,
6
+ type Artifact,
7
+ ArtifactSchema,
5
8
  type GradingReport,
6
9
  GradingReportSchema,
7
10
  harnessUsage,
@@ -31,6 +34,10 @@ export type ListedRun = {
31
34
  startedAt: string | null;
32
35
  endedAt: string | null;
33
36
  durationMs: number | null;
37
+ timeToFirstActionMs: number | null;
38
+ timeToFirstMutationMs: number | null;
39
+ timeToFirstDurableMutationMs: number | null;
40
+ driverMetrics: NonNullable<RunMetadata["driverMetrics"]>;
34
41
  casePassed: boolean | null;
35
42
  caseScore: number | null;
36
43
  assertionPassed: number;
@@ -63,6 +70,10 @@ const summarizeRun = async (
63
70
  startedAt,
64
71
  endedAt,
65
72
  durationMs,
73
+ timeToFirstActionMs: meta?.actionTiming?.timeToFirstActionMs ?? null,
74
+ timeToFirstMutationMs: meta?.actionTiming?.timeToFirstMutationMs ?? null,
75
+ timeToFirstDurableMutationMs: meta?.actionTiming?.timeToFirstDurableMutationMs ?? null,
76
+ driverMetrics: meta?.driverMetrics ?? {},
66
77
  casePassed: grading?.casePassed ?? null,
67
78
  caseScore: grading?.caseScore ?? null,
68
79
  assertionPassed: passing,
@@ -156,6 +167,9 @@ export type RunDetail = {
156
167
  artifacts: string[];
157
168
  };
158
169
 
170
+ export const loadRunArtifacts = async (runDir: string): Promise<Artifact[]> =>
171
+ (await safeReadJson(join(runDir, "artifacts.json"), ArtifactSchema.array())) ?? [];
172
+
159
173
  const readEventsJsonl = async (path: string): Promise<RunEvent[]> => {
160
174
  if (!existsSync(path)) return [];
161
175
  if ((await lstat(path)).isSymbolicLink()) {
@@ -291,6 +305,13 @@ const appendMetadataDetail = (lines: string[], detail: RunDetail): void => {
291
305
  `phases: agent=${secs(phases.agent)} collect=${secs(phases.collect)} grading=${secs(phases.grading)}`,
292
306
  );
293
307
  }
308
+ const actions = detail.metadata.actionTiming;
309
+ if (actions) {
310
+ const value = (ms: number | undefined): string => (ms === undefined ? "—" : `${ms}ms`);
311
+ lines.push(
312
+ `first action: any=${value(actions.timeToFirstActionMs)} mutation=${value(actions.timeToFirstMutationMs)} durable-mutation=${value(actions.timeToFirstDurableMutationMs)}`,
313
+ );
314
+ }
294
315
  }
295
316
  };
296
317
 
@@ -362,6 +383,10 @@ const formatEventLine = (event: RunEvent): string => {
362
383
  }
363
384
  return `responded(custom): ${JSON.stringify(event.payload).slice(0, 80)}`;
364
385
  case "progress":
386
+ if (event.kind === "action") {
387
+ const payload = ActionProgressPayloadSchema.safeParse(event.payload).data;
388
+ return payload ? `action: ${payload.name} (${payload.category})` : "action: malformed";
389
+ }
365
390
  if (event.kind === "tool-call") {
366
391
  const payload = ToolCallPayloadSchema.safeParse(event.payload).data;
367
392
  return payload ? `tool-call: ${payload.name}` : "tool-call: malformed";
@@ -10,6 +10,7 @@ import {
10
10
  AnswererModelCallAccountingSchema,
11
11
  type AnswerHistoryEntry,
12
12
  type Artifact,
13
+ actionTiming,
13
14
  type EvalCase,
14
15
  emptyUsage,
15
16
  type FixturesRef,
@@ -18,6 +19,7 @@ import {
18
19
  guardRunObserver,
19
20
  inspectRunArtifact,
20
21
  type LifecycleStage,
22
+ normalizeDriverMetrics,
21
23
  type OperationalFailure,
22
24
  type PhaseDurations,
23
25
  type ReadyState,
@@ -183,6 +185,11 @@ const modelAccountingFromSummary = (
183
185
  ): { modelCalls: number; unpricedModelCalls?: number | undefined } | null =>
184
186
  SummaryModelAccountingSchema.safeParse(summary).data ?? null;
185
187
 
188
+ const metricsFromSummary = (summary: unknown) => {
189
+ if (!summary || typeof summary !== "object") return {};
190
+ return normalizeDriverMetrics((summary as { metrics?: unknown }).metrics);
191
+ };
192
+
186
193
  const artifactMeasurements = async (
187
194
  artifacts: readonly Artifact[],
188
195
  runDir: string,
@@ -390,6 +397,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
390
397
  metadata.exitReason = "error";
391
398
  metadata.operationalFailure = { phase: operationalPhase, reason };
392
399
  metadata.phaseDurationMs = phaseDurations(this.runDir.recorded);
400
+ metadata.actionTiming = actionTiming(this.runDir.recorded);
393
401
  await this.runDir.writeJson("run.json", metadata);
394
402
  await this.args.request.recordPreGradingAccounting?.(metadata);
395
403
  await this.args.request.recordModelAttemptsComplete?.(this.subjectAttemptIds);
@@ -407,6 +415,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
407
415
  this.lifecycle("collect-end");
408
416
  await this.runDir.events.drain();
409
417
  const completed = await this.completedAgentRun();
418
+ completed.metadata.actionTiming = actionTiming(this.runDir.recorded);
410
419
  this.metadata = completed.metadata;
411
420
  this.usage = completed.usage;
412
421
  this.run = completed.run;
@@ -430,6 +439,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
430
439
  grading.assertions.flatMap((assertion) => (assertion.usage ? [assertion.usage] : [])),
431
440
  );
432
441
  metadata.phaseDurationMs = phaseDurations(this.runDir.recorded);
442
+ metadata.actionTiming = actionTiming(this.runDir.recorded);
433
443
  await this.runDir.events.drain();
434
444
  await this.runDir.writeJson("run.json", metadata);
435
445
  await this.runDir.writeJson("artifacts.json", this.collection.artifacts);
@@ -563,6 +573,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
563
573
  const summary = outcome.kind === "done" ? outcome.done.summary : outcome.error.summary;
564
574
  const driverCost = costFromSummary(summary);
565
575
  const reportedModelAccounting = modelAccountingFromSummary(summary);
576
+ const driverMetrics = metricsFromSummary(summary);
566
577
  const answererAccounting = readAnswererModelAccounting(this.args.answerer);
567
578
  const agentCalls = this.ready ? (reportedModelAccounting?.modelCalls ?? 1) : 0;
568
579
  const operationalFailure = loopFailure(outcome, this.collection.error);
@@ -597,6 +608,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
597
608
  ...(operationalFailure ? { operationalFailure } : {}),
598
609
  provenance: this.runProvenance(),
599
610
  ...(driverCost !== undefined ? { cost: { usd: driverCost } } : {}),
611
+ ...(Object.keys(driverMetrics).length > 0 ? { driverMetrics } : {}),
600
612
  };
601
613
  return {
602
614
  metadata,
@@ -1,7 +1,13 @@
1
1
  import { rename, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import { harnessUsage, isUsageEmpty, type RunMetadata } from "../core/index.js";
4
+ import {
5
+ type DriverMetric,
6
+ DriverMetricUnitSchema,
7
+ harnessUsage,
8
+ isUsageEmpty,
9
+ type RunMetadata,
10
+ } from "../core/index.js";
5
11
  import { runComparisonIdentity } from "./comparison-identity.js";
6
12
  import { safeReadJson } from "./fs-read.js";
7
13
  import type { SuiteCaseResult } from "./orchestrator.js";
@@ -16,6 +22,9 @@ export const MetricSummarySchema = z.object({
16
22
  mean: z.number(),
17
23
  median: z.number(),
18
24
  p90: z.number(),
25
+ unit: z.exactOptional(DriverMetricUnitSchema),
26
+ label: z.exactOptional(z.string()),
27
+ better: z.exactOptional(z.enum(["lower", "higher", "neutral"])),
19
28
  });
20
29
  export type MetricSummary = z.infer<typeof MetricSummarySchema>;
21
30
 
@@ -59,13 +68,22 @@ export const SuiteSummarySchema = z.object({
59
68
  });
60
69
  export type SuiteSummary = z.infer<typeof SuiteSummarySchema>;
61
70
 
62
- type MetricSamples = Map<string, number[]>;
71
+ type MetricSamples = Map<
72
+ string,
73
+ { values: number[]; descriptor?: Omit<DriverMetric, "value"> | undefined }
74
+ >;
63
75
 
64
- const add = (samples: MetricSamples, name: string, value: number | undefined): void => {
76
+ const add = (
77
+ samples: MetricSamples,
78
+ name: string,
79
+ value: number | undefined,
80
+ descriptor?: Omit<DriverMetric, "value">,
81
+ ): void => {
65
82
  if (value === undefined || !Number.isFinite(value)) return;
66
- const values = samples.get(name) ?? [];
67
- values.push(value);
68
- samples.set(name, values);
83
+ const sample = samples.get(name) ?? { values: [], descriptor };
84
+ if (!sample.descriptor && descriptor) sample.descriptor = descriptor;
85
+ sample.values.push(value);
86
+ samples.set(name, sample);
69
87
  };
70
88
 
71
89
  const cacheHitRate = (usage: {
@@ -137,6 +155,13 @@ const collectMetadata = (samples: MetricSamples, metadata: RunMetadata): void =>
137
155
  add(samples, "phase.agentMs", metadata.phaseDurationMs?.agent);
138
156
  add(samples, "phase.collectMs", metadata.phaseDurationMs?.collect);
139
157
  add(samples, "phase.gradingMs", metadata.phaseDurationMs?.grading);
158
+ add(samples, "timeToFirstActionMs", metadata.actionTiming?.timeToFirstActionMs);
159
+ add(samples, "timeToFirstMutationMs", metadata.actionTiming?.timeToFirstMutationMs);
160
+ add(samples, "timeToFirstDurableMutationMs", metadata.actionTiming?.timeToFirstDurableMutationMs);
161
+ for (const [name, metric] of Object.entries(metadata.driverMetrics ?? {})) {
162
+ const { value, ...descriptor } = metric;
163
+ add(samples, `driver.${name}`, value, descriptor);
164
+ }
140
165
  };
141
166
 
142
167
  const percentile = (sorted: readonly number[], fraction: number): number => {
@@ -165,7 +190,10 @@ const summarizeMetrics = (samples: MetricSamples): Record<string, MetricSummary>
165
190
  Object.fromEntries(
166
191
  [...samples.entries()]
167
192
  .sort(([a], [b]) => a.localeCompare(b))
168
- .map(([name, values]) => [name, summarizeNumbers(values)]),
193
+ .map(([name, { values, descriptor }]) => [
194
+ name,
195
+ MetricSummarySchema.parse({ ...summarizeNumbers(values), ...descriptor }),
196
+ ]),
169
197
  );
170
198
 
171
199
  const summarizeOutcomes = (
@@ -258,7 +286,7 @@ export const buildSuiteSummary = (
258
286
  ? { score: result.grading.caseScore }
259
287
  : {}),
260
288
  metrics: Object.fromEntries(
261
- [...samples.entries()].map(([name, values]) => [name, values[0] ?? 0]),
289
+ [...samples.entries()].map(([name, sample]) => [name, sample.values[0] ?? 0]),
262
290
  ),
263
291
  };
264
292
  }),
@@ -0,0 +1,221 @@
1
+ import type { Artifact } from "../core/index.js";
2
+ import { inspectRunArtifact, readRunArtifactBytes } from "../core/index.js";
3
+
4
+ const MAX_PREVIEW_BYTES = 512_000;
5
+ const MAX_DIFF_LINES = 2_000;
6
+ const MAX_DIFF_CELLS = 1_000_000;
7
+
8
+ export type ArtifactPreview = {
9
+ path: string;
10
+ exists: boolean;
11
+ viewable: boolean;
12
+ size: number | null;
13
+ contentType: string | null;
14
+ text: string | null;
15
+ reason: string | null;
16
+ };
17
+
18
+ type ArtifactDiffOperation = {
19
+ kind: "same" | "added" | "removed";
20
+ lines: string[];
21
+ };
22
+
23
+ export type ArtifactComparison = {
24
+ path: string;
25
+ before: ArtifactPreview;
26
+ after: ArtifactPreview;
27
+ operations: ArtifactDiffOperation[] | null;
28
+ reason: string | null;
29
+ };
30
+
31
+ const evidenceArtifacts = (
32
+ indexed: readonly Artifact[],
33
+ knownPaths: readonly string[],
34
+ ): Artifact[] => {
35
+ const out = [...indexed];
36
+ const indexedPaths = new Set(indexed.map((artifact) => artifact.path));
37
+ for (const path of knownPaths) {
38
+ if (!indexedPaths.has(path)) out.push({ path });
39
+ }
40
+ return out;
41
+ };
42
+
43
+ export const loadArtifactPreview = async (
44
+ runDir: string,
45
+ indexed: readonly Artifact[],
46
+ knownPaths: readonly string[],
47
+ path: string,
48
+ ): Promise<ArtifactPreview> => {
49
+ if (!knownPaths.includes(path) && !indexed.some((artifact) => artifact.path === path)) {
50
+ return {
51
+ path,
52
+ exists: false,
53
+ viewable: false,
54
+ size: null,
55
+ contentType: null,
56
+ text: null,
57
+ reason: "artifact was not captured by this run",
58
+ };
59
+ }
60
+ const artifacts = evidenceArtifacts(indexed, knownPaths);
61
+ const run = { runDir, artifacts };
62
+ const inspected = await inspectRunArtifact(run, path);
63
+ const contentType = inspected.artifact?.contentType ?? null;
64
+ if (!inspected.stats?.isFile()) {
65
+ return {
66
+ path,
67
+ exists: false,
68
+ viewable: false,
69
+ size: inspected.stats?.size ?? null,
70
+ contentType,
71
+ text: null,
72
+ reason: inspected.issue ?? "artifact is not a regular file",
73
+ };
74
+ }
75
+ if (inspected.stats.size > MAX_PREVIEW_BYTES) {
76
+ return {
77
+ path,
78
+ exists: true,
79
+ viewable: false,
80
+ size: inspected.stats.size,
81
+ contentType,
82
+ text: null,
83
+ reason: `artifact exceeds the ${MAX_PREVIEW_BYTES}-byte preview limit`,
84
+ };
85
+ }
86
+ const bytes = await readRunArtifactBytes(run, path, { maxBytes: MAX_PREVIEW_BYTES });
87
+ let text: string;
88
+ try {
89
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
90
+ } catch {
91
+ return {
92
+ path,
93
+ exists: true,
94
+ viewable: false,
95
+ size: bytes.length,
96
+ contentType,
97
+ text: null,
98
+ reason: "artifact is not valid UTF-8 text",
99
+ };
100
+ }
101
+ if (text.includes("\0")) {
102
+ return {
103
+ path,
104
+ exists: true,
105
+ viewable: false,
106
+ size: bytes.length,
107
+ contentType,
108
+ text: null,
109
+ reason: "artifact appears to be binary",
110
+ };
111
+ }
112
+ return {
113
+ path,
114
+ exists: true,
115
+ viewable: true,
116
+ size: bytes.length,
117
+ contentType,
118
+ text,
119
+ reason: null,
120
+ };
121
+ };
122
+
123
+ const appendOperation = (
124
+ operations: ArtifactDiffOperation[],
125
+ kind: ArtifactDiffOperation["kind"],
126
+ line: string,
127
+ ): void => {
128
+ const current = operations.at(-1);
129
+ if (current?.kind === kind) current.lines.push(line);
130
+ else operations.push({ kind, lines: [line] });
131
+ };
132
+
133
+ const diffLines = (beforeText: string, afterText: string): ArtifactDiffOperation[] | null => {
134
+ const before = beforeText.split("\n");
135
+ const after = afterText.split("\n");
136
+ if (
137
+ before.length > MAX_DIFF_LINES ||
138
+ after.length > MAX_DIFF_LINES ||
139
+ before.length * after.length > MAX_DIFF_CELLS
140
+ ) {
141
+ return null;
142
+ }
143
+ const width = after.length + 1;
144
+ const table = new Uint32Array((before.length + 1) * width);
145
+ for (let left = before.length - 1; left >= 0; left--) {
146
+ for (let right = after.length - 1; right >= 0; right--) {
147
+ const cell = left * width + right;
148
+ table[cell] =
149
+ before[left] === after[right]
150
+ ? (table[(left + 1) * width + right + 1] ?? 0) + 1
151
+ : Math.max(table[(left + 1) * width + right] ?? 0, table[left * width + right + 1] ?? 0);
152
+ }
153
+ }
154
+ const operations: ArtifactDiffOperation[] = [];
155
+ let left = 0;
156
+ let right = 0;
157
+ while (left < before.length && right < after.length) {
158
+ if (before[left] === after[right]) {
159
+ appendOperation(operations, "same", before[left] ?? "");
160
+ left++;
161
+ right++;
162
+ } else if ((table[(left + 1) * width + right] ?? 0) >= (table[left * width + right + 1] ?? 0)) {
163
+ appendOperation(operations, "removed", before[left] ?? "");
164
+ left++;
165
+ } else {
166
+ appendOperation(operations, "added", after[right] ?? "");
167
+ right++;
168
+ }
169
+ }
170
+ while (left < before.length) {
171
+ appendOperation(operations, "removed", before[left] ?? "");
172
+ left++;
173
+ }
174
+ while (right < after.length) {
175
+ appendOperation(operations, "added", after[right] ?? "");
176
+ right++;
177
+ }
178
+ return operations;
179
+ };
180
+
181
+ export const compareArtifactPreviews = (
182
+ path: string,
183
+ before: ArtifactPreview,
184
+ after: ArtifactPreview,
185
+ ): ArtifactComparison => {
186
+ if (before.viewable && before.text !== null && !after.exists) {
187
+ return {
188
+ path,
189
+ before,
190
+ after,
191
+ operations: [{ kind: "removed", lines: before.text.split("\n") }],
192
+ reason: null,
193
+ };
194
+ }
195
+ if (!before.exists && after.viewable && after.text !== null) {
196
+ return {
197
+ path,
198
+ before,
199
+ after,
200
+ operations: [{ kind: "added", lines: after.text.split("\n") }],
201
+ reason: null,
202
+ };
203
+ }
204
+ if (!before.viewable || !after.viewable || before.text === null || after.text === null) {
205
+ return {
206
+ path,
207
+ before,
208
+ after,
209
+ operations: null,
210
+ reason: before.reason ?? after.reason ?? "artifact text is unavailable",
211
+ };
212
+ }
213
+ const operations = diffLines(before.text, after.text);
214
+ return {
215
+ path,
216
+ before,
217
+ after,
218
+ operations,
219
+ reason: operations ? null : "artifact is too line-dense for an inline diff",
220
+ };
221
+ };