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.
- package/dist/bundling-sources/sdk/src/client.ts +122 -32
- package/dist/bundling-sources/sdk/src/compat.ts +2 -0
- package/dist/bundling-sources/sdk/src/deployment-protection.ts +19 -0
- package/dist/bundling-sources/sdk/src/http.ts +3 -7
- package/dist/bundling-sources/sdk/src/index.ts +7 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/types.ts +25 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +18 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +33 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +11 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +53 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +187 -6
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +12 -3
- package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +5 -2
- package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +40 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +51 -28
- package/dist/bundling-sources/shared_libs/plays/deploy/monitor-files.ts +52 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/run-deployment.ts +146 -19
- package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +176 -0
- package/dist/cli/index.js +1046 -416
- package/dist/cli/index.mjs +894 -264
- package/dist/index.d.mts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +67 -35
- package/dist/index.mjs +67 -35
- package/dist/install-integrity.json +3 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- 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
|
|
120
|
-
* sandbox memory envelope outside the customer-code Node
|
|
121
|
-
* synchronous infinite loop can block the runner's
|
|
122
|
-
* block this separate process; nor can a runner whose heap is
|
|
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))
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
225
|
+
at,
|
|
225
226
|
source: 'play',
|
|
226
|
-
line
|
|
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
|
|
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 {
|
|
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 {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 {
|
|
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
|
|
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 >
|
|
166
|
+
if (found.length > MAX_DISCOVERED_FILES) {
|
|
146
167
|
throw new DeeplineManifestError(
|
|
147
|
-
`deepline.json globs matched more than ${
|
|
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
|
+
}
|