@elench/testkit 0.1.54 → 0.1.55

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.
@@ -6,8 +6,9 @@ import {
6
6
  collectConfiguredInputs,
7
7
  runConfiguredSteps,
8
8
  } from "../runner/template-steps.mjs";
9
+ import { captureOutput } from "../runner/processes.mjs";
9
10
 
10
- export async function runTemplateStage(config, stageName, databaseUrl) {
11
+ export async function runTemplateStage(config, stageName, databaseUrl, options = {}) {
11
12
  const steps = config.testkit.database?.template?.[stageName] || [];
12
13
  if (steps.length === 0) return;
13
14
 
@@ -21,6 +22,9 @@ export async function runTemplateStage(config, stageName, databaseUrl) {
21
22
  steps,
22
23
  env,
23
24
  labelPrefix: `template:${stageName}`,
25
+ reporter: options.reporter || null,
26
+ setupRegistry: options.setupRegistry || null,
27
+ parentOperation: options.parentOperation || null,
24
28
  });
25
29
  }
26
30
 
@@ -32,12 +36,12 @@ export function collectTemplateInputs(productDir, template = {}) {
32
36
  });
33
37
  }
34
38
 
35
- export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
39
+ export async function captureTemplateSnapshot(config, outputPath, databaseUrl, options = {}) {
36
40
  const templateDbUrl = databaseUrl;
37
41
  const absoluteOutputPath = path.resolve(config.productDir, outputPath);
38
42
  fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
39
43
 
40
- await execa(
44
+ const child = execa(
41
45
  "pg_dump",
42
46
  [
43
47
  "--schema-only",
@@ -53,19 +57,54 @@ export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
53
57
  ...buildExecutionEnv(config, {}, process.env),
54
58
  DATABASE_URL: templateDbUrl,
55
59
  },
56
- stdio: "inherit",
60
+ stdout: "pipe",
61
+ stderr: "pipe",
62
+ reject: false,
57
63
  }
58
64
  );
65
+ const liveWriter =
66
+ options.reporter?.outputMode === "debug"
67
+ ? (line) => options.reporter.writeDebugLine?.(line)
68
+ : null;
69
+ const logRecord = options.logRecord || null;
70
+ const drains = [
71
+ captureOutput(child.stdout, {
72
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
73
+ liveWriter,
74
+ onLine(line) {
75
+ if (logRecord) logRecord.stream.write(`${new Date().toISOString()} [stdout] ${line}\n`);
76
+ },
77
+ }),
78
+ captureOutput(child.stderr, {
79
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
80
+ liveWriter,
81
+ onLine(line) {
82
+ if (logRecord) logRecord.stream.write(`${new Date().toISOString()} [stderr] ${line}\n`);
83
+ },
84
+ }),
85
+ ];
86
+ const result = await child;
87
+ await Promise.all(drains);
88
+ if (result.exitCode !== 0) {
89
+ throw new Error(result.shortMessage || result.stderr || result.stdout || "pg_dump failed");
90
+ }
59
91
 
60
92
  sanitizeSnapshotFile(absoluteOutputPath);
61
93
  return absoluteOutputPath;
62
94
  }
63
95
 
64
- function sanitizeSnapshotFile(filePath) {
96
+ export function sanitizeSnapshotFile(filePath) {
65
97
  const dump = fs.readFileSync(filePath, "utf8");
66
98
  const sanitized = dump
67
99
  .split("\n")
68
- .filter((line) => line.trim() !== "SET transaction_timeout = 0;")
100
+ .filter((line) => {
101
+ const trimmed = line.trim();
102
+ return (
103
+ trimmed !== "SET transaction_timeout = 0;" &&
104
+ !trimmed.startsWith("\\restrict ") &&
105
+ !trimmed.startsWith("\\unrestrict ")
106
+ );
107
+ })
69
108
  .join("\n");
70
109
 
71
110
  if (sanitized !== dump) {
@@ -0,0 +1,43 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { sanitizeSnapshotFile } from "./template-steps.mjs";
6
+
7
+ const tempDirs = [];
8
+
9
+ afterEach(() => {
10
+ while (tempDirs.length > 0) {
11
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ function makeTempDir(prefix) {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
17
+ tempDirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ describe("template snapshot sanitization", () => {
22
+ it("removes volatile pg_dump control lines", () => {
23
+ const dir = makeTempDir("testkit-template-snapshot-");
24
+ const filePath = path.join(dir, "schema.sql");
25
+ fs.writeFileSync(
26
+ filePath,
27
+ [
28
+ "SET statement_timeout = 0;",
29
+ "SET transaction_timeout = 0;",
30
+ "\\restrict abc123",
31
+ "CREATE TABLE public.widgets (id integer);",
32
+ "\\unrestrict abc123",
33
+ "",
34
+ ].join("\n")
35
+ );
36
+
37
+ sanitizeSnapshotFile(filePath);
38
+
39
+ expect(fs.readFileSync(filePath, "utf8")).toBe(
40
+ ["SET statement_timeout = 0;", "CREATE TABLE public.widgets (id integer);", ""].join("\n")
41
+ );
42
+ });
43
+ });
@@ -9,11 +9,20 @@ import {
9
9
  const TIMINGS_FILENAME = "timings.json";
10
10
  const RESULT_ARTIFACTS_DIRNAME = "artifacts";
11
11
  const RESULT_LOGS_DIRNAME = "logs";
12
+ const RESULT_SETUP_DIRNAME = "setup";
13
+ const LIVE_ARTIFACT_FILENAME = "live.json";
12
14
 
13
15
  export function writeRunArtifact(productDir, artifact) {
14
16
  const resultsDir = path.join(productDir, ".testkit", "results");
15
17
  fs.mkdirSync(resultsDir, { recursive: true });
16
18
  fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
19
+ fs.rmSync(path.join(resultsDir, LIVE_ARTIFACT_FILENAME), { force: true });
20
+ }
21
+
22
+ export function writeLiveRunArtifact(productDir, artifact) {
23
+ const resultsDir = path.join(productDir, ".testkit", "results");
24
+ fs.mkdirSync(resultsDir, { recursive: true });
25
+ fs.writeFileSync(path.join(resultsDir, LIVE_ARTIFACT_FILENAME), JSON.stringify(artifact, null, 2));
17
26
  }
18
27
 
19
28
  export function writeStatusArtifact(productDir, artifact) {
@@ -32,6 +41,13 @@ export function resetResultArtifacts(productDir) {
32
41
  recursive: true,
33
42
  force: true,
34
43
  });
44
+ fs.rmSync(path.join(productDir, ".testkit", "results", RESULT_SETUP_DIRNAME), {
45
+ recursive: true,
46
+ force: true,
47
+ });
48
+ fs.rmSync(path.join(productDir, ".testkit", "results", LIVE_ARTIFACT_FILENAME), {
49
+ force: true,
50
+ });
35
51
  }
36
52
 
37
53
  export function persistTaskArtifacts(productDir, task, emittedArtifacts) {
@@ -2,21 +2,26 @@ import fs from "fs";
2
2
  import path from "path";
3
3
 
4
4
  const RESULT_LOGS_DIRNAME = "logs";
5
+ const RESULT_SETUP_DIRNAME = "setup";
5
6
 
6
7
  export function createRunLogRegistry(productDir) {
7
- const records = new Map();
8
+ const serviceRecords = new Map();
9
+ const setupRecords = new Map();
8
10
 
9
11
  return {
10
12
  ensureServiceLogRecord(config) {
11
13
  const key = `${config.runtimeLabel || config.name}:${config.name}`;
12
- const existing = records.get(key);
14
+ const existing = serviceRecords.get(key);
13
15
  if (existing) return existing;
14
16
 
15
17
  const fileName = `${sanitizePathSegment(config.runtimeLabel || config.name)}__${sanitizePathSegment(config.name)}.log`;
16
18
  const relativePath = path.join(".testkit", "results", RESULT_LOGS_DIRNAME, fileName);
17
19
  const absolutePath = path.join(productDir, relativePath);
18
20
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
19
- const stream = fs.createWriteStream(absolutePath, { flags: "a" });
21
+ const stream = fs.createWriteStream(absolutePath, {
22
+ fd: fs.openSync(absolutePath, "a"),
23
+ flags: "a",
24
+ });
20
25
  const record = {
21
26
  key,
22
27
  serviceName: config.name,
@@ -25,7 +30,32 @@ export function createRunLogRegistry(productDir) {
25
30
  absolutePath,
26
31
  stream,
27
32
  };
28
- records.set(key, record);
33
+ serviceRecords.set(key, record);
34
+ return record;
35
+ },
36
+ ensureSetupLogRecord(config, stage) {
37
+ const key = `${config.runtimeLabel || config.name}:${config.name}:${stage}`;
38
+ const existing = setupRecords.get(key);
39
+ if (existing) return existing;
40
+
41
+ const fileName = `${sanitizePathSegment(config.runtimeLabel || config.name)}__${sanitizePathSegment(config.name)}__${sanitizePathSegment(stage)}.log`;
42
+ const relativePath = path.join(".testkit", "results", RESULT_SETUP_DIRNAME, fileName);
43
+ const absolutePath = path.join(productDir, relativePath);
44
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
45
+ const stream = fs.createWriteStream(absolutePath, {
46
+ fd: fs.openSync(absolutePath, "a"),
47
+ flags: "a",
48
+ });
49
+ const record = {
50
+ key,
51
+ serviceName: config.name,
52
+ runtimeLabel: config.runtimeLabel || config.name,
53
+ stage,
54
+ path: normalizePath(relativePath),
55
+ absolutePath,
56
+ stream,
57
+ };
58
+ setupRecords.set(key, record);
29
59
  return record;
30
60
  },
31
61
  append(record, streamName, line) {
@@ -33,7 +63,7 @@ export function createRunLogRegistry(productDir) {
33
63
  record.stream.write(`${new Date().toISOString()} [${streamName}] ${line}\n`);
34
64
  },
35
65
  listServiceLogs() {
36
- return [...records.values()]
66
+ return [...serviceRecords.values()]
37
67
  .map((record) => ({
38
68
  serviceName: record.serviceName,
39
69
  runtimeLabel: record.runtimeLabel,
@@ -45,8 +75,26 @@ export function createRunLogRegistry(productDir) {
45
75
  left.runtimeLabel.localeCompare(right.runtimeLabel)
46
76
  );
47
77
  },
78
+ listSetupLogs() {
79
+ return [...setupRecords.values()]
80
+ .map((record) => ({
81
+ serviceName: record.serviceName,
82
+ runtimeLabel: record.runtimeLabel,
83
+ stage: record.stage,
84
+ path: record.path,
85
+ }))
86
+ .sort(
87
+ (left, right) =>
88
+ left.serviceName.localeCompare(right.serviceName) ||
89
+ left.runtimeLabel.localeCompare(right.runtimeLabel) ||
90
+ left.stage.localeCompare(right.stage)
91
+ );
92
+ },
48
93
  closeAll() {
49
- for (const record of records.values()) {
94
+ for (const record of serviceRecords.values()) {
95
+ record.stream.end();
96
+ }
97
+ for (const record of setupRecords.values()) {
50
98
  record.stream.end();
51
99
  }
52
100
  },
@@ -14,7 +14,7 @@ import {
14
14
  recordTaskOutcome,
15
15
  summarizeDbBackend,
16
16
  } from "./results.mjs";
17
- import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
17
+ import { buildLiveRunArtifact, buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
18
  import {
19
19
  applyKnownFailureIssueValidationToArtifacts,
20
20
  applyKnownFailuresToArtifacts,
@@ -29,10 +29,12 @@ import {
29
29
  loadTimings,
30
30
  resetResultArtifacts,
31
31
  saveTimings,
32
+ writeLiveRunArtifact,
32
33
  writeRunArtifact,
33
34
  writeStatusArtifact,
34
35
  } from "./artifacts.mjs";
35
36
  import { createRunLogRegistry } from "./logs.mjs";
37
+ import { createSetupOperationRegistry } from "./setup-operations.mjs";
36
38
  import {
37
39
  cleanupRunById,
38
40
  cleanupRuns,
@@ -72,6 +74,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
72
74
  );
73
75
  const reporter = opts.reporter || null;
74
76
  const logRegistry = createRunLogRegistry(productDir);
77
+ let workerCount = 0;
78
+ let runtimeInstanceCount = 0;
79
+ let runtimeStats = [];
75
80
  const requestedFiles = opts.fileNames || [];
76
81
  if (requestedFiles.length > 0) {
77
82
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -120,10 +125,40 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
120
125
  reporter
121
126
  );
122
127
  const trackers = buildServiceTrackers(servicePlans, startedAt);
128
+ const writeLiveSnapshot = () => {
129
+ const now = Date.now();
130
+ const partialResults = configs.map((config) =>
131
+ finalizeServiceResult(trackers.get(config.name), startedAt, now)
132
+ );
133
+ writeLiveRunArtifact(
134
+ productDir,
135
+ buildLiveRunArtifact({
136
+ productDir,
137
+ results: partialResults,
138
+ startedAt,
139
+ updatedAt: now,
140
+ execution,
141
+ workerCount,
142
+ runtimeInstanceCount,
143
+ runtimeStats,
144
+ typeValues,
145
+ suiteSelectors,
146
+ fileNames: requestedFiles,
147
+ shard: opts.shard || null,
148
+ serviceFilter: opts.serviceFilter || null,
149
+ metadata,
150
+ summarizeDbBackend,
151
+ serviceLogs: logRegistry.listServiceLogs(),
152
+ setupLogs: logRegistry.listSetupLogs(),
153
+ setupOperations: setupRegistry.listOperations(),
154
+ })
155
+ );
156
+ };
157
+ const setupRegistry = createSetupOperationRegistry({
158
+ logRegistry,
159
+ onChange: writeLiveSnapshot,
160
+ });
123
161
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
124
- let workerCount = 0;
125
- let runtimeInstanceCount = 0;
126
- let runtimeStats = [];
127
162
  let exitCode = 0;
128
163
  const lifecycle = createRunLifecycle(productDir);
129
164
  lifecycle.markRunning();
@@ -131,6 +166,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
131
166
  let results = [];
132
167
  let finishedAt = Date.now();
133
168
  let knownFailureIssueValidation = null;
169
+ writeLiveSnapshot();
134
170
 
135
171
  try {
136
172
  if (executedPlans.length > 0) {
@@ -149,6 +185,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
149
185
  runtimeOptions: {
150
186
  reporter,
151
187
  logRegistry,
188
+ setupRegistry,
152
189
  },
153
190
  });
154
191
  const timingUpdates = [];
@@ -164,8 +201,14 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
164
201
  timingUpdates,
165
202
  lifecycle,
166
203
  claimNextTask,
167
- recordTaskOutcome,
168
- recordGraphError,
204
+ (allTrackers, task, outcome, now) => {
205
+ recordTaskOutcome(allTrackers, task, outcome, now);
206
+ writeLiveSnapshot();
207
+ },
208
+ (allTrackers, graph, message, now) => {
209
+ recordGraphError(allTrackers, graph, message, now);
210
+ writeLiveSnapshot();
211
+ },
169
212
  reporter
170
213
  )
171
214
  )
@@ -177,9 +220,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
177
220
  for (const tracker of trackers.values()) {
178
221
  if (!tracker.skipped) addTrackerError(tracker, message);
179
222
  }
223
+ writeLiveSnapshot();
180
224
  }
181
225
  }
182
226
  runtimeStats = runtimeManager.getStats();
227
+ writeLiveSnapshot();
183
228
  } finally {
184
229
  await runtimeManager.cleanupAll();
185
230
  }
@@ -208,6 +253,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
208
253
  metadata,
209
254
  summarizeDbBackend,
210
255
  serviceLogs: logRegistry.listServiceLogs(),
256
+ setupLogs: logRegistry.listSetupLogs(),
257
+ setupOperations: setupRegistry.listOperations(),
211
258
  });
212
259
  const statusArtifact = opts.writeStatus
213
260
  ? buildStatusArtifact({
@@ -112,6 +112,9 @@ export function buildRunArtifact({
112
112
  metadata,
113
113
  summarizeDbBackend,
114
114
  serviceLogs = [],
115
+ setupLogs = [],
116
+ setupOperations = [],
117
+ runStatus = null,
115
118
  }) {
116
119
  const executed = results.filter((result) => !result.skipped);
117
120
  const effectivelySkippedServices = executed.filter(isEffectivelySkippedService);
@@ -128,7 +131,7 @@ export function buildRunArtifact({
128
131
  const dbBackend = summarizeDbBackend(results);
129
132
 
130
133
  return {
131
- schemaVersion: 7,
134
+ schemaVersion: 8,
132
135
  source: "testkit",
133
136
  generatedAt: new Date(finishedAt).toISOString(),
134
137
  product: {
@@ -138,7 +141,7 @@ export function buildRunArtifact({
138
141
  git: metadata.git,
139
142
  host: metadata.host,
140
143
  run: {
141
- status: failedServices.length > 0 ? "failed" : "passed",
144
+ status: runStatus || (failedServices.length > 0 ? "failed" : "passed"),
142
145
  startedAt: new Date(startedAt).toISOString(),
143
146
  finishedAt: new Date(finishedAt).toISOString(),
144
147
  durationMs: finishedAt - startedAt,
@@ -179,6 +182,10 @@ export function buildRunArtifact({
179
182
  },
180
183
  logs: {
181
184
  services: serviceLogs,
185
+ setup: setupLogs,
186
+ },
187
+ setup: {
188
+ operations: setupOperations,
182
189
  },
183
190
  services: results.map((result) => ({
184
191
  name: result.name,
@@ -203,6 +210,49 @@ export function buildRunArtifact({
203
210
  };
204
211
  }
205
212
 
213
+ export function buildLiveRunArtifact({
214
+ productDir,
215
+ results,
216
+ startedAt,
217
+ updatedAt,
218
+ execution,
219
+ workerCount,
220
+ runtimeInstanceCount,
221
+ runtimeStats,
222
+ typeValues,
223
+ suiteSelectors,
224
+ fileNames,
225
+ shard,
226
+ serviceFilter,
227
+ metadata,
228
+ summarizeDbBackend,
229
+ serviceLogs = [],
230
+ setupLogs = [],
231
+ setupOperations = [],
232
+ }) {
233
+ return buildRunArtifact({
234
+ productDir,
235
+ results,
236
+ startedAt,
237
+ finishedAt: updatedAt,
238
+ execution,
239
+ workerCount,
240
+ runtimeInstanceCount,
241
+ runtimeStats,
242
+ typeValues,
243
+ suiteSelectors,
244
+ fileNames,
245
+ shard,
246
+ serviceFilter,
247
+ metadata,
248
+ summarizeDbBackend,
249
+ serviceLogs,
250
+ setupLogs,
251
+ setupOperations,
252
+ runStatus: "running",
253
+ });
254
+ }
255
+
206
256
  function isEffectivelySkippedService(result) {
207
257
  return (
208
258
  !result.skipped &&
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
2
+ import { buildLiveRunArtifact, buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
3
3
 
4
4
  describe("runner reporting", () => {
5
5
  it("builds run artifacts", () => {
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
78
78
  });
79
79
 
80
80
  expect(artifact.product.name).toBe("my-product");
81
- expect(artifact.schemaVersion).toBe(7);
81
+ expect(artifact.schemaVersion).toBe(8);
82
82
  expect(artifact.run).toMatchObject({
83
83
  workers: 2,
84
84
  fileTimeoutSeconds: 60,
@@ -109,7 +109,85 @@ describe("runner reporting", () => {
109
109
  expect(artifact.services[0].totalTaskDurationMs).toBe(2400);
110
110
  expect(artifact.logs).toEqual({
111
111
  services: [],
112
+ setup: [],
112
113
  });
114
+ expect(artifact.setup).toEqual({
115
+ operations: [],
116
+ });
117
+ });
118
+
119
+ it("builds live run artifacts with running status and setup details", () => {
120
+ const artifact = buildLiveRunArtifact({
121
+ productDir: "/tmp/my-product",
122
+ results: [],
123
+ startedAt: 1_000,
124
+ updatedAt: 2_000,
125
+ execution: {
126
+ workers: 2,
127
+ fileTimeoutSeconds: 60,
128
+ },
129
+ workerCount: 1,
130
+ runtimeInstanceCount: 1,
131
+ runtimeStats: [],
132
+ typeValues: ["int"],
133
+ suiteSelectors: [],
134
+ fileNames: [],
135
+ shard: null,
136
+ serviceFilter: null,
137
+ metadata: {
138
+ git: {
139
+ branch: "main",
140
+ commitSha: "abc",
141
+ repoRoot: "/tmp",
142
+ },
143
+ host: {
144
+ hostname: "local",
145
+ username: "dev",
146
+ },
147
+ testkitVersion: "0.1.54",
148
+ },
149
+ summarizeDbBackend: () => "local",
150
+ serviceLogs: [],
151
+ setupLogs: [
152
+ {
153
+ serviceName: "api",
154
+ runtimeLabel: "api",
155
+ stage: "runtime:prepare",
156
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
157
+ },
158
+ ],
159
+ setupOperations: [
160
+ {
161
+ id: "setup-1",
162
+ serviceName: "api",
163
+ runtimeLabel: "api",
164
+ stage: "runtime:prepare",
165
+ kind: "runtime-prepare",
166
+ summary: "runtime prepare",
167
+ parentId: null,
168
+ status: "running",
169
+ startedAt: "1970-01-01T00:00:01.000Z",
170
+ finishedAt: null,
171
+ durationMs: null,
172
+ error: null,
173
+ logRef: {
174
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
175
+ stage: "runtime:prepare",
176
+ },
177
+ },
178
+ ],
179
+ });
180
+
181
+ expect(artifact.run.status).toBe("running");
182
+ expect(artifact.logs.setup).toEqual([
183
+ {
184
+ serviceName: "api",
185
+ runtimeLabel: "api",
186
+ stage: "runtime:prepare",
187
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
188
+ },
189
+ ]);
190
+ expect(artifact.setup.operations).toHaveLength(1);
113
191
  });
114
192
 
115
193
  it("builds deterministic status artifacts", () => {
@@ -39,7 +39,7 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle, optio
39
39
  if (!context.prepared) {
40
40
  if (!context.preparationPromise) {
41
41
  context.preparationPromise = (async () => {
42
- await prepareDatabases(context.runtimeConfigs);
42
+ await prepareDatabases(context.runtimeConfigs, options);
43
43
  await prepareRuntimeServices(context.runtimeConfigs, options);
44
44
  context.prepared = true;
45
45
  })().finally(() => {
@@ -80,8 +80,8 @@ export async function cleanupRuntimeInstanceContext(context, lifecycle) {
80
80
  await deactivateRuntimeInstanceContext(context, lifecycle);
81
81
  }
82
82
 
83
- export async function prepareDatabases(runtimeConfigs) {
83
+ export async function prepareDatabases(runtimeConfigs, options = {}) {
84
84
  for (const config of runtimeConfigs) {
85
- await prepareDatabaseRuntime(config);
85
+ await prepareDatabaseRuntime(config, options);
86
86
  }
87
87
  }
@@ -28,6 +28,12 @@ export async function prepareRuntimeService(config, options = {}) {
28
28
  const manifestPath = path.join(prepareDir, MANIFEST_FILE);
29
29
  const existingManifest = readPrepareManifest(manifestPath);
30
30
  if (existingManifest?.fingerprint === fingerprint) {
31
+ options.setupRegistry?.recordCached({
32
+ config,
33
+ stage: "runtime:prepare",
34
+ kind: "runtime-prepare",
35
+ summary: "runtime prepare cache hit",
36
+ });
31
37
  return;
32
38
  }
33
39
 
@@ -42,6 +48,14 @@ export async function prepareRuntimeService(config, options = {}) {
42
48
  env.DATABASE_URL = databaseUrl;
43
49
  }
44
50
 
51
+ const prepareOperation = options.setupRegistry?.start({
52
+ config,
53
+ stage: "runtime:prepare",
54
+ kind: "runtime-prepare",
55
+ summary: "runtime prepare",
56
+ recordLog: false,
57
+ });
58
+
45
59
  try {
46
60
  await announceResolvedToolchain(
47
61
  config,
@@ -54,7 +68,16 @@ export async function prepareRuntimeService(config, options = {}) {
54
68
  env,
55
69
  labelPrefix: "runtime:prepare",
56
70
  reporter: options.reporter,
71
+ setupRegistry: options.setupRegistry || null,
72
+ parentOperation: prepareOperation,
57
73
  });
74
+ const finished = prepareOperation
75
+ ? options.setupRegistry.finish(prepareOperation, {
76
+ status: "passed",
77
+ summary: "runtime prepare",
78
+ })
79
+ : null;
80
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
58
81
  writePrepareManifest(manifestPath, {
59
82
  fingerprint,
60
83
  preparedAt: new Date().toISOString(),
@@ -62,6 +85,14 @@ export async function prepareRuntimeService(config, options = {}) {
62
85
  serviceName: config.name,
63
86
  });
64
87
  } catch (error) {
88
+ const finished = prepareOperation
89
+ ? options.setupRegistry.finish(prepareOperation, {
90
+ status: "failed",
91
+ summary: "runtime prepare",
92
+ error: error?.message || error,
93
+ })
94
+ : null;
95
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
65
96
  fs.rmSync(prepareDir, { recursive: true, force: true });
66
97
  throw error;
67
98
  }