@workos/quickstudy 0.0.1

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/examples/harbor-notes/README.md +40 -0
  4. package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
  5. package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
  6. package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
  7. package/examples/harbor-notes/experiments/scripted.ts +6 -0
  8. package/examples/harbor-notes/package.json +6 -0
  9. package/examples/harbor-notes/quickstudy.identity.json +1 -0
  10. package/examples/harbor-notes/runtime.ts +48 -0
  11. package/examples/harbor-notes/semantic-example.ts +21 -0
  12. package/images/agent-runtime/Dockerfile +58 -0
  13. package/images/egress-proxy/Dockerfile +28 -0
  14. package/images/mcp-proxy/Dockerfile +30 -0
  15. package/package.json +53 -0
  16. package/src/adapters/claude.ts +107 -0
  17. package/src/adapters/codex.ts +107 -0
  18. package/src/adapters/echo.ts +57 -0
  19. package/src/adapters/parse.ts +117 -0
  20. package/src/adapters/types.ts +152 -0
  21. package/src/build-info.generated.ts +12 -0
  22. package/src/cli.ts +787 -0
  23. package/src/completeness.ts +104 -0
  24. package/src/diagnose/excerpt.ts +106 -0
  25. package/src/diagnose/prompt.ts +175 -0
  26. package/src/diagnose/render.ts +55 -0
  27. package/src/diagnose/run.ts +290 -0
  28. package/src/diagnose/select.ts +110 -0
  29. package/src/diagnose/types.ts +88 -0
  30. package/src/evals/discovery.ts +173 -0
  31. package/src/evals/prompt.ts +190 -0
  32. package/src/evals/result.ts +10 -0
  33. package/src/evals/types.ts +115 -0
  34. package/src/execution-policy.ts +71 -0
  35. package/src/experiments/discovery.ts +76 -0
  36. package/src/experiments/groups.ts +119 -0
  37. package/src/experiments/types.ts +116 -0
  38. package/src/export-types.ts +127 -0
  39. package/src/export.ts +381 -0
  40. package/src/hash.ts +74 -0
  41. package/src/identity-diff.ts +30 -0
  42. package/src/ids.ts +30 -0
  43. package/src/index.ts +58 -0
  44. package/src/isolation/docker.ts +639 -0
  45. package/src/isolation/image-contexts.generated.ts +927 -0
  46. package/src/isolation/images.ts +138 -0
  47. package/src/isolation/mcp-proxy/server.ts +260 -0
  48. package/src/isolation/mcp.ts +144 -0
  49. package/src/isolation/proxy/allowlist.ts +148 -0
  50. package/src/isolation/proxy/server.ts +382 -0
  51. package/src/llm.ts +132 -0
  52. package/src/manifest.ts +228 -0
  53. package/src/model-identity.ts +12 -0
  54. package/src/plan.ts +55 -0
  55. package/src/probe.ts +426 -0
  56. package/src/report/pass-at-k.ts +76 -0
  57. package/src/report/report.ts +731 -0
  58. package/src/runner/context.ts +96 -0
  59. package/src/runner/deadline.ts +37 -0
  60. package/src/runner/execute.ts +992 -0
  61. package/src/runner/run-lock.ts +32 -0
  62. package/src/runner/scheduler.ts +62 -0
  63. package/src/runner/score-worker.ts +107 -0
  64. package/src/runner/scorer-worker.ts +61 -0
  65. package/src/runtime/types.ts +89 -0
  66. package/src/secrets.ts +151 -0
  67. package/src/semantic.ts +185 -0
  68. package/src/serve.ts +52 -0
  69. package/src/source-identity.ts +76 -0
  70. package/src/store/artifacts.ts +146 -0
  71. package/src/store/db.ts +318 -0
  72. package/src/store/schema.ts +39 -0
  73. package/src/surface-usage.ts +297 -0
  74. package/src/ui-bundle.generated.ts +12 -0
  75. package/ui/dist/index.html +32 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The v3 experiment vocabulary. An experiment is the full recipe for HOW an
3
+ * attempt runs — agent, runtime, treatment — authored as a TypeScript module
4
+ * whose default export is `defineExperiment({...})`. The returned object is
5
+ * deep-frozen: experiment config is identity (it is hashed into the run
6
+ * manifest), so nothing may mutate it after definition.
7
+ */
8
+
9
+ import type { SurfaceUsageConfig } from "../surface-usage.ts";
10
+ import type { RuntimeCapabilities } from "../runtime/types.ts";
11
+
12
+ /** Which agent executes the attempt, plus the identity fields recorded in the manifest. */
13
+ export interface AgentSpec {
14
+ /** Adapter name, e.g. "echo" (the built-in no-op pipeline smoke agent). */
15
+ adapter: string;
16
+ provider?: string;
17
+ model?: string;
18
+ reasoning?: string;
19
+ cliVersion?: string;
20
+ }
21
+
22
+ /**
23
+ * Where — and with what treatment — the attempt executes. `host` runs the
24
+ * adapter in a host scratch directory (no provisioning); `container` runs
25
+ * inside `image` (or the image `containerImage(metadata)` resolves per eval)
26
+ * with the standard per-attempt container lifecycle.
27
+ *
28
+ * The optional capability methods (runtime/types.ts) own everything the
29
+ * environment offers the agent and the scorer: provisioning, egress hosts,
30
+ * MCP servers, web policy, PATH treatment, scorer capabilities. Capability
31
+ * FUNCTIONS never contribute to identity — the manifest hashes only the
32
+ * plain-data fields, so identity-bearing runtime state belongs in `config`.
33
+ */
34
+ export interface Runtime extends RuntimeCapabilities {
35
+ kind: "host" | "container";
36
+ /** Shared observation targets, independent of which surfaces this arm offers. */
37
+ observationTargets?: SurfaceUsageConfig;
38
+ /** Shared scarce resource pool; scheduler waits instead of starting doomed jobs. */
39
+ capacity?: { key: string; limit: number };
40
+ /** Explicit environment fields the declared treatment may vary in a comparison.
41
+ * Images and lifecycle budgets can never be exempted. All group members must
42
+ * declare the same fields. Source/config identity still changes per attempt.
43
+ */
44
+ treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp">;
45
+ /** Container image tag (container runtimes; alternative to `containerImage`). */
46
+ image?: string;
47
+ /** Scripted command run in /workspace (container runtimes, this phase). */
48
+ command?: string[];
49
+ /** Free-form runtime configuration; hashed into the experiment's identity. */
50
+ config?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface Experiment {
54
+ id: string;
55
+ agent: AgentSpec;
56
+ runtime: Runtime;
57
+ /**
58
+ * Experiments sharing a `comparisonGroup` are compared as treatments of
59
+ * one configuration; a group whose members disagree on anything beyond
60
+ * the declared treatment has automatic comparison withheld (never blocked).
61
+ */
62
+ comparisonGroup?: string;
63
+ /**
64
+ * What this experiment varies. An opaque string, compared only within a
65
+ * comparison group — never an enum in harness code. By convention the
66
+ * treatment's own runtime configuration lives at `runtime.config[treatment]`,
67
+ * which is what the group comparison excludes.
68
+ */
69
+ treatment?: string;
70
+ }
71
+
72
+ /** An experiment plus the module path that default-exported it. */
73
+ export interface LoadedExperiment {
74
+ experiment: Experiment;
75
+ /** Absolute path of the defining module (hashed into the manifest). */
76
+ path: string;
77
+ }
78
+
79
+ function deepFreeze<T>(value: T): T {
80
+ if (value !== null && typeof value === "object") {
81
+ for (const item of Object.values(value)) deepFreeze(item);
82
+ Object.freeze(value);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ /**
88
+ * Validate and freeze an experiment definition. The identity-frozen object is
89
+ * what discovery hands to the planner and the manifest hasher.
90
+ */
91
+ export function defineExperiment(experiment: Experiment): Experiment {
92
+ if (typeof experiment.id !== "string" || experiment.id.trim() === "") {
93
+ throw new Error("defineExperiment: id must be a non-empty string");
94
+ }
95
+ if (typeof experiment.agent?.adapter !== "string" || experiment.agent.adapter.trim() === "") {
96
+ throw new Error(`defineExperiment: experiment "${experiment.id}" needs agent.adapter`);
97
+ }
98
+ const kind = experiment.runtime?.kind;
99
+ if (kind !== "host" && kind !== "container") {
100
+ throw new Error(`defineExperiment: experiment "${experiment.id}" runtime.kind must be "host" or "container"`);
101
+ }
102
+ if (
103
+ kind === "container" &&
104
+ (typeof experiment.runtime.image !== "string" || experiment.runtime.image === "") &&
105
+ typeof experiment.runtime.containerImage !== "function"
106
+ ) {
107
+ throw new Error(
108
+ `defineExperiment: experiment "${experiment.id}" container runtime needs an image ` +
109
+ `(or a containerImage(metadata) resolver)`,
110
+ );
111
+ }
112
+ if (experiment.comparisonGroup !== undefined && experiment.comparisonGroup.trim() === "") {
113
+ throw new Error(`defineExperiment: experiment "${experiment.id}" comparisonGroup must not be empty`);
114
+ }
115
+ return deepFreeze(experiment);
116
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The data-directory contract: the stable interface between the harness and
3
+ * the static UI. `src/export.ts` writes it; `ui/src/data.ts` reads it. This
4
+ * module is PURE (types + constants only, no bun/node imports) so the browser
5
+ * bundle can import it directly — shared types make schema drift a compile
6
+ * error rather than a blank page.
7
+ *
8
+ * Layout, relative to the exported site root:
9
+ *
10
+ * index.html UI bundle (window.__QS_DATA__ inlined)
11
+ * assets/… UI bundle
12
+ * data/meta.json DataMeta — schemaVersion + run index
13
+ * data/runs/<run>/run.json DataRun — run summary + attempt rows
14
+ * data/runs/<run>/report.json ReportJson, embedded VERBATIM
15
+ * data/runs/<run>/diagnosis.json DiagnosisJson, embedded VERBATIM — only
16
+ * when the artifact exists (never rebuilt)
17
+ * data/runs/<run>/attempts/<id>.json standalone DataAttempt
18
+ * data/runs/<run>/attempts/<id>.transcript.txt lazy transcript asset
19
+ * data/runs/<run>/attempts/<id>.diff.patch lazy diff asset
20
+ *
21
+ * Workspace trees are deliberately NOT exported (size); the diff and
22
+ * transcript are the reviewable artifacts.
23
+ */
24
+
25
+ // Type-only imports — erased at compile time, so this module stays pure for
26
+ // the browser bundle (report.ts itself imports node:fs and must never load).
27
+ import type { DiagnosisJson } from "./diagnose/types.ts";
28
+ import type { CheckResult } from "./evals/types.ts";
29
+ import type { ComparisonGroup } from "./experiments/groups.ts";
30
+ import type { RunManifest } from "./manifest.ts";
31
+ import type { ReportJson } from "./report/report.ts";
32
+ import type { SurfaceUsage } from "./surface-usage.ts";
33
+
34
+ /**
35
+ * Bumped on any breaking change to this file's shapes. The UI refuses data
36
+ * from a different version with an explicit banner, never a blank page.
37
+ */
38
+ export const DATA_SCHEMA_VERSION = 3;
39
+
40
+ /** One run in the `meta.json` index (and the header of its `run.json`). */
41
+ export interface DataRunSummary {
42
+ incomplete?: boolean;
43
+ id: string;
44
+ /** ISO timestamps on the wire (the store keeps epoch milliseconds). */
45
+ started_at: string;
46
+ /** NULL = the run never finished; the UI banners incompleteness. */
47
+ finished_at: string | null;
48
+ trials: number;
49
+ eval_ids: string[];
50
+ experiment_ids: string[];
51
+ manifest_hash: string;
52
+ attempts: number;
53
+ }
54
+
55
+ /** `data/meta.json` — the index the UI boots from. */
56
+ export interface DataMeta {
57
+ /** Immutable per-run snapshot paths, relative to data/. Absent in historical exports. */
58
+ run_paths?: Record<string, string>;
59
+ schemaVersion: number;
60
+ generated_at: string;
61
+ runs: DataRunSummary[];
62
+ }
63
+
64
+ /** One attempt row as exported (artifact paths are data-dir-relative). */
65
+ export interface DataAttempt {
66
+ judgments?: import("./evals/types.ts").JudgeRecord[];
67
+ id: string;
68
+ run_id: string;
69
+ eval_id: string;
70
+ experiment_id: string;
71
+ suite: string;
72
+ trial_index: number;
73
+ status: "completed" | "incomplete" | "error";
74
+ /** The scorer's verdict; null unless the attempt completed. */
75
+ passed: boolean | null;
76
+ /** Named check results; null unless the attempt completed. */
77
+ checks: CheckResult[] | null;
78
+ /** Experiment metadata joined at export time (manifest + run config). */
79
+ treatment: string | null;
80
+ comparison_group: string | null;
81
+ /** null = telemetry unavailable for this attempt (never a zero-filled object). */
82
+ surface_usage: SurfaceUsage | null;
83
+ tokens_in: number | null;
84
+ tokens_out: number | null;
85
+ cost_usd: number | null;
86
+ /** Agent steps (adapter-counted); null = no parseable stream. */
87
+ turns: number | null;
88
+ started_at: string | null;
89
+ finished_at: string | null;
90
+ /** Present when status = "error" (and for recorded teardown notes). */
91
+ error: string | null;
92
+ /** Relative to the run's data dir (e.g. `attempts/<id>.transcript.txt`); null = artifact missing. */
93
+ transcript: string | null;
94
+ diff: string | null;
95
+ }
96
+
97
+ /** `data/runs/<run>/run.json`. */
98
+ export interface DataRun {
99
+ schemaVersion: number;
100
+ run: DataRunSummary;
101
+ manifest: RunManifest;
102
+ /** The comparison groups recorded at run time (withhold-and-diff state included). */
103
+ groups: ComparisonGroup[];
104
+ attempts: DataAttempt[];
105
+ excluded_attempts?: DataAttempt[];
106
+ publication?: import("./completeness.ts").PublicationCoverage;
107
+ }
108
+
109
+ /**
110
+ * The index inlined into the exported `index.html` as `window.__QS_DATA__`.
111
+ * Browsers restrict `fetch` from `file://` pages, so everything the views
112
+ * need up-front rides inside the document; only bulky text assets
113
+ * (transcripts, diffs) stay lazy and degrade gracefully offline.
114
+ */
115
+ export interface InlineData {
116
+ meta: DataMeta;
117
+ /** runId -> run.json payload. */
118
+ runs: Record<string, DataRun>;
119
+ /** runId -> report.json payload (verbatim ReportJson shape). */
120
+ reports: Record<string, ReportJson>;
121
+ /**
122
+ * runId -> diagnosis.json payload (verbatim DiagnosisJson shape). Present
123
+ * only when at least one exported run has a stored diagnosis — absence is
124
+ * a legitimate state (the UI renders how to produce one), never an error.
125
+ */
126
+ diagnoses?: Record<string, DiagnosisJson>;
127
+ }
package/src/export.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * `quickstudy export`: materialize the data directory (see export-types.ts)
3
+ * from SQLite + the artifacts dir, and copy the prebuilt UI bundle next to
4
+ * it. One renderer, two delivery modes — `quickstudy ui` runs this exact
5
+ * export into a temp dir and serves it, so if it works from file:// it works
6
+ * served.
7
+ *
8
+ * Key decisions (from the spec):
9
+ * - Transcripts/diffs are sliced into per-attempt text assets, lazy-loaded
10
+ * by the viewer — never one giant JSON.
11
+ * - Historical reports are preserved verbatim. Modern reports are reused
12
+ * only when their source fingerprint matches the current attempt records.
13
+ * - diagnosis.json rides along VERBATIM only when the artifact exists on
14
+ * disk. Unlike report.json it is NEVER rebuilt: diagnosis is key-gated
15
+ * and token-spending, and export must not spend tokens or require a key.
16
+ * - Workspace trees are NOT exported (size); diff + transcript are the
17
+ * reviewable artifacts.
18
+ * - Offline mode embeds the meta/run/report index for file:// use.
19
+ * Hosted mode embeds only meta and fetches immutable run snapshots.
20
+ * Transcripts/diffs remain lazy assets in both modes.
21
+ */
22
+
23
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import type { DiagnosisJson } from "./diagnose/types.ts";
26
+ import {
27
+ DATA_SCHEMA_VERSION,
28
+ type DataAttempt,
29
+ type DataMeta,
30
+ type DataRun,
31
+ type DataRunSummary,
32
+ type InlineData,
33
+ } from "./export-types.ts";
34
+ import { trialCoverage } from "./completeness.ts";
35
+ import { hashString } from "./hash.ts";
36
+ import { buildReport, renderReportText, type ReportJson } from "./report/report.ts";
37
+ import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "./store/db.ts";
38
+
39
+ /** Loud, CLI-friendly failures (unknown run, missing bundle, ...). */
40
+ export class ExportError extends Error {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "ExportError";
44
+ }
45
+ }
46
+
47
+ export interface ExportOptions {
48
+ /** Offline embeds JSON; hosted fetches immutable per-run snapshots lazily. */
49
+ mode?: "offline" | "hosted";
50
+ /** Explicit publication gate; omits incompletely sampled pairs with reasons. */
51
+ expectedTrials?: number;
52
+ store: ResultsStore;
53
+ /** Artifacts root (the harness `--results` dir). */
54
+ resultsDir: string;
55
+ /** Runs to export. May be empty — the UI renders its empty state. */
56
+ runIds: string[];
57
+ /** Site output directory (created if needed). */
58
+ outDir: string;
59
+ /**
60
+ * The prebuilt UI bundle (ui/dist). Omit to export the data directory
61
+ * only — used by dev-data generation and shape tests.
62
+ */
63
+ bundleDir?: string;
64
+ /** Overrides `generated_at` stamps (meta.json and export-time-built reports) — fixture seeding only. */
65
+ generatedAt?: string;
66
+ /** Warning sink for non-fatal notes (missing artifacts, rebuilt reports). */
67
+ log?: (message: string) => void;
68
+ }
69
+
70
+ export interface ExportSummary {
71
+ outDir: string;
72
+ dataDir: string;
73
+ runs: number;
74
+ attempts: number;
75
+ /** Attempt artifacts referenced in SQLite but unreadable on disk. */
76
+ missingAssets: number;
77
+ }
78
+
79
+ function iso(epochMs: number | null): string | null {
80
+ return epochMs === null ? null : new Date(epochMs).toISOString();
81
+ }
82
+
83
+ function mustGetRun(store: ResultsStore, runId: string): RunRecord {
84
+ const run = store.getRun(runId);
85
+ if (run) return run;
86
+ const known = store
87
+ .listRuns()
88
+ .slice(0, 5)
89
+ .map((r) => ` ${r.id} (started ${new Date(r.startedAt).toISOString()})`);
90
+ throw new ExportError(
91
+ `no run "${runId}" in this results database${known.length > 0 ? `; most recent runs:\n${known.join("\n")}` : ""}`,
92
+ );
93
+ }
94
+
95
+ function toRunSummary(run: RunRecord, attemptCount: number): DataRunSummary {
96
+ return {
97
+ id: run.id,
98
+ started_at: new Date(run.startedAt).toISOString(),
99
+ finished_at: iso(run.finishedAt),
100
+ trials: run.config.trials,
101
+ eval_ids: [...run.config.evalIds],
102
+ experiment_ids: [...run.config.experimentIds],
103
+ manifest_hash: run.manifest.hash,
104
+ attempts: attemptCount,
105
+ };
106
+ }
107
+
108
+ interface AssetCopyResult {
109
+ relativePath: string | null;
110
+ missing: boolean;
111
+ }
112
+
113
+ /** Copy one artifact into the run's attempts/ dir; missing files degrade to null. */
114
+ function copyAsset(sourcePath: string | null, targetDir: string, fileName: string): AssetCopyResult {
115
+ if (sourcePath === null) return { relativePath: null, missing: false };
116
+ if (!existsSync(sourcePath)) return { relativePath: null, missing: true };
117
+ const target = join(targetDir, fileName);
118
+ const sourceStat = statSync(sourcePath);
119
+ if (!existsSync(target) || statSync(target).size !== sourceStat.size || statSync(target).mtimeMs < sourceStat.mtimeMs) {
120
+ const temporary = `${target}.tmp`; copyFileSync(sourcePath, temporary); renameSync(temporary, target);
121
+ }
122
+ return { relativePath: `attempts/${fileName}`, missing: false };
123
+ }
124
+
125
+ /** Experiment id -> declared comparison group, from the run's recorded groups. */
126
+ function groupsByExperiment(run: RunRecord): Map<string, string> {
127
+ const byExperiment = new Map<string, string>();
128
+ for (const group of run.config.groups ?? []) {
129
+ for (const experimentId of group.experimentIds) byExperiment.set(experimentId, group.group);
130
+ }
131
+ return byExperiment;
132
+ }
133
+
134
+ function toDataAttempt(
135
+ run: RunRecord,
136
+ groupOf: Map<string, string>,
137
+ attempt: AttemptRecord,
138
+ transcript: string | null,
139
+ diff: string | null,
140
+ ): DataAttempt {
141
+ return {
142
+ ...((attempt.result?.judgments ?? attempt.judgeRecords) ? { judgments: attempt.result?.judgments ?? attempt.judgeRecords } : {}),
143
+ id: attempt.id,
144
+ run_id: attempt.runId,
145
+ eval_id: attempt.evalId,
146
+ experiment_id: attempt.experimentId,
147
+ suite: attempt.suite,
148
+ trial_index: attempt.trialIndex,
149
+ status: attempt.status,
150
+ passed: attempt.status === "completed" ? (attempt.result?.passed ?? false) : null,
151
+ checks: attempt.result?.checks ?? null,
152
+ treatment: run.manifest.experiments[attempt.experimentId]?.components.treatment ?? null,
153
+ comparison_group: groupOf.get(attempt.experimentId) ?? null,
154
+ surface_usage: attempt.surfaceUsage,
155
+ tokens_in: attempt.tokensIn,
156
+ tokens_out: attempt.tokensOut,
157
+ cost_usd: attempt.costUsd,
158
+ turns: attempt.turns,
159
+ started_at: iso(attempt.startedAt),
160
+ finished_at: iso(attempt.finishedAt),
161
+ error: attempt.error,
162
+ transcript,
163
+ diff,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * The stored report.json is the artifact consumers already trust — embed it
169
+ * verbatim. When absent, build it fresh (deterministic and offline; export
170
+ * must not spend tokens or require a key).
171
+ */
172
+ function resolveReport(
173
+ store: ResultsStore,
174
+ resultsDir: string,
175
+ runId: string,
176
+ log: (message: string) => void,
177
+ generatedAt?: string,
178
+ ): ReportJson {
179
+ const path = join(resultsDir, runId, "report.json");
180
+ if (existsSync(path)) {
181
+ try {
182
+ // Export is an artifact copier, not report validation. Preserve the
183
+ // stored JSON byte-for-byte in meaning, including forward-compatible
184
+ // fields a newer report producer may add.
185
+ const stored = JSON.parse(readFileSync(path, "utf8")) as ReportJson;
186
+ const run = store.getRun(runId)!;
187
+ // Historical reports retain their methodology. New reports are reused
188
+ // only when the entire source record still matches (including retries).
189
+ if (run.manifest.manifestVersion === "v3") return stored;
190
+ const current = buildReport({ store, runId, resultsDir, generatedAt: generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)! });
191
+ return stored.source_fingerprint === current.source_fingerprint ? stored : current;
192
+ } catch (err) {
193
+ log(`stored report.json for ${runId} is unreadable (${err instanceof Error ? err.message : String(err)}) — rebuilding`);
194
+ }
195
+ } else {
196
+ log(`no stored report.json for ${runId} — building it at export time (identical to \`quickstudy report ${runId}\`)`);
197
+ }
198
+ return buildReport({
199
+ store,
200
+ runId,
201
+ resultsDir,
202
+ generatedAt: generatedAt ?? iso(store.getRun(runId)!.finishedAt) ?? iso(store.getRun(runId)!.startedAt)!,
203
+ });
204
+ }
205
+
206
+ /**
207
+ * diagnosis.json rides along ONLY when the artifact already exists on disk.
208
+ * Unlike {@link resolveReport} there is NO rebuild fallback: diagnosis is
209
+ * key-gated and token-spending, and export must not spend tokens or require
210
+ * a key. Absent is a legitimate state (the UI renders how to produce one),
211
+ * so it skips silently; only an unreadable artifact earns a warning.
212
+ */
213
+ function resolveDiagnosis(
214
+ resultsDir: string,
215
+ runId: string,
216
+ runDir: string,
217
+ log: (message: string) => void,
218
+ ): DiagnosisJson | null {
219
+ const path = join(resultsDir, runId, "diagnosis.json");
220
+ if (!existsSync(path)) return null;
221
+ let diagnosis: DiagnosisJson;
222
+ try {
223
+ // Export is an artifact copier, not diagnosis validation. Preserve the
224
+ // stored JSON byte-for-byte in meaning, including forward-compatible
225
+ // fields a newer diagnosis producer may add.
226
+ diagnosis = JSON.parse(readFileSync(path, "utf8")) as DiagnosisJson;
227
+ } catch (err) {
228
+ log(
229
+ `stored diagnosis.json for ${runId} is unreadable (${err instanceof Error ? err.message : String(err)}) — exported without it (export never rebuilds a diagnosis)`,
230
+ );
231
+ return null;
232
+ }
233
+ copyFileSync(path, join(runDir, "diagnosis.json"));
234
+ return diagnosis;
235
+ }
236
+
237
+ const QS_DATA_SLOT = /<script id="qs-data">[\s\S]*?<\/script>/;
238
+
239
+ /**
240
+ * Inline the data index into the bundle's index.html. `<` is escaped so a
241
+ * `</script>` (or `<!--`) inside transcript-derived JSON can never truncate
242
+ * the document; U+2028/9 are escaped for pre-ES2019 parsers.
243
+ */
244
+ export function inlineDataIntoHtml(html: string, data: InlineData): string {
245
+ if (!QS_DATA_SLOT.test(html)) {
246
+ throw new ExportError(
247
+ 'the UI bundle\'s index.html has no `<script id="qs-data">` slot — rebuild it with `bun run ui:build`',
248
+ );
249
+ }
250
+ const json = JSON.stringify(data)
251
+ .replace(/</g, "\\u003c")
252
+ .replace(/\u2028/g, "\\u2028")
253
+ .replace(/\u2029/g, "\\u2029");
254
+ // Function replacement: a string replacement would interpret `$&`/`$'`
255
+ // sequences inside transcript-derived JSON as replacement patterns.
256
+ return html.replace(QS_DATA_SLOT, () => `<script id="qs-data">window.__QS_DATA__ = ${json};</script>`);
257
+ }
258
+
259
+ /** Export one or more runs into a self-contained static site directory. */
260
+ export async function exportSite(options: ExportOptions): Promise<ExportSummary> {
261
+ const log = options.log ?? ((message: string) => console.warn(message));
262
+ const { store, resultsDir, outDir } = options;
263
+
264
+ // Validate everything up-front: nothing is written until all runs resolve.
265
+ const runs = options.runIds.map((runId) => mustGetRun(store, runId));
266
+ if (options.bundleDir !== undefined && !existsSync(join(options.bundleDir, "index.html"))) {
267
+ throw new ExportError(
268
+ `no UI bundle at ${options.bundleDir} — build it first with \`bun run ui:build\``,
269
+ );
270
+ }
271
+
272
+ const publications = new Map(runs.map((run) => [run.id, options.expectedTrials === undefined ? undefined : buildReport({ store, runId: run.id, resultsDir, expectedTrials: options.expectedTrials, generatedAt: options.generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)! })]));
273
+ const dataDir = join(outDir, "data");
274
+ mkdirSync(dataDir, { recursive: true });
275
+
276
+ const inlineRuns: Record<string, DataRun> = {};
277
+ const inlineReports: Record<string, ReportJson> = {};
278
+ const inlineDiagnoses: Record<string, DiagnosisJson> = {};
279
+ const summaries: DataRunSummary[] = [];
280
+ const runPaths: Record<string, string> = {};
281
+ let attemptTotal = 0;
282
+ let missingAssets = 0;
283
+
284
+ for (const run of runs) {
285
+ const history = store.listAttempts(run.id);
286
+ const current = activeAttempts(history);
287
+ const publication = publications.get(run.id)?.publication;
288
+ const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
289
+ const groupOf = groupsByExperiment(run);
290
+ const runDir = join(dataDir, "runs", run.id);
291
+ const attemptsDir = join(runDir, "attempts");
292
+ mkdirSync(attemptsDir, { recursive: true });
293
+
294
+ const dataAttempts: DataAttempt[] = [];
295
+ const historyAttempts: DataAttempt[] = [];
296
+ for (const attempt of history) {
297
+ const transcript = copyAsset(attempt.transcriptRef, attemptsDir, `${attempt.id}.transcript.txt`);
298
+ const diff = copyAsset(attempt.diffRef, attemptsDir, `${attempt.id}.diff.patch`);
299
+ for (const asset of [transcript, diff]) {
300
+ if (asset.missing) {
301
+ missingAssets += 1;
302
+ log(`attempt ${attempt.id}: artifact referenced in SQLite is missing on disk — exported without it`);
303
+ }
304
+ }
305
+ const dataAttempt = toDataAttempt(run, groupOf, attempt, transcript.relativePath, diff.relativePath);
306
+ if (attempts.includes(attempt)) dataAttempts.push(dataAttempt);
307
+ else historyAttempts.push(dataAttempt);
308
+ atomicWrite(join(attemptsDir, `${attempt.id}.json`), `${JSON.stringify(dataAttempt, null, 2)}\n`, "utf8");
309
+ }
310
+
311
+ const summary = toRunSummary(run, attempts.length);
312
+ summary.incomplete = publication ? false : run.finishedAt === null || trialCoverage(run, current).some((p) => !p.complete);
313
+ const dataRun: DataRun = {
314
+ schemaVersion: DATA_SCHEMA_VERSION,
315
+ run: summary,
316
+ manifest: run.manifest,
317
+ groups: run.config.groups ?? [],
318
+ attempts: dataAttempts,
319
+ ...(historyAttempts.length ? { excluded_attempts: historyAttempts } : {}),
320
+ ...(publication ? { publication } : {}),
321
+ };
322
+ atomicWrite(join(runDir, "run.json"), `${JSON.stringify(dataRun, null, 2)}\n`, "utf8");
323
+
324
+ const report = publications.get(run.id) ?? resolveReport(store, resultsDir, run.id, log, options.generatedAt);
325
+ if (publication) {
326
+ atomicWrite(join(runDir, "omissions.json"), JSON.stringify(publication, null, 2));
327
+ atomicWrite(join(runDir, "omissions.txt"), renderReportText(report));
328
+ atomicWrite(join(runDir, "source-report.json"), JSON.stringify(buildReport({ store, runId: run.id, resultsDir }), null, 2));
329
+ log(`${run.id}: complete-sample publication includes ${publication.eligible.length} pair(s), omits ${publication.omitted.length}; see data/runs/${run.id}/omissions.json and omissions.txt`);
330
+ }
331
+ atomicWrite(join(runDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
332
+
333
+ const diagnosis = resolveDiagnosis(resultsDir, run.id, runDir, log);
334
+ if (diagnosis !== null) inlineDiagnoses[run.id] = diagnosis;
335
+ const snapshot = hashString(JSON.stringify({ dataRun, report, diagnosis })).slice(0, 24);
336
+ const snapshotPath = `runs/${run.id}/snapshots/${snapshot}`;
337
+ const snapshotDir = join(dataDir, snapshotPath); mkdirSync(snapshotDir, { recursive: true });
338
+ atomicWrite(join(snapshotDir, "run.json"), JSON.stringify(dataRun));
339
+ atomicWrite(join(snapshotDir, "report.json"), JSON.stringify(report));
340
+ atomicWrite(join(snapshotDir, "diagnosis.json"), JSON.stringify(diagnosis));
341
+ runPaths[run.id] = snapshotPath;
342
+
343
+ summaries.push(summary);
344
+ inlineRuns[run.id] = dataRun;
345
+ inlineReports[run.id] = report;
346
+ attemptTotal += attempts.length;
347
+ }
348
+
349
+ // Newest first, matching ResultsStore.listRuns ordering (ISO strings sort).
350
+ summaries.sort((a, b) => b.started_at.localeCompare(a.started_at) || b.id.localeCompare(a.id));
351
+ const meta: DataMeta = {
352
+ schemaVersion: DATA_SCHEMA_VERSION,
353
+ generated_at: options.generatedAt ?? new Date().toISOString(),
354
+ runs: summaries,
355
+ run_paths: runPaths,
356
+ };
357
+ atomicWrite(join(dataDir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
358
+
359
+ if (options.bundleDir !== undefined) {
360
+ cpSync(options.bundleDir, outDir, { recursive: true, filter: (path) => path === options.bundleDir || !path.endsWith("/index.html") });
361
+ const indexPath = join(outDir, "index.html");
362
+ const html = readFileSync(join(options.bundleDir, "index.html"), "utf8");
363
+ // The `diagnoses` key is omitted entirely when no exported run has one.
364
+ const inline: InlineData = {
365
+ meta,
366
+ runs: options.mode === "hosted" ? {} : inlineRuns,
367
+ reports: options.mode === "hosted" ? {} : inlineReports,
368
+ ...(options.mode !== "hosted" && Object.keys(inlineDiagnoses).length > 0 ? { diagnoses: inlineDiagnoses } : {}),
369
+ };
370
+ atomicWrite(indexPath, inlineDataIntoHtml(html, inline), "utf8");
371
+ }
372
+
373
+ return { outDir, dataDir, runs: runs.length, attempts: attemptTotal, missingAssets };
374
+ }
375
+
376
+ /** Write complete bytes before exposing a new snapshot/pointer; reuse identical files. */
377
+ function atomicWrite(path: string, value: string, _encoding?: string): void {
378
+ if (existsSync(path) && readFileSync(path, "utf8") === value) return;
379
+ const temporary = `${path}.${process.pid}.tmp`;
380
+ writeFileSync(temporary, value, "utf8"); renameSync(temporary, path);
381
+ }