deepline 0.3.213 → 0.3.215

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 (33) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +122 -32
  2. package/dist/bundling-sources/sdk/src/compat.ts +2 -0
  3. package/dist/bundling-sources/sdk/src/deployment-protection.ts +19 -0
  4. package/dist/bundling-sources/sdk/src/http.ts +3 -7
  5. package/dist/bundling-sources/sdk/src/index.ts +7 -0
  6. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  7. package/dist/bundling-sources/sdk/src/types.ts +25 -0
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +18 -0
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +33 -7
  11. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +11 -1
  12. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +3 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +53 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +187 -6
  15. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +12 -3
  16. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +5 -2
  17. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +40 -0
  18. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +51 -28
  19. package/dist/bundling-sources/shared_libs/plays/deploy/monitor-files.ts +52 -0
  20. package/dist/bundling-sources/shared_libs/plays/deploy/run-deployment.ts +146 -19
  21. package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +176 -0
  22. package/dist/cli/index.js +1046 -416
  23. package/dist/cli/index.mjs +894 -264
  24. package/dist/index.d.mts +165 -3
  25. package/dist/index.d.ts +165 -3
  26. package/dist/index.js +67 -35
  27. package/dist/index.mjs +67 -35
  28. package/dist/install-integrity.json +3 -0
  29. package/dist/release.d.mts +1 -1
  30. package/dist/release.d.ts +1 -1
  31. package/dist/release.js +1 -1
  32. package/dist/release.mjs +1 -1
  33. package/package.json +3 -2
@@ -1,5 +1,8 @@
1
1
  import { STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS } from '../../../plays/sandbox-runtime-limits';
2
2
  import { RUNTIME_SANDBOX_MEMORY_ABORT_CODE } from '../../run-failure';
3
+ import { PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER } from '../../runtime-contract';
4
+ import { RUNTIME_CLAIM_HEARTBEAT_DIVISOR } from '../../runtime-constants';
5
+ import { RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS } from '../../runner-app/runtime-heartbeat';
3
6
  import {
4
7
  RUNTIME_MEMORY_LEDGER_PATH_ENV,
5
8
  RUNTIME_MEMORY_PRESSURE_PATH_ENV,
@@ -116,11 +119,11 @@ export const RUNTIME_SANDBOX_MEMORY_ABORT_REASON =
116
119
  RUNTIME_SANDBOX_MEMORY_ABORT_CODE;
117
120
 
118
121
  /**
119
- * Build the tiny parent process that owns the hard runtime deadline and the
120
- * sandbox memory envelope outside the customer-code Node event loop. A
121
- * synchronous infinite loop can block the runner's own timer, but it cannot
122
- * block this separate process; nor can a runner whose heap is thrashing run
123
- * its own memory watch reliably.
122
+ * Build the parent process that owns runner liveness, the hard runtime
123
+ * deadline, and the sandbox memory envelope outside the customer-code Node
124
+ * event loop. A synchronous infinite loop can block the runner's timers, but
125
+ * it cannot block this separate process; nor can a runner whose heap is
126
+ * thrashing run its own memory watch reliably.
124
127
  *
125
128
  * argv (after `node -e <source>`):
126
129
  * 1 runnerPath, 2 configPath, 3 runtimeStartedPath, 4 runtimeCompletedPath,
@@ -185,6 +188,178 @@ let settled = false;
185
188
  let forceKillTimer = null;
186
189
  let runtimeTimer = null;
187
190
  let terminalGraceTimer = null;
191
+ let heartbeatTimer = null;
192
+ let heartbeatController = null;
193
+ let heartbeatInFlight = false;
194
+ let heartbeatStopped = false;
195
+ let heartbeatConfigRead = false;
196
+ let heartbeatConfig = null;
197
+ let heartbeatLastSuccessAtMs = 0;
198
+ let heartbeatAttempt = 0;
199
+ let heartbeatLastErrorCode = null;
200
+ let heartbeatStopBeforeLeaseMs = 0;
201
+ let stopRequested = false;
202
+
203
+ function readRunnerHeartbeatConfig() {
204
+ if (heartbeatConfigRead) return heartbeatConfig;
205
+ heartbeatConfigRead = true;
206
+ try {
207
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
208
+ const context = config && config.context;
209
+ const push = context && context.runnerPushExecution;
210
+ const gatewayBaseUrl = String(context && context.receiptGatewayBaseUrl || '').replace(/\\/$/, '');
211
+ const executorToken = context && context.executorToken;
212
+ const runId = push && push.runId;
213
+ const leaseSeconds = Number(push && push.leaseSeconds);
214
+ if (
215
+ !gatewayBaseUrl ||
216
+ typeof executorToken !== 'string' ||
217
+ !executorToken.trim() ||
218
+ typeof runId !== 'string' ||
219
+ !runId.trim() ||
220
+ !Number.isFinite(leaseSeconds) ||
221
+ leaseSeconds <= 0
222
+ ) {
223
+ return null;
224
+ }
225
+ heartbeatConfig = {
226
+ gatewayBaseUrl,
227
+ executorToken,
228
+ runId,
229
+ leaseMs: Math.max(1_000, Math.floor(leaseSeconds * 1_000)),
230
+ };
231
+ } catch {}
232
+ return heartbeatConfig;
233
+ }
234
+
235
+ function stopRunnerHeartbeat() {
236
+ heartbeatStopped = true;
237
+ if (heartbeatTimer) {
238
+ clearInterval(heartbeatTimer);
239
+ heartbeatTimer = null;
240
+ }
241
+ if (heartbeatController) {
242
+ heartbeatController.abort();
243
+ heartbeatController = null;
244
+ }
245
+ }
246
+
247
+ function runnerHeartbeatErrorCode(error) {
248
+ const code = error && typeof error.code === 'string' ? error.code : '';
249
+ if (/^(?:E[A-Z0-9_]+|ERR_[A-Z0-9_]+|UND_ERR_[A-Z0-9_]+)$/.test(code)) return code;
250
+ const name = error && typeof error.name === 'string' ? error.name : '';
251
+ if (name === 'AbortError' || name === 'TimeoutError') return name;
252
+ return 'unknown';
253
+ }
254
+
255
+ function startRunnerHeartbeatSupervisor() {
256
+ if (heartbeatTimer || heartbeatStopped || settled) return;
257
+ const config = readRunnerHeartbeatConfig();
258
+ if (!config) return;
259
+ let handoff = null;
260
+ try {
261
+ handoff = JSON.parse(fs.readFileSync(runtimeStartedPath, 'utf8'));
262
+ } catch {}
263
+ const handoffAt = Number(handoff && handoff.lastHeartbeatAtMs);
264
+ heartbeatLastSuccessAtMs = Number.isFinite(handoffAt) ? handoffAt : Date.now();
265
+ const cadenceMs = Math.max(250, Math.floor(config.leaseMs / ${RUNTIME_CLAIM_HEARTBEAT_DIVISOR}));
266
+ heartbeatStopBeforeLeaseMs = Math.max(
267
+ 0,
268
+ config.leaseMs - cadenceMs - ${RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS},
269
+ );
270
+ process.stderr.write(
271
+ '[play-runner.heartbeat] watchdog_owner_started ' +
272
+ JSON.stringify({ runId: config.runId, cadenceMs, handoffHeartbeatAgeMs: Math.max(0, Date.now() - heartbeatLastSuccessAtMs) }) +
273
+ '\\n',
274
+ );
275
+ void runnerHeartbeatTick();
276
+ heartbeatTimer = setInterval(runnerHeartbeatTick, cadenceMs);
277
+ heartbeatTimer.unref?.();
278
+ }
279
+
280
+ async function runnerHeartbeatTick() {
281
+ if (heartbeatStopped || heartbeatInFlight || settled || !child || child.exitCode !== null) return;
282
+ const config = readRunnerHeartbeatConfig();
283
+ if (!config) return;
284
+ heartbeatInFlight = true;
285
+ heartbeatAttempt += 1;
286
+ const startedAt = Date.now();
287
+ const controller = new AbortController();
288
+ heartbeatController = controller;
289
+ const timeout = setTimeout(() => controller.abort(), ${RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS});
290
+ try {
291
+ const response = await fetch(
292
+ config.gatewayBaseUrl + '/api/v2/plays/runtime',
293
+ {
294
+ method: 'POST',
295
+ headers: {
296
+ 'content-type': 'application/json',
297
+ authorization: 'Bearer ' + config.executorToken,
298
+ ${JSON.stringify(PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER)}: require('node:crypto').randomUUID(),
299
+ },
300
+ body: JSON.stringify({ action: 'runner_heartbeat', runId: config.runId }),
301
+ redirect: 'manual',
302
+ signal: controller.signal,
303
+ },
304
+ );
305
+ if (response.status >= 300 && response.status < 400) {
306
+ await response.body?.cancel().catch(() => undefined);
307
+ throw Object.assign(new Error('gateway_redirect'), { code: 'redirect_' + response.status });
308
+ }
309
+ if (!response.ok) {
310
+ await response.body?.cancel().catch(() => undefined);
311
+ throw Object.assign(new Error('gateway_http_error'), { code: 'http_' + response.status });
312
+ }
313
+ const body = await response.json().catch(() => null);
314
+ if (body && body.ok === false && ['cancelled', 'failed', 'completed'].includes(body.terminal)) {
315
+ process.stderr.write(
316
+ '[play-runner.heartbeat] watchdog_owner_terminal ' +
317
+ JSON.stringify({ runId: config.runId, terminal: body.terminal, attempt: heartbeatAttempt }) +
318
+ '\\n',
319
+ );
320
+ stopRunnerHeartbeat();
321
+ stopChild('runner_scheduler_terminal', { terminal: body.terminal, heartbeatAttempt });
322
+ return;
323
+ }
324
+ if (!body || body.ok !== true || (body.schedulerStatus !== undefined && !['queued', 'running', 'waiting'].includes(body.schedulerStatus))) {
325
+ throw Object.assign(new Error('gateway_protocol_error'), { code: 'protocol_error' });
326
+ }
327
+ heartbeatLastSuccessAtMs = Date.now();
328
+ heartbeatLastErrorCode = null;
329
+ } catch (error) {
330
+ if (heartbeatStopped || settled) return;
331
+ heartbeatLastErrorCode = controller.signal.aborted
332
+ ? 'TimeoutError'
333
+ : runnerHeartbeatErrorCode(error);
334
+ const heartbeatAgeMs = Math.max(0, Date.now() - heartbeatLastSuccessAtMs);
335
+ process.stderr.write(
336
+ '[play-runner.heartbeat] watchdog_owner_retrying ' +
337
+ JSON.stringify({ runId: config.runId, attempt: heartbeatAttempt, durationMs: Math.max(0, Date.now() - startedAt), heartbeatAgeMs, errorCode: heartbeatLastErrorCode }) +
338
+ '\\n',
339
+ );
340
+ // Stop before the scheduler's lease expires. A failed request can consume
341
+ // its full timeout, and the next cadence would otherwise let the scheduler
342
+ // win the terminal race and report an ambiguous runner loss.
343
+ if (heartbeatAgeMs >= heartbeatStopBeforeLeaseMs) {
344
+ stopRunnerHeartbeat();
345
+ const diagnostic = {
346
+ heartbeatAttempt,
347
+ heartbeatAgeMs,
348
+ leaseMs: config.leaseMs,
349
+ stopBeforeLeaseMs: heartbeatStopBeforeLeaseMs,
350
+ lastErrorCode: heartbeatLastErrorCode,
351
+ };
352
+ process.stderr.write(
353
+ 'RUNTIME_CONTROL_PLANE_UNAVAILABLE: ' + JSON.stringify(diagnostic) + '\\n',
354
+ );
355
+ stopChild('runner_heartbeat_unreachable', diagnostic);
356
+ }
357
+ } finally {
358
+ clearTimeout(timeout);
359
+ if (heartbeatController === controller) heartbeatController = null;
360
+ heartbeatInFlight = false;
361
+ }
362
+ }
188
363
 
189
364
  function writeTerminationDiagnostic(reason, details = {}) {
190
365
  if (!terminationDiagnosticPath) return;
@@ -204,6 +379,8 @@ function writeTerminationDiagnostic(reason, details = {}) {
204
379
  }
205
380
 
206
381
  function stopChild(reason, details) {
382
+ if (settled || stopRequested) return;
383
+ stopRequested = true;
207
384
  writeTerminationDiagnostic(reason, details);
208
385
  child.kill('SIGTERM');
209
386
  forceKillTimer = setTimeout(() => child.kill('SIGKILL'), ${RUNNER_TERMINATION_KILL_GRACE_MS});
@@ -391,7 +568,10 @@ function armTerminalGrace() {
391
568
  }
392
569
 
393
570
  const runtimeBoundaryPoll = setInterval(() => {
394
- if (fs.existsSync(runtimeStartedPath)) armRuntimeDeadline();
571
+ if (fs.existsSync(runtimeStartedPath)) {
572
+ armRuntimeDeadline();
573
+ startRunnerHeartbeatSupervisor();
574
+ }
395
575
  if (fs.existsSync(runtimeCompletedPath)) armTerminalGrace();
396
576
  }, 25);
397
577
  const startupTimer = setTimeout(() => {
@@ -413,6 +593,7 @@ function finish(code) {
413
593
  settled = true;
414
594
  clearInterval(runtimeBoundaryPoll);
415
595
  clearInterval(memoryWatch);
596
+ stopRunnerHeartbeat();
416
597
  clearTimeout(startupTimer);
417
598
  if (runtimeTimer) clearTimeout(runtimeTimer);
418
599
  if (terminalGraceTimer) clearTimeout(terminalGraceTimer);
@@ -13891,24 +13891,33 @@ export async function upsertPostgresSchedulerObservation(
13891
13891
  /** Read every observation for a run/attempt, ordered for deterministic folds. */
13892
13892
  export async function getPostgresSchedulerObservations(
13893
13893
  client: PostgresSchedulerQueryClient,
13894
- input: { runId: string; attempt?: number | null },
13894
+ input: {
13895
+ runId: string;
13896
+ attempt?: number | null;
13897
+ /** Return only the newest settled invocation for each node. */
13898
+ latestSettledPerNode?: boolean;
13899
+ },
13895
13900
  options: PostgresSchedulerReceiptOptions,
13896
13901
  ): Promise<DocflowObservationRecord[]> {
13897
13902
  const hasAttempt = input.attempt != null;
13903
+ const latestSettledPerNode = input.latestSettledPerNode === true;
13898
13904
  const params: unknown[] = [receiptOrgId(options), input.runId];
13899
13905
  if (hasAttempt) {
13900
13906
  params.push(normalizePostgresSchedulerReceiptRunAttempt(input.attempt));
13901
13907
  }
13902
13908
  const result = await client.query<ObservationRow>(
13903
13909
  `
13904
- SELECT run_id, attempt, node_id, invocation_seq, invocation_id, status,
13910
+ SELECT ${latestSettledPerNode ? 'DISTINCT ON (node_id)' : ''}
13911
+ run_id, attempt, node_id, invocation_seq, invocation_id, status,
13905
13912
  inputs_json, outputs_json, inputs_truncated, outputs_truncated,
13906
13913
  error, started_at, settled_at
13907
13914
  FROM ${tableName('observations', options)}
13908
13915
  WHERE org_id = $1
13909
13916
  AND run_id = $2
13910
13917
  ${hasAttempt ? 'AND attempt = $3' : ''}
13911
- ORDER BY node_id, attempt, invocation_seq
13918
+ ${latestSettledPerNode ? "AND status IN ('completed', 'failed')" : ''}
13919
+ ORDER BY node_id, attempt ${latestSettledPerNode ? 'DESC' : ''},
13920
+ invocation_seq ${latestSettledPerNode ? 'DESC' : ''}
13912
13921
  `,
13913
13922
  params,
13914
13923
  );
@@ -219,11 +219,14 @@ export function runtimeSheetPageTailHoldCompletedEvent(
219
219
  tableNamespace: string,
220
220
  ): PlayRunnerEvent | null {
221
221
  if (!Number.isInteger(holdMs) || (holdMs ?? 0) <= 0) return null;
222
+ const at = new Date().toISOString();
222
223
  return {
223
224
  type: 'log',
224
- at: new Date().toISOString(),
225
+ at,
225
226
  source: 'play',
226
- line: `[runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
227
+ // The status projection keeps the line but drops the event envelope's
228
+ // timestamp, so include the emission time for runtime-preview diagnostics.
229
+ line: `[${at}] [runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
227
230
  };
228
231
  }
229
232
 
@@ -91,6 +91,23 @@ export type DeploymentPlayState = {
91
91
  liveRevision: DeploymentPlayState['workingRevision'];
92
92
  };
93
93
 
94
+ export type DeploymentMonitorEntry = {
95
+ /** Repository-relative source path, including the linked target's root. */
96
+ file: string;
97
+ /** The monitor API owns definition validation. */
98
+ definition: unknown;
99
+ };
100
+
101
+ export type DeploymentMonitorOutcome = {
102
+ key: string;
103
+ file: string;
104
+ status: 'planned' | 'applying' | 'applied' | 'stopped' | 'failed';
105
+ jobId?: string;
106
+ warning?: string;
107
+ error?: string;
108
+ credits?: number | null;
109
+ };
110
+
94
111
  export class DeployApiClient {
95
112
  constructor(
96
113
  private readonly config: {
@@ -223,6 +240,29 @@ export class DeployApiClient {
223
240
  });
224
241
  }
225
242
 
243
+ monitors(
244
+ action: 'plan' | 'apply',
245
+ entries: DeploymentMonitorEntry[],
246
+ ): Promise<{ monitors: DeploymentMonitorOutcome[] }> {
247
+ return this.request({
248
+ method: 'POST',
249
+ path: this.deploymentPath('/monitors'),
250
+ body: { action, entries },
251
+ });
252
+ }
253
+
254
+ monitorJob(
255
+ jobId: string,
256
+ monitorKey: string,
257
+ ): Promise<{ monitors: DeploymentMonitorOutcome[] }> {
258
+ return this.request({
259
+ method: 'GET',
260
+ path: this.deploymentPath(
261
+ `/monitors?jobId=${encodeURIComponent(jobId)}&monitorKey=${encodeURIComponent(monitorKey)}`,
262
+ ),
263
+ });
264
+ }
265
+
226
266
  compileManifest(input: {
227
267
  name: string;
228
268
  sourceCode: string;
@@ -4,19 +4,22 @@ import picomatch from 'picomatch';
4
4
 
5
5
  /**
6
6
  * deepline.json — the repo-owned half of deploy configuration. The repo
7
- * describes what plays exist and where; Convex (playProjects) decides what
7
+ * describes what plays and monitors exist and where; Convex (playProjects) decides what
8
8
  * happens when they change (production branch, PR checks, enable/disable).
9
9
  * No field lives in both places.
10
10
  */
11
11
  export type DeeplineRepoManifest = {
12
12
  /** Globs relative to the project root directory. */
13
13
  plays: string[];
14
+ /** Monitor JSON files to discover, relative to the project root directory. */
15
+ monitors: string[];
14
16
  /** Optional tenant guard: the workspace slug this repo expects. */
15
17
  org?: string;
16
18
  };
17
19
 
18
20
  export const DEFAULT_PLAY_GLOBS = ['**/*.play.ts'];
19
- const MAX_DISCOVERED_PLAY_FILES = 200;
21
+ export const DEFAULT_MONITOR_GLOBS = ['**/*.monitor.json'];
22
+ const MAX_DISCOVERED_FILES = 200;
20
23
  const IGNORED_DIRECTORIES = new Set([
21
24
  'node_modules',
22
25
  '.git',
@@ -32,11 +35,33 @@ export class DeeplineManifestError extends Error {
32
35
  }
33
36
  }
34
37
 
38
+ function readGlobs(value: unknown, field: 'plays' | 'monitors'): string[] {
39
+ if (
40
+ !Array.isArray(value) ||
41
+ (field === 'plays' && value.length === 0) ||
42
+ !value.every((glob) => typeof glob === 'string' && glob.trim().length > 0)
43
+ ) {
44
+ throw new DeeplineManifestError(
45
+ `deepline.json "${field}" must be ${field === 'plays' ? 'a non-empty' : 'an'} array of glob strings.`,
46
+ );
47
+ }
48
+ const globs = value.map((glob: string) => glob.trim().replace(/^\.\//, ''));
49
+ if (globs.some((glob) => glob.startsWith('/') || glob.includes('..'))) {
50
+ throw new DeeplineManifestError(
51
+ `deepline.json "${field}" globs must be relative and must not contain "..".`,
52
+ );
53
+ }
54
+ return globs;
55
+ }
56
+
35
57
  export function parseDeeplineManifest(
36
58
  raw: string | null,
37
59
  ): DeeplineRepoManifest {
38
60
  if (raw === null) {
39
- return { plays: DEFAULT_PLAY_GLOBS };
61
+ return {
62
+ plays: DEFAULT_PLAY_GLOBS,
63
+ monitors: DEFAULT_MONITOR_GLOBS,
64
+ };
40
65
  }
41
66
  let parsed: unknown;
42
67
  try {
@@ -49,32 +74,28 @@ export function parseDeeplineManifest(
49
74
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
50
75
  throw new DeeplineManifestError('deepline.json must be a JSON object.');
51
76
  }
52
- const record = parsed as { plays?: unknown; org?: unknown };
53
- let plays = DEFAULT_PLAY_GLOBS;
54
- if (record.plays !== undefined) {
55
- if (
56
- !Array.isArray(record.plays) ||
57
- record.plays.length === 0 ||
58
- !record.plays.every(
59
- (glob) => typeof glob === 'string' && glob.trim().length > 0,
60
- )
61
- ) {
62
- throw new DeeplineManifestError(
63
- 'deepline.json "plays" must be a non-empty array of glob strings, e.g. ["plays/**/*.play.ts"].',
64
- );
65
- }
66
- plays = record.plays.map((glob) => glob.trim().replace(/^\.\//, ''));
67
- }
68
- if (plays.some((glob) => glob.startsWith('/') || glob.includes('..'))) {
69
- throw new DeeplineManifestError(
70
- 'deepline.json "plays" globs must be relative and must not contain "..".',
71
- );
72
- }
77
+ const record = parsed as {
78
+ plays?: unknown;
79
+ monitors?: unknown;
80
+ org?: unknown;
81
+ };
82
+ const plays =
83
+ record.plays === undefined
84
+ ? DEFAULT_PLAY_GLOBS
85
+ : readGlobs(record.plays, 'plays');
86
+ const monitors =
87
+ record.monitors === undefined
88
+ ? DEFAULT_MONITOR_GLOBS
89
+ : readGlobs(record.monitors, 'monitors');
73
90
  const org =
74
91
  typeof record.org === 'string' && record.org.trim()
75
92
  ? record.org.trim()
76
93
  : undefined;
77
- return { plays, ...(org ? { org } : {}) };
94
+ return {
95
+ plays,
96
+ monitors,
97
+ ...(org ? { org } : {}),
98
+ };
78
99
  }
79
100
 
80
101
  export const DEFAULT_MANIFEST_FILE = 'deepline.json';
@@ -121,7 +142,7 @@ export async function readDeeplineManifest(
121
142
  * node_modules or fixtures publishes garbage, so vendored/build directories
122
143
  * are always skipped and discovery is capped loudly.
123
144
  */
124
- export async function discoverPlayFiles(input: {
145
+ export async function discoverDeploymentFiles(input: {
125
146
  projectRoot: string;
126
147
  globs: string[];
127
148
  }): Promise<string[]> {
@@ -142,9 +163,9 @@ export async function discoverPlayFiles(input: {
142
163
  const relativePath = relative(input.projectRoot, absolute);
143
164
  if (matchers.some((match) => match(relativePath))) {
144
165
  found.push(relativePath);
145
- if (found.length > MAX_DISCOVERED_PLAY_FILES) {
166
+ if (found.length > MAX_DISCOVERED_FILES) {
146
167
  throw new DeeplineManifestError(
147
- `deepline.json globs matched more than ${MAX_DISCOVERED_PLAY_FILES} play files. Tighten the globs.`,
168
+ `deepline.json globs matched more than ${MAX_DISCOVERED_FILES} files. Tighten the globs.`,
148
169
  );
149
170
  }
150
171
  }
@@ -160,3 +181,5 @@ export async function discoverPlayFiles(input: {
160
181
  await walk(input.projectRoot);
161
182
  return found.sort();
162
183
  }
184
+
185
+ export const discoverPlayFiles = discoverDeploymentFiles;
@@ -0,0 +1,52 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type {
4
+ DeploymentMonitorEntry,
5
+ DeploymentMonitorOutcome,
6
+ } from './api-client';
7
+ import { DeeplineManifestError, discoverDeploymentFiles } from './manifest';
8
+
9
+ export async function readMonitorFiles(input: {
10
+ projectRoot: string;
11
+ rootDirectory: string;
12
+ globs: string[];
13
+ }): Promise<DeploymentMonitorEntry[]> {
14
+ const files = await discoverDeploymentFiles(input);
15
+ if (files.length > 100) {
16
+ throw new DeeplineManifestError(
17
+ 'A deployment may contain at most 100 monitor definitions. Split the repository into multiple linked targets.',
18
+ );
19
+ }
20
+ return Promise.all(
21
+ files.map(async (file) => {
22
+ const sourcePath = input.rootDirectory
23
+ ? `${input.rootDirectory}/${file}`
24
+ : file;
25
+ const raw = await readFile(join(input.projectRoot, file), 'utf8');
26
+ try {
27
+ return { file: sourcePath, definition: JSON.parse(raw) as unknown };
28
+ } catch {
29
+ throw new DeeplineManifestError(
30
+ `${sourcePath} must contain a monitor definition as valid JSON.`,
31
+ );
32
+ }
33
+ }),
34
+ );
35
+ }
36
+
37
+ export function monitorDeploymentSummary(
38
+ outcomes: DeploymentMonitorOutcome[],
39
+ ): string {
40
+ return outcomes
41
+ .map(
42
+ (outcome) =>
43
+ `- Monitor \`${outcome.key}\` (\`${outcome.file}\`): ${outcome.status}${
44
+ outcome.credits != null
45
+ ? ` (${outcome.credits} Deepline credits)`
46
+ : ''
47
+ }${outcome.jobId ? ` — job \`${outcome.jobId}\`` : ''}${
48
+ outcome.error ? `\n - ${outcome.error}` : ''
49
+ }${outcome.warning ? `\n - :warning: ${outcome.warning}` : ''}`,
50
+ )
51
+ .join('\n');
52
+ }