@dailephd/my-dev-kit-lab 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -2
- package/README.md +8 -1
- package/dist/src/browser/index.js +2 -0
- package/dist/src/browser/playwrightRuntime.js +74 -0
- package/dist/src/browser/types.js +10 -0
- package/dist/src/cli/help.js +76 -0
- package/dist/src/cli/runLabCli.js +29 -1
- package/dist/src/commands/runTutorialRunCommand.js +170 -0
- package/dist/src/commands/runTutorialValidateCommand.js +138 -0
- package/dist/src/core/processTree.js +52 -0
- package/dist/src/core/resolveCommand.js +34 -0
- package/dist/src/core/runMeasuredCommand.js +39 -31
- package/dist/src/runtime/index.js +1 -0
- package/dist/src/runtime/managedProcess.js +380 -0
- package/dist/src/screenshot/captureReportScreenshot.js +30 -27
- package/dist/src/tutorial/index.js +17 -0
- package/dist/src/tutorial/loadTutorialContracts.js +60 -0
- package/dist/src/tutorial/markdownTutorialWriter.js +53 -0
- package/dist/src/tutorial/runTutorial.js +465 -0
- package/dist/src/tutorial/scenarioValidation.js +427 -0
- package/dist/src/tutorial/subtitleWriter.js +88 -0
- package/dist/src/tutorial/targetContractValidation.js +212 -0
- package/dist/src/tutorial/tutorialActions.js +175 -0
- package/dist/src/tutorial/tutorialArtifacts.js +176 -0
- package/dist/src/tutorial/tutorialAssertions.js +249 -0
- package/dist/src/tutorial/tutorialCursor.js +149 -0
- package/dist/src/tutorial/tutorialLocators.js +37 -0
- package/dist/src/tutorial/tutorialManifest.js +74 -0
- package/dist/src/tutorial/tutorialOverlay.js +180 -0
- package/dist/src/tutorial/tutorialPaths.js +86 -0
- package/dist/src/tutorial/tutorialPointerGeometry.js +16 -0
- package/dist/src/tutorial/tutorialSession.js +424 -0
- package/dist/src/tutorial/types.js +51 -0
- package/dist/src/tutorial/validationPrimitives.js +141 -0
- package/examples/tutorial-browser/index.html +109 -0
- package/examples/tutorial-browser/prepare.mjs +14 -0
- package/examples/tutorial-browser/scenario.json +19 -0
- package/examples/tutorial-browser/server.mjs +14 -0
- package/package.json +8 -4
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { launchChromium } from "../browser/index.js";
|
|
4
|
+
import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
|
|
5
|
+
import { startManagedProcess } from "../runtime/managedProcess.js";
|
|
6
|
+
import { loadTutorialScenario, loadTutorialTargetContract } from "./loadTutorialContracts.js";
|
|
7
|
+
import { describeTargetIdMismatch, tutorialTargetIdsMatch } from "./targetContractValidation.js";
|
|
8
|
+
import { buildTutorialRunPaths, createTutorialRunDirectories, generateTutorialRunId } from "./tutorialPaths.js";
|
|
9
|
+
import { closeTutorialSession, executeTutorialSteps, openTutorialSession } from "./tutorialSession.js";
|
|
10
|
+
import { buildTutorialArtifactPaths, finalizeTutorialVideo, writeTextArtifact } from "./tutorialArtifacts.js";
|
|
11
|
+
import { buildSubtitleCues, renderSrt, renderVtt } from "./subtitleWriter.js";
|
|
12
|
+
import { renderTutorialMarkdown } from "./markdownTutorialWriter.js";
|
|
13
|
+
import { buildTutorialManifest, renderTutorialManifest } from "./tutorialManifest.js";
|
|
14
|
+
import { TARGET_ROOT_PLACEHOLDER, TUTORIAL_RUN_RESULT_SCHEMA_VERSION } from "./types.js";
|
|
15
|
+
export async function runTutorial(options) {
|
|
16
|
+
const startedAtMs = Date.now();
|
|
17
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const cleanupErrors = [];
|
|
20
|
+
const finish = (status, extra = {}) => {
|
|
21
|
+
const endedAtMs = Date.now();
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: TUTORIAL_RUN_RESULT_SCHEMA_VERSION,
|
|
24
|
+
status,
|
|
25
|
+
startedAt,
|
|
26
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
27
|
+
durationMs: endedAtMs - startedAtMs,
|
|
28
|
+
steps: [],
|
|
29
|
+
artifacts: { screenshots: [] },
|
|
30
|
+
warnings,
|
|
31
|
+
cleanupErrors,
|
|
32
|
+
...extra
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
// ---- 1. Contracts -------------------------------------------------------
|
|
36
|
+
const loadedScenario = await loadTutorialScenario(options.context.invocationCwd, options.scenarioPath);
|
|
37
|
+
if (!loadedScenario.ok) {
|
|
38
|
+
return finish("scenario-invalid", { error: loadedScenario.errors.join("\n") });
|
|
39
|
+
}
|
|
40
|
+
const { scenario } = loadedScenario.value;
|
|
41
|
+
const loadedTarget = await loadTutorialTargetContract(options.context.invocationCwd, options.targetContractPath);
|
|
42
|
+
if (!loadedTarget.ok) {
|
|
43
|
+
return finish("target-invalid", {
|
|
44
|
+
scenarioId: scenario.id,
|
|
45
|
+
error: loadedTarget.errors.join("\n")
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const { targetContract, contractRoot } = loadedTarget.value;
|
|
49
|
+
// ---- 2. Identity --------------------------------------------------------
|
|
50
|
+
// Checked before any directory is created, any command runs, or any process
|
|
51
|
+
// starts, so a mismatched pair costs nothing but a comparison.
|
|
52
|
+
if (!tutorialTargetIdsMatch(scenario.targetId, targetContract.id)) {
|
|
53
|
+
return finish("target-mismatch", {
|
|
54
|
+
scenarioId: scenario.id,
|
|
55
|
+
targetId: targetContract.id,
|
|
56
|
+
error: describeTargetIdMismatch(scenario.targetId, targetContract.id)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// ---- 3. Run directory ---------------------------------------------------
|
|
60
|
+
const runId = (options.generateRunId ?? generateTutorialRunId)();
|
|
61
|
+
const paths = buildTutorialRunPaths({
|
|
62
|
+
workspaceRoot: options.context.workspaceRoot,
|
|
63
|
+
invocationCwd: options.context.invocationCwd,
|
|
64
|
+
scenarioId: scenario.id,
|
|
65
|
+
runId,
|
|
66
|
+
outDir: options.outDir
|
|
67
|
+
});
|
|
68
|
+
const identity = { scenarioId: scenario.id, targetId: targetContract.id, runId, paths };
|
|
69
|
+
try {
|
|
70
|
+
await createTutorialRunDirectories(paths);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// No run layout, so no artifacts and no manifest are possible or expected.
|
|
74
|
+
return finish("prepare-failed", {
|
|
75
|
+
...identity,
|
|
76
|
+
error: `Tutorial run directory could not be created at ${paths.runRoot}: ${messageOf(error)}`
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ends a run that failed before step execution produced anything recordable.
|
|
81
|
+
*
|
|
82
|
+
* The run layout exists, so a manifest is still written: it reports the real
|
|
83
|
+
* failure and marks every tutorial artifact as skipped rather than pretending
|
|
84
|
+
* one was produced. The failure status is never replaced by an artifact
|
|
85
|
+
* status -- an early failure is not a video problem.
|
|
86
|
+
*/
|
|
87
|
+
const finishBeforeArtifacts = async (status, error, reason) => {
|
|
88
|
+
const skipped = (kind) => ({
|
|
89
|
+
kind,
|
|
90
|
+
status: "skipped",
|
|
91
|
+
error: reason
|
|
92
|
+
});
|
|
93
|
+
const artifacts = {
|
|
94
|
+
video: skipped("video"),
|
|
95
|
+
srt: skipped("srt"),
|
|
96
|
+
vtt: skipped("vtt"),
|
|
97
|
+
markdown: skipped("markdown"),
|
|
98
|
+
screenshots: []
|
|
99
|
+
};
|
|
100
|
+
const endedAtMs = Date.now();
|
|
101
|
+
const manifest = buildTutorialManifest({
|
|
102
|
+
scenario,
|
|
103
|
+
targetContract,
|
|
104
|
+
run: {
|
|
105
|
+
id: runId,
|
|
106
|
+
status,
|
|
107
|
+
startedAt,
|
|
108
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
109
|
+
durationMs: endedAtMs - startedAtMs
|
|
110
|
+
},
|
|
111
|
+
steps: [],
|
|
112
|
+
artifacts: [artifacts.video, artifacts.srt, artifacts.vtt, artifacts.markdown],
|
|
113
|
+
warnings,
|
|
114
|
+
cleanupErrors
|
|
115
|
+
});
|
|
116
|
+
artifacts.manifest = await writeTextArtifact({
|
|
117
|
+
kind: "manifest",
|
|
118
|
+
runRoot: paths.runRoot,
|
|
119
|
+
filePath: buildTutorialArtifactPaths(paths).manifest,
|
|
120
|
+
contents: renderTutorialManifest(manifest)
|
|
121
|
+
});
|
|
122
|
+
if (artifacts.manifest.status === "failed") {
|
|
123
|
+
warnings.push(artifacts.manifest.error ?? "Tutorial manifest could not be written.");
|
|
124
|
+
}
|
|
125
|
+
return finish(status, { ...identity, artifacts, error });
|
|
126
|
+
};
|
|
127
|
+
// ---- 4. Prepare ---------------------------------------------------------
|
|
128
|
+
const prepareResult = await runPrepare(targetContract, contractRoot, paths);
|
|
129
|
+
if (prepareResult) {
|
|
130
|
+
return finishBeforeArtifacts("prepare-failed", prepareResult, "Tutorial preparation failed before any recording started.");
|
|
131
|
+
}
|
|
132
|
+
// ---- 5. Processes + readiness ------------------------------------------
|
|
133
|
+
const started = [];
|
|
134
|
+
const startup = await startTargetProcesses(targetContract, contractRoot, paths, started);
|
|
135
|
+
if (startup) {
|
|
136
|
+
cleanupErrors.push(...(await stopProcessesInReverse(started)));
|
|
137
|
+
return finishBeforeArtifacts(startup.status, startup.error, "Target processes did not become ready, so no recording started.");
|
|
138
|
+
}
|
|
139
|
+
// ---- 6. Browser ---------------------------------------------------------
|
|
140
|
+
const launch = await (options.launchBrowser ?? launchChromium)({ headless: true });
|
|
141
|
+
if (launch.status !== "launched") {
|
|
142
|
+
cleanupErrors.push(...(await stopProcessesInReverse(started)));
|
|
143
|
+
const status = launch.status === "unavailable" ? "browser-unavailable" : "browser-failed";
|
|
144
|
+
const error = launch.status === "unavailable"
|
|
145
|
+
? `Tutorial browser runtime is unavailable (${launch.reason}): ${launch.error}`
|
|
146
|
+
: `Tutorial browser failed to launch: ${launch.error}`;
|
|
147
|
+
return finishBeforeArtifacts(status, error, "Tutorial recording never started because the browser was not available.");
|
|
148
|
+
}
|
|
149
|
+
const browser = launch.browser;
|
|
150
|
+
// ---- 7. Session + steps -------------------------------------------------
|
|
151
|
+
const artifactPaths = buildTutorialArtifactPaths(paths);
|
|
152
|
+
let steps = [];
|
|
153
|
+
let screenshots = [];
|
|
154
|
+
let primaryStatus = "passed";
|
|
155
|
+
let primaryError;
|
|
156
|
+
let videoRecordingStarted = false;
|
|
157
|
+
let videoRecord;
|
|
158
|
+
const opened = await openTutorialSession(browser, {
|
|
159
|
+
viewport: scenario.browser.viewport,
|
|
160
|
+
recordVideoDir: artifactPaths.videoTempDir
|
|
161
|
+
});
|
|
162
|
+
if (!opened.ok) {
|
|
163
|
+
primaryStatus = "browser-failed";
|
|
164
|
+
primaryError = opened.error;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
videoRecordingStarted = true;
|
|
168
|
+
const execution = await executeTutorialSteps({
|
|
169
|
+
page: opened.session.page,
|
|
170
|
+
scenario,
|
|
171
|
+
applicationUrl: targetContract.applicationUrl,
|
|
172
|
+
targetRoot: paths.targetRoot,
|
|
173
|
+
visuals: true,
|
|
174
|
+
paths,
|
|
175
|
+
...(options.sleep ? { sleep: options.sleep } : {})
|
|
176
|
+
});
|
|
177
|
+
steps = execution.steps;
|
|
178
|
+
screenshots = execution.screenshots;
|
|
179
|
+
warnings.push(...execution.visualWarnings);
|
|
180
|
+
if (execution.failedStepId !== undefined) {
|
|
181
|
+
primaryStatus = "step-failed";
|
|
182
|
+
primaryError = execution.error;
|
|
183
|
+
}
|
|
184
|
+
// Playwright finalizes a recording only on page close, so the session is
|
|
185
|
+
// closed before the video is saved. A failed tutorial still gets its video.
|
|
186
|
+
cleanupErrors.push(...(await closeTutorialSession(opened.session)));
|
|
187
|
+
const finalized = await finalizeTutorialVideo({
|
|
188
|
+
video: opened.session.video,
|
|
189
|
+
runRoot: paths.runRoot,
|
|
190
|
+
videoPath: artifactPaths.video,
|
|
191
|
+
videoTempDir: artifactPaths.videoTempDir
|
|
192
|
+
});
|
|
193
|
+
videoRecord = finalized.record;
|
|
194
|
+
warnings.push(...finalized.warnings);
|
|
195
|
+
}
|
|
196
|
+
// ---- 8. Browser cleanup -------------------------------------------------
|
|
197
|
+
// Browser resources are released before the processes they were talking to.
|
|
198
|
+
const browserCloseError = await closeQuietly(() => browser.close());
|
|
199
|
+
if (browserCloseError) {
|
|
200
|
+
cleanupErrors.push(`Tutorial browser close failed: ${browserCloseError}`);
|
|
201
|
+
}
|
|
202
|
+
// ---- 9. Artifacts -------------------------------------------------------
|
|
203
|
+
if (!videoRecord) {
|
|
204
|
+
videoRecord = {
|
|
205
|
+
kind: "video",
|
|
206
|
+
status: "skipped",
|
|
207
|
+
error: "Tutorial recording never started, so no canonical WebM was expected."
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const cues = buildSubtitleCues({ scenario, steps });
|
|
211
|
+
const srtRecord = await writeTextArtifact({
|
|
212
|
+
kind: "srt",
|
|
213
|
+
runRoot: paths.runRoot,
|
|
214
|
+
filePath: artifactPaths.srt,
|
|
215
|
+
contents: renderSrt(cues)
|
|
216
|
+
});
|
|
217
|
+
const vttRecord = await writeTextArtifact({
|
|
218
|
+
kind: "vtt",
|
|
219
|
+
runRoot: paths.runRoot,
|
|
220
|
+
filePath: artifactPaths.vtt,
|
|
221
|
+
contents: renderVtt(cues)
|
|
222
|
+
});
|
|
223
|
+
const markdownRecord = await writeTextArtifact({
|
|
224
|
+
kind: "markdown",
|
|
225
|
+
runRoot: paths.runRoot,
|
|
226
|
+
filePath: artifactPaths.markdown,
|
|
227
|
+
contents: renderTutorialMarkdown({ scenario, steps, screenshots })
|
|
228
|
+
});
|
|
229
|
+
// ---- 10. Process cleanup ------------------------------------------------
|
|
230
|
+
cleanupErrors.push(...(await stopProcessesInReverse(started)));
|
|
231
|
+
// ---- 11. Final status ---------------------------------------------------
|
|
232
|
+
// Precedence for an otherwise-passing run, most specific first: a missing
|
|
233
|
+
// required video, then any other failed required artifact, then cleanup.
|
|
234
|
+
// A real execution failure is never replaced by an artifact problem.
|
|
235
|
+
const artifactFailures = [videoRecord, srtRecord, vttRecord, markdownRecord, ...screenshots].filter((record) => record.status === "failed");
|
|
236
|
+
const videoFailed = videoRecord.status === "failed" || (videoRecordingStarted && videoRecord.status === "skipped");
|
|
237
|
+
const nonVideoArtifactFailed = artifactFailures.some((record) => record.kind !== "video");
|
|
238
|
+
let finalStatus = primaryStatus;
|
|
239
|
+
let finalError = primaryError;
|
|
240
|
+
if (primaryStatus === "passed") {
|
|
241
|
+
if (videoFailed) {
|
|
242
|
+
finalStatus = "video-finalization-failed";
|
|
243
|
+
finalError = videoRecord.error ?? "Canonical tutorial video could not be finalized.";
|
|
244
|
+
}
|
|
245
|
+
else if (nonVideoArtifactFailed) {
|
|
246
|
+
finalStatus = "artifact-failed";
|
|
247
|
+
finalError = artifactFailures
|
|
248
|
+
.filter((record) => record.kind !== "video")
|
|
249
|
+
.map((record) => record.error ?? `${record.kind} artifact failed`)
|
|
250
|
+
.join("; ");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Artifact problems are recorded but must not mask the real failure.
|
|
255
|
+
for (const record of artifactFailures) {
|
|
256
|
+
warnings.push(record.error ?? `${record.kind} artifact failed`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ---- 12. Manifest -------------------------------------------------------
|
|
260
|
+
const allArtifacts = [
|
|
261
|
+
videoRecord,
|
|
262
|
+
srtRecord,
|
|
263
|
+
vttRecord,
|
|
264
|
+
markdownRecord,
|
|
265
|
+
...screenshots,
|
|
266
|
+
...collectProcessLogRecords(targetContract, paths)
|
|
267
|
+
];
|
|
268
|
+
const manifestEndedAtMs = Date.now();
|
|
269
|
+
const manifest = buildTutorialManifest({
|
|
270
|
+
scenario,
|
|
271
|
+
targetContract,
|
|
272
|
+
run: {
|
|
273
|
+
id: runId,
|
|
274
|
+
status: finalStatus,
|
|
275
|
+
startedAt,
|
|
276
|
+
endedAt: new Date(manifestEndedAtMs).toISOString(),
|
|
277
|
+
durationMs: manifestEndedAtMs - startedAtMs
|
|
278
|
+
},
|
|
279
|
+
steps,
|
|
280
|
+
artifacts: allArtifacts,
|
|
281
|
+
warnings,
|
|
282
|
+
cleanupErrors
|
|
283
|
+
});
|
|
284
|
+
const manifestRecord = await writeTextArtifact({
|
|
285
|
+
kind: "manifest",
|
|
286
|
+
runRoot: paths.runRoot,
|
|
287
|
+
filePath: artifactPaths.manifest,
|
|
288
|
+
contents: renderTutorialManifest(manifest)
|
|
289
|
+
});
|
|
290
|
+
if (manifestRecord.status === "failed") {
|
|
291
|
+
if (finalStatus === "passed") {
|
|
292
|
+
finalStatus = "artifact-failed";
|
|
293
|
+
finalError = manifestRecord.error;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
warnings.push(manifestRecord.error ?? "Tutorial manifest could not be written.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const artifacts = {
|
|
300
|
+
video: videoRecord,
|
|
301
|
+
srt: srtRecord,
|
|
302
|
+
vtt: vttRecord,
|
|
303
|
+
markdown: markdownRecord,
|
|
304
|
+
manifest: manifestRecord,
|
|
305
|
+
screenshots
|
|
306
|
+
};
|
|
307
|
+
// A cleanup failure only downgrades an otherwise-clean run, because "passed"
|
|
308
|
+
// would misreport a leaked resource.
|
|
309
|
+
if (finalStatus === "passed" && cleanupErrors.length > 0) {
|
|
310
|
+
return finish("cleanup-failed", {
|
|
311
|
+
...identity,
|
|
312
|
+
steps,
|
|
313
|
+
artifacts,
|
|
314
|
+
error: `Tutorial run completed but cleanup failed: ${cleanupErrors.join("; ")}`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return finish(finalStatus, {
|
|
318
|
+
...identity,
|
|
319
|
+
steps,
|
|
320
|
+
artifacts,
|
|
321
|
+
...(finalError !== undefined ? { error: finalError } : {})
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Managed-process log paths are known from the contract and the run layout, so
|
|
326
|
+
* they are recorded from structured data rather than by scanning the directory.
|
|
327
|
+
*/
|
|
328
|
+
function collectProcessLogRecords(targetContract, paths) {
|
|
329
|
+
const records = [];
|
|
330
|
+
for (const declared of targetContract.processes) {
|
|
331
|
+
records.push({
|
|
332
|
+
kind: "stdout-log",
|
|
333
|
+
id: declared.id,
|
|
334
|
+
status: "written",
|
|
335
|
+
path: `logs/processes/${declared.id}.stdout.txt`
|
|
336
|
+
});
|
|
337
|
+
records.push({
|
|
338
|
+
kind: "stderr-log",
|
|
339
|
+
id: declared.id,
|
|
340
|
+
status: "written",
|
|
341
|
+
path: `logs/processes/${declared.id}.stderr.txt`
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return records;
|
|
345
|
+
}
|
|
346
|
+
/** Returns an error message when prepare failed, otherwise undefined. */
|
|
347
|
+
async function runPrepare(targetContract, contractRoot, paths) {
|
|
348
|
+
const prepareLogDir = path.join(paths.logsRoot, "prepare");
|
|
349
|
+
const measured = await runMeasuredCommand({
|
|
350
|
+
commandId: "prepare",
|
|
351
|
+
executable: targetContract.prepare.executable,
|
|
352
|
+
args: expandPlaceholders(targetContract.prepare.args ?? [], paths.targetRoot),
|
|
353
|
+
cwd: contractRoot,
|
|
354
|
+
outDir: prepareLogDir,
|
|
355
|
+
env: expandEnvPlaceholders(targetContract.prepare.env, paths.targetRoot),
|
|
356
|
+
timeoutMs: undefined
|
|
357
|
+
});
|
|
358
|
+
if (!measured.ok || measured.exitCode !== 0) {
|
|
359
|
+
const detail = measured.error ?? `exit code ${String(measured.exitCode)}`;
|
|
360
|
+
return `Tutorial prepare command exited with ${detail}. See ${measured.stdoutPath} and ${measured.stderrPath}.`;
|
|
361
|
+
}
|
|
362
|
+
// The prepare command is the only thing allowed to populate targetRoot, so its
|
|
363
|
+
// success is not enough -- the directory it promised must actually be there.
|
|
364
|
+
try {
|
|
365
|
+
const stats = await stat(paths.targetRoot);
|
|
366
|
+
if (!stats.isDirectory()) {
|
|
367
|
+
return `Tutorial prepare command succeeded but ${paths.targetRoot} is not a directory.`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return `Tutorial prepare command succeeded but did not create the target directory ${paths.targetRoot}.`;
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
async function startTargetProcesses(targetContract, contractRoot, paths, started) {
|
|
376
|
+
for (const declared of targetContract.processes) {
|
|
377
|
+
let handle;
|
|
378
|
+
try {
|
|
379
|
+
handle = await startManagedProcess({
|
|
380
|
+
id: declared.id,
|
|
381
|
+
executable: declared.executable,
|
|
382
|
+
args: expandPlaceholders(declared.args ?? [], paths.targetRoot),
|
|
383
|
+
cwd: resolveProcessCwd(declared, contractRoot, paths),
|
|
384
|
+
outDir: path.join(paths.logsRoot, "processes"),
|
|
385
|
+
env: expandEnvPlaceholders(declared.env, paths.targetRoot)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return {
|
|
390
|
+
status: "process-start-failed",
|
|
391
|
+
error: `Managed process ${JSON.stringify(declared.id)} failed to start: ${messageOf(error)}`
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
started.push(handle);
|
|
395
|
+
const readiness = await handle.waitForReadiness(declared.readiness);
|
|
396
|
+
if (readiness.status !== "ready") {
|
|
397
|
+
return {
|
|
398
|
+
status: "readiness-failed",
|
|
399
|
+
error: describeReadinessFailure(declared, readiness.status, readiness.error)
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
function describeReadinessFailure(declared, status, error) {
|
|
406
|
+
const id = JSON.stringify(declared.id);
|
|
407
|
+
switch (status) {
|
|
408
|
+
case "timeout":
|
|
409
|
+
return `Managed process ${id} did not become ready before the readiness timeout (${declared.readiness.timeoutMs}ms) at ${declared.readiness.url}.`;
|
|
410
|
+
case "process-exited":
|
|
411
|
+
return `Managed process ${id} exited before it became ready at ${declared.readiness.url}.`;
|
|
412
|
+
default:
|
|
413
|
+
return `Managed process ${id} readiness probe failed at ${declared.readiness.url}: ${error ?? "unknown readiness error"}`;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function resolveProcessCwd(declared, contractRoot, paths) {
|
|
417
|
+
return declared.cwd === "target-root" ? paths.targetRoot : contractRoot;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Substitutes the single supported placeholder. Validation has already rejected
|
|
421
|
+
* every other `{{...}}` form, so this is a literal replacement and never a
|
|
422
|
+
* template evaluation.
|
|
423
|
+
*/
|
|
424
|
+
export function expandPlaceholders(values, targetRoot) {
|
|
425
|
+
return values.map((value) => value.split(TARGET_ROOT_PLACEHOLDER).join(targetRoot));
|
|
426
|
+
}
|
|
427
|
+
export function expandEnvPlaceholders(env, targetRoot) {
|
|
428
|
+
if (!env) {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
const expanded = {};
|
|
432
|
+
for (const [key, value] of Object.entries(env)) {
|
|
433
|
+
expanded[key] = value.split(TARGET_ROOT_PLACEHOLDER).join(targetRoot);
|
|
434
|
+
}
|
|
435
|
+
return expanded;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Stops managed processes in reverse startup order so a dependent process is
|
|
439
|
+
* always shut down before whatever it depends on.
|
|
440
|
+
*/
|
|
441
|
+
async function stopProcessesInReverse(started) {
|
|
442
|
+
const errors = [];
|
|
443
|
+
for (const handle of [...started].reverse()) {
|
|
444
|
+
try {
|
|
445
|
+
await handle.stop();
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
errors.push(`Managed process ${JSON.stringify(handle.id)} failed to stop: ${messageOf(error)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
started.length = 0;
|
|
452
|
+
return errors;
|
|
453
|
+
}
|
|
454
|
+
async function closeQuietly(close) {
|
|
455
|
+
try {
|
|
456
|
+
await close();
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
return messageOf(error);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function messageOf(error) {
|
|
464
|
+
return error instanceof Error ? error.message : String(error);
|
|
465
|
+
}
|