deepline 0.1.0 → 0.1.2

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 (97) hide show
  1. package/dist/cli/index.js +212 -54
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/cli/index.mjs +198 -40
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/index.d.mts +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.mjs +1 -1
  9. package/dist/repo/apps/play-runner-workers/src/coordinator-entry.ts +3256 -0
  10. package/dist/repo/apps/play-runner-workers/src/dedup-do.ts +710 -0
  11. package/dist/repo/apps/play-runner-workers/src/entry.ts +5070 -0
  12. package/dist/repo/apps/play-runner-workers/src/runtime/README.md +21 -0
  13. package/dist/repo/apps/play-runner-workers/src/runtime/batching.ts +177 -0
  14. package/dist/repo/apps/play-runner-workers/src/runtime/execution-plan.ts +52 -0
  15. package/dist/repo/apps/play-runner-workers/src/runtime/tool-batch.ts +100 -0
  16. package/dist/repo/apps/play-runner-workers/src/runtime/tool-result.ts +184 -0
  17. package/dist/repo/sdk/src/cli/commands/auth.ts +482 -0
  18. package/dist/repo/sdk/src/cli/commands/billing.ts +188 -0
  19. package/dist/repo/sdk/src/cli/commands/csv.ts +123 -0
  20. package/dist/repo/sdk/src/cli/commands/db.ts +119 -0
  21. package/dist/repo/sdk/src/cli/commands/feedback.ts +40 -0
  22. package/dist/repo/sdk/src/cli/commands/org.ts +117 -0
  23. package/dist/repo/sdk/src/cli/commands/play.ts +3200 -0
  24. package/dist/repo/sdk/src/cli/commands/tools.ts +687 -0
  25. package/dist/repo/sdk/src/cli/dataset-stats.ts +341 -0
  26. package/dist/repo/sdk/src/cli/index.ts +138 -0
  27. package/dist/repo/sdk/src/cli/progress.ts +135 -0
  28. package/dist/repo/sdk/src/cli/trace.ts +61 -0
  29. package/dist/repo/sdk/src/cli/utils.ts +145 -0
  30. package/dist/repo/sdk/src/client.ts +1188 -0
  31. package/dist/repo/sdk/src/compat.ts +77 -0
  32. package/dist/repo/sdk/src/config.ts +285 -0
  33. package/dist/repo/sdk/src/errors.ts +125 -0
  34. package/dist/repo/sdk/src/http.ts +391 -0
  35. package/dist/repo/sdk/src/index.ts +139 -0
  36. package/dist/repo/sdk/src/play.ts +1330 -0
  37. package/dist/repo/sdk/src/plays/bundle-play-file.ts +133 -0
  38. package/dist/repo/sdk/src/plays/harness-stub.ts +210 -0
  39. package/dist/repo/sdk/src/plays/local-file-discovery.ts +326 -0
  40. package/dist/repo/sdk/src/tool-output.ts +489 -0
  41. package/dist/repo/sdk/src/types.ts +669 -0
  42. package/dist/repo/sdk/src/version.ts +2 -0
  43. package/dist/repo/sdk/src/worker-play-entry.ts +286 -0
  44. package/dist/repo/shared_libs/observability/node-tracing.ts +129 -0
  45. package/dist/repo/shared_libs/observability/tracing.ts +98 -0
  46. package/dist/repo/shared_libs/play-runtime/backend.ts +139 -0
  47. package/dist/repo/shared_libs/play-runtime/batch-runtime.ts +182 -0
  48. package/dist/repo/shared_libs/play-runtime/batching-types.ts +91 -0
  49. package/dist/repo/shared_libs/play-runtime/context.ts +3999 -0
  50. package/dist/repo/shared_libs/play-runtime/coordinator-headers.ts +78 -0
  51. package/dist/repo/shared_libs/play-runtime/ctx-contract.ts +250 -0
  52. package/dist/repo/shared_libs/play-runtime/ctx-types.ts +713 -0
  53. package/dist/repo/shared_libs/play-runtime/dataset-id.ts +10 -0
  54. package/dist/repo/shared_libs/play-runtime/db-session-crypto.ts +304 -0
  55. package/dist/repo/shared_libs/play-runtime/db-session.ts +462 -0
  56. package/dist/repo/shared_libs/play-runtime/dedup-backend.ts +0 -0
  57. package/dist/repo/shared_libs/play-runtime/default-batch-strategies.ts +124 -0
  58. package/dist/repo/shared_libs/play-runtime/execution-plan.ts +262 -0
  59. package/dist/repo/shared_libs/play-runtime/live-events.ts +214 -0
  60. package/dist/repo/shared_libs/play-runtime/live-state-contract.ts +50 -0
  61. package/dist/repo/shared_libs/play-runtime/map-execution-frame.ts +114 -0
  62. package/dist/repo/shared_libs/play-runtime/map-row-identity.ts +158 -0
  63. package/dist/repo/shared_libs/play-runtime/profiles.ts +90 -0
  64. package/dist/repo/shared_libs/play-runtime/progress-emitter.ts +172 -0
  65. package/dist/repo/shared_libs/play-runtime/protocol.ts +121 -0
  66. package/dist/repo/shared_libs/play-runtime/public-play-contract.ts +42 -0
  67. package/dist/repo/shared_libs/play-runtime/result-normalization.ts +33 -0
  68. package/dist/repo/shared_libs/play-runtime/runtime-actions.ts +208 -0
  69. package/dist/repo/shared_libs/play-runtime/runtime-api.ts +1873 -0
  70. package/dist/repo/shared_libs/play-runtime/runtime-constraints.ts +2 -0
  71. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +201 -0
  72. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-pg.ts +48 -0
  73. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver.ts +84 -0
  74. package/dist/repo/shared_libs/play-runtime/scheduler-backend.ts +174 -0
  75. package/dist/repo/shared_libs/play-runtime/static-pipeline-types.ts +147 -0
  76. package/dist/repo/shared_libs/play-runtime/suspension.ts +68 -0
  77. package/dist/repo/shared_libs/play-runtime/tool-batch-executor.ts +146 -0
  78. package/dist/repo/shared_libs/play-runtime/tool-result.ts +387 -0
  79. package/dist/repo/shared_libs/play-runtime/tracing.ts +31 -0
  80. package/dist/repo/shared_libs/play-runtime/waterfall-replay.ts +75 -0
  81. package/dist/repo/shared_libs/play-runtime/worker-api-types.ts +140 -0
  82. package/dist/repo/shared_libs/plays/artifact-transport.ts +14 -0
  83. package/dist/repo/shared_libs/plays/artifact-types.ts +49 -0
  84. package/dist/repo/shared_libs/plays/bundling/index.ts +1346 -0
  85. package/dist/repo/shared_libs/plays/compiler-manifest.ts +186 -0
  86. package/dist/repo/shared_libs/plays/contracts.ts +51 -0
  87. package/dist/repo/shared_libs/plays/dataset.ts +308 -0
  88. package/dist/repo/shared_libs/plays/definition.ts +264 -0
  89. package/dist/repo/shared_libs/plays/file-refs.ts +11 -0
  90. package/dist/repo/shared_libs/plays/rate-limit-scheduler.ts +206 -0
  91. package/dist/repo/shared_libs/plays/resolve-static-pipeline.ts +164 -0
  92. package/dist/repo/shared_libs/plays/row-identity.ts +302 -0
  93. package/dist/repo/shared_libs/plays/runtime-validation.ts +415 -0
  94. package/dist/repo/shared_libs/plays/static-pipeline.ts +560 -0
  95. package/dist/repo/shared_libs/temporal/constants.ts +39 -0
  96. package/dist/repo/shared_libs/temporal/preview-config.ts +153 -0
  97. package/package.json +4 -4
@@ -0,0 +1,3200 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ realpathSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ import { Command, Option } from 'commander';
11
+ import { DeeplineClient, type PlayStatus } from '../../client.js';
12
+ import { DeeplineError } from '../../errors.js';
13
+ import {
14
+ bundlePlayFile,
15
+ extractDefinedPlayName,
16
+ type BundledPlayFileSuccess,
17
+ } from '../../plays/bundle-play-file.js';
18
+ import type { PlayStagedFileRef } from '../../plays/local-file-discovery.js';
19
+ import type {
20
+ PlayDescription,
21
+ PlayDetail,
22
+ PlayLiveEvent,
23
+ PlayListItem,
24
+ PlayRevisionSummary,
25
+ PlayRunListItem,
26
+ } from '../../types.js';
27
+ import {
28
+ buildDatasetStats,
29
+ extractCanonicalRowsInfo,
30
+ writeCanonicalRowsCsv,
31
+ type CanonicalRowsInfo,
32
+ type DatasetStats,
33
+ } from '../dataset-stats.js';
34
+ import {
35
+ createCliProgress,
36
+ getActiveCliProgress,
37
+ type CliProgress,
38
+ } from '../progress.js';
39
+ import { recordCliTrace, traceCliSpan } from '../trace.js';
40
+ import { argsWantJson } from '../utils.js';
41
+
42
+ type PlayRunCommandOptions = {
43
+ target: { kind: 'file'; path: string } | { kind: 'name'; name: string };
44
+ csvPath: string | null;
45
+ input: Record<string, unknown> | null;
46
+ revisionId: string | null;
47
+ revisionSelector: 'live' | 'latest' | null;
48
+ watch: boolean;
49
+ emitLogs: boolean;
50
+ jsonOutput: boolean;
51
+ pollIntervalMs: number;
52
+ waitTimeoutMs: number | null;
53
+ force: boolean;
54
+ outPath: string | null;
55
+ };
56
+
57
+ type PlayCheckCommandOptions = {
58
+ target: string;
59
+ jsonOutput: boolean;
60
+ };
61
+
62
+ type PlaySearchOptions = {
63
+ query: string;
64
+ jsonOutput: boolean;
65
+ compact: boolean;
66
+ origin: 'prebuilt' | 'owned' | undefined;
67
+ };
68
+
69
+ function parseReferencedPlayTarget(target: string): {
70
+ ownerSlug: string | null;
71
+ playName: string;
72
+ unqualifiedPlayName: string;
73
+ } {
74
+ const trimmed = target.trim();
75
+ const slashIndex = trimmed.indexOf('/');
76
+ if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
77
+ return { ownerSlug: null, playName: trimmed, unqualifiedPlayName: trimmed };
78
+ }
79
+ return {
80
+ ownerSlug: trimmed.slice(0, slashIndex),
81
+ playName: trimmed,
82
+ unqualifiedPlayName: trimmed.slice(slashIndex + 1),
83
+ };
84
+ }
85
+
86
+ function isPrebuiltReferenceTarget(target: string): boolean {
87
+ return target.trim().toLowerCase().startsWith('prebuilt/');
88
+ }
89
+
90
+ function buildBarePrebuiltReferenceError(input: {
91
+ requested: string;
92
+ reference: string;
93
+ }): Error {
94
+ return new Error(
95
+ `Prebuilt play "${input.requested}" must be referenced as "${input.reference}". ` +
96
+ `Use the prebuilt/ namespace anywhere you run, describe, get, or link to Deepline-managed plays.`,
97
+ );
98
+ }
99
+
100
+ async function assertCanonicalNamedPlayReference(
101
+ client: DeeplineClient,
102
+ target: string,
103
+ ): Promise<PlayDetail> {
104
+ const parsed = parseReferencedPlayTarget(target);
105
+ const detail = await client.getPlay(parsed.playName);
106
+ if (
107
+ detail.play.ownerType === 'deepline' &&
108
+ !isPrebuiltReferenceTarget(target)
109
+ ) {
110
+ throw buildBarePrebuiltReferenceError({
111
+ requested: target,
112
+ reference: formatPlayReference(detail.play),
113
+ });
114
+ }
115
+ return detail;
116
+ }
117
+
118
+ function formatPlayReference(
119
+ play: Pick<PlayDetail['play'], 'reference' | 'ownerSlug' | 'name'>,
120
+ ): string {
121
+ if (play.reference) {
122
+ return play.reference;
123
+ }
124
+ const ownerSlug = play.ownerSlug?.trim() || 'org';
125
+ return `${ownerSlug}/${play.name}`;
126
+ }
127
+
128
+ function formatPlayListReference(play: PlayListItem | PlayDescription): string {
129
+ return play.reference || play.name;
130
+ }
131
+
132
+ function defaultMaterializedPlayPath(reference: string): string {
133
+ const playName = parseReferencedPlayTarget(reference).unqualifiedPlayName;
134
+ const safeName = playName
135
+ .trim()
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9-]/g, '-')
138
+ .replace(/-+/g, '-')
139
+ .replace(/^-|-$/g, '');
140
+ return resolve(`${safeName || 'play'}.play.ts`);
141
+ }
142
+
143
+ type MaterializedRemotePlaySource = {
144
+ path: string;
145
+ status: 'created' | 'updated' | 'unchanged';
146
+ created: boolean;
147
+ };
148
+
149
+ function materializeRemotePlaySource(input: {
150
+ target: string;
151
+ playName: string;
152
+ sourceCode: string;
153
+ outPath: string | null;
154
+ }): MaterializedRemotePlaySource | null {
155
+ if (isFileTarget(input.target)) {
156
+ return null;
157
+ }
158
+ if (!input.sourceCode.trim()) {
159
+ return null;
160
+ }
161
+
162
+ const outputPath =
163
+ input.outPath ?? defaultMaterializedPlayPath(input.playName);
164
+ if (existsSync(outputPath)) {
165
+ const existingSource = readFileSync(outputPath, 'utf-8');
166
+ if (existingSource === input.sourceCode) {
167
+ return { path: outputPath, status: 'unchanged', created: false };
168
+ }
169
+ writeFileSync(outputPath, input.sourceCode, 'utf-8');
170
+ return { path: outputPath, status: 'updated', created: false };
171
+ }
172
+
173
+ writeFileSync(outputPath, input.sourceCode, 'utf-8');
174
+ return { path: outputPath, status: 'created', created: true };
175
+ }
176
+
177
+ function formatLoadedPlayMessage(
178
+ materializedFile: MaterializedRemotePlaySource,
179
+ ): string {
180
+ if (materializedFile.status === 'unchanged') {
181
+ return `Loaded play here: ${materializedFile.path} (unchanged)`;
182
+ }
183
+ if (materializedFile.status === 'updated') {
184
+ return `Loaded play here: ${materializedFile.path} (updated)`;
185
+ }
186
+ return `Loaded play here: ${materializedFile.path}`;
187
+ }
188
+
189
+ function buildReadonlyPrebuiltPlayError(reference: string): Error {
190
+ return new Error(
191
+ `Cannot edit or push ${reference} because Deepline prebuilt plays are read-only.\n` +
192
+ `To make your own version:\n` +
193
+ `1. Copy the source into a new local file.\n` +
194
+ `2. Change definePlay('${reference.split('/').slice(1).join('/')}', ...) to a new play name you own.\n` +
195
+ `3. Run: deepline plays publish <your-file.play.ts>\n` +
196
+ `4. Your play will then live under your workspace namespace.`,
197
+ );
198
+ }
199
+
200
+ async function ensureEditableRemotePlay(
201
+ client: DeeplineClient,
202
+ target: string,
203
+ ): Promise<PlayDetail> {
204
+ const parsed = parseReferencedPlayTarget(target);
205
+ const detail = await client.getPlay(parsed.playName);
206
+ if (detail.play.ownerType === 'deepline') {
207
+ throw buildReadonlyPrebuiltPlayError(formatPlayReference(detail.play));
208
+ }
209
+ return detail;
210
+ }
211
+
212
+ function buildMissingDefinePlayError(filePath: string): Error {
213
+ return new Error(
214
+ `Play file ${filePath} must export definePlay('play-name', async (...) => { ... }). ` +
215
+ 'Plain `export default async function ...` plays are no longer supported for SDK/CLI file-backed runs.',
216
+ );
217
+ }
218
+
219
+ function extractPlayName(code: string, filePath: string): string {
220
+ const definedPlayName = extractDefinedPlayName(code, filePath);
221
+ if (definedPlayName) {
222
+ return definedPlayName;
223
+ }
224
+ throw buildMissingDefinePlayError(filePath);
225
+ }
226
+
227
+ function isFileTarget(target: string): boolean {
228
+ return existsSync(resolve(target));
229
+ }
230
+
231
+ function looksLikeFilePath(target: string): boolean {
232
+ if (target.trim().toLowerCase().startsWith('prebuilt/')) {
233
+ return false;
234
+ }
235
+ return (
236
+ target.includes('/') ||
237
+ target.includes('\\') ||
238
+ /\.(ts|js|mjs|play\.ts)$/.test(target)
239
+ );
240
+ }
241
+
242
+ function parsePositiveInteger(value: string, flagName: string): number {
243
+ const parsed = Number.parseInt(value, 10);
244
+ if (!Number.isFinite(parsed) || parsed <= 0) {
245
+ throw new Error(`${flagName} must be a positive integer.`);
246
+ }
247
+ return parsed;
248
+ }
249
+
250
+ function parseJsonInput(raw: string): Record<string, unknown> {
251
+ const source = raw.startsWith('@')
252
+ ? readFileSync(resolve(raw.slice(1)), 'utf-8')
253
+ : raw;
254
+ const parsed = JSON.parse(source);
255
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
256
+ throw new Error('--input must be a JSON object.');
257
+ }
258
+ return parsed as Record<string, unknown>;
259
+ }
260
+
261
+ function stageFile(logicalPath: string, absolutePath: string) {
262
+ const buffer = readFileSync(absolutePath);
263
+ return {
264
+ logicalPath,
265
+ contentBase64: buffer.toString('base64'),
266
+ contentHash: createHash('sha256').update(buffer).digest('hex'),
267
+ contentType: absolutePath.toLowerCase().endsWith('.csv')
268
+ ? 'text/csv'
269
+ : absolutePath.toLowerCase().endsWith('.json')
270
+ ? 'application/json'
271
+ : 'application/octet-stream',
272
+ bytes: buffer.byteLength,
273
+ };
274
+ }
275
+
276
+ function normalizePlayPath(filePath: string): string {
277
+ try {
278
+ return realpathSync.native(resolve(filePath));
279
+ } catch {
280
+ return resolve(filePath);
281
+ }
282
+ }
283
+
284
+ function formatBundlingErrors(filePath: string, errors: string[]): string {
285
+ return `Failed to bundle ${filePath}: ${errors.join('; ')}`;
286
+ }
287
+
288
+ function formatUnresolvedPackagedFiles(
289
+ filePath: string,
290
+ unresolvedFileReferences: BundledPlayFileSuccess['unresolvedFileReferences'],
291
+ ): string {
292
+ const details = unresolvedFileReferences
293
+ .map((unresolved) => `${unresolved.sourceFragment}: ${unresolved.message}`)
294
+ .join('; ');
295
+ return `Failed to package local ctx.csv(...) files in ${filePath}: ${details}`;
296
+ }
297
+
298
+ async function collectBundledPlayGraph(entryFile: string): Promise<{
299
+ root: BundledPlayFileSuccess;
300
+ nodes: Map<string, BundledPlayFileSuccess>;
301
+ }> {
302
+ const nodes = new Map<string, BundledPlayFileSuccess>();
303
+ const visiting = new Set<string>();
304
+
305
+ const visit = async (filePath: string): Promise<BundledPlayFileSuccess> => {
306
+ const absolutePath = normalizePlayPath(filePath);
307
+ const cached = nodes.get(absolutePath);
308
+ if (cached) {
309
+ return cached;
310
+ }
311
+ if (visiting.has(absolutePath)) {
312
+ throw new Error(
313
+ `Recursive imported play graph detected while bundling ${absolutePath}. ` +
314
+ 'Break the import cycle and compose plays with ctx.runPlay(...) instead.',
315
+ );
316
+ }
317
+
318
+ visiting.add(absolutePath);
319
+ try {
320
+ // Bundle target is hardcoded to esm_workers — workers_edge is the
321
+ // server-side default execution profile and the only deployable
322
+ // runtime. The legacy cjs_node20 target remains in the bundler for
323
+ // benchmarks that pass `--profile legacy`, but the CLI does not
324
+ // auto-select it.
325
+ const bundleResult = await bundlePlayFile(absolutePath, {
326
+ target: 'esm_workers',
327
+ });
328
+ if (bundleResult.success === false) {
329
+ throw new Error(
330
+ formatBundlingErrors(absolutePath, bundleResult.errors),
331
+ );
332
+ }
333
+ if (bundleResult.unresolvedFileReferences.length > 0) {
334
+ throw new Error(
335
+ formatUnresolvedPackagedFiles(
336
+ absolutePath,
337
+ bundleResult.unresolvedFileReferences,
338
+ ),
339
+ );
340
+ }
341
+
342
+ nodes.set(absolutePath, bundleResult);
343
+ for (const dependency of bundleResult.importedPlayDependencies) {
344
+ await visit(dependency.filePath);
345
+ }
346
+ return bundleResult;
347
+ } finally {
348
+ visiting.delete(absolutePath);
349
+ }
350
+ };
351
+
352
+ const root = await visit(entryFile);
353
+ return { root, nodes };
354
+ }
355
+
356
+ async function compileBundledPlayGraphManifests(
357
+ client: DeeplineClient,
358
+ graph: {
359
+ root: BundledPlayFileSuccess;
360
+ nodes: Map<string, BundledPlayFileSuccess>;
361
+ },
362
+ ): Promise<void> {
363
+ const compiling = new Map<string, Promise<void>>();
364
+ const compileNode = (node: BundledPlayFileSuccess): Promise<void> => {
365
+ const existing = compiling.get(node.filePath);
366
+ if (existing) return existing;
367
+
368
+ const promise = (async () => {
369
+ for (const dependency of node.importedPlayDependencies) {
370
+ const child = graph.nodes.get(normalizePlayPath(dependency.filePath));
371
+ if (!child) {
372
+ throw new Error(
373
+ `Missing bundled play graph node for imported dependency ${dependency.filePath}.`,
374
+ );
375
+ }
376
+ await compileNode(child);
377
+ }
378
+
379
+ const name =
380
+ node.playName ?? extractPlayName(node.sourceCode, node.filePath);
381
+ node.compilerManifest = await client.compilePlayManifest({
382
+ name,
383
+ sourceCode: node.sourceCode,
384
+ artifact: node.artifact,
385
+ importedPlayDependencies: node.importedPlayDependencies.map(
386
+ (dependency) => {
387
+ const child = graph.nodes.get(
388
+ normalizePlayPath(dependency.filePath),
389
+ );
390
+ if (!child?.compilerManifest) {
391
+ throw new Error(
392
+ `Missing compiler manifest for imported dependency ${dependency.filePath}.`,
393
+ );
394
+ }
395
+ return child.compilerManifest;
396
+ },
397
+ ),
398
+ });
399
+ })();
400
+
401
+ compiling.set(node.filePath, promise);
402
+ return promise;
403
+ };
404
+
405
+ await compileNode(graph.root);
406
+ }
407
+
408
+ function requireCompilerManifest(node: BundledPlayFileSuccess) {
409
+ if (!node.compilerManifest) {
410
+ throw new Error(`Missing compiler manifest for ${node.filePath}.`);
411
+ }
412
+ return node.compilerManifest;
413
+ }
414
+
415
+ async function publishImportedPlayDependencies(
416
+ client: DeeplineClient,
417
+ graph: {
418
+ root: BundledPlayFileSuccess;
419
+ nodes: Map<string, BundledPlayFileSuccess>;
420
+ },
421
+ ): Promise<void> {
422
+ const published = new Set<string>();
423
+
424
+ const publishNode = async (
425
+ filePath: string,
426
+ skipPublish: boolean,
427
+ ): Promise<void> => {
428
+ const absolutePath = normalizePlayPath(filePath);
429
+ const node = graph.nodes.get(absolutePath);
430
+ if (!node) {
431
+ throw new Error(`Missing bundled play graph node for ${absolutePath}.`);
432
+ }
433
+
434
+ for (const dependency of node.importedPlayDependencies) {
435
+ await publishNode(dependency.filePath, false);
436
+ }
437
+
438
+ if (skipPublish || published.has(absolutePath)) {
439
+ return;
440
+ }
441
+
442
+ if (!node.playName) {
443
+ throw new Error(
444
+ `Imported play ${absolutePath} must export definePlay(...) so it can be published for runtime composition.`,
445
+ );
446
+ }
447
+
448
+ await client.registerPlayArtifact({
449
+ name: node.playName,
450
+ sourceCode: node.sourceCode,
451
+ artifact: node.artifact,
452
+ compilerManifest: requireCompilerManifest(node),
453
+ publish: true,
454
+ });
455
+ published.add(absolutePath);
456
+ };
457
+
458
+ await publishNode(graph.root.filePath, true);
459
+ }
460
+
461
+ function formatTimestamp(value: string | number | null | undefined): string {
462
+ if (!value) return '—';
463
+ const date = typeof value === 'number' ? new Date(value) : new Date(value);
464
+ if (Number.isNaN(date.getTime())) return '—';
465
+ return date.toISOString();
466
+ }
467
+
468
+ function formatRunLine(run: PlayRunListItem): string {
469
+ return `${run.workflowId} ${run.status} ${formatTimestamp(run.startTime)}`;
470
+ }
471
+
472
+ type PlayRunTarget =
473
+ | { kind: 'run'; runId: string }
474
+ | { kind: 'name'; name: string };
475
+
476
+ function parsePlayRunTarget(input: {
477
+ args: string[];
478
+ usage: string;
479
+ allowName: boolean;
480
+ }): PlayRunTarget {
481
+ const { args, usage } = input;
482
+ let runId: string | null = null;
483
+ let playName: string | null = null;
484
+
485
+ for (let index = 0; index < args.length; index += 1) {
486
+ const arg = args[index]!;
487
+ if (arg === '--json') {
488
+ continue;
489
+ }
490
+ if (
491
+ arg === '--reason' ||
492
+ arg === '--interval-ms' ||
493
+ arg === '--poll-interval-ms'
494
+ ) {
495
+ index += 1;
496
+ continue;
497
+ }
498
+ if (arg === '--run-id' && args[index + 1]) {
499
+ runId = args[++index]!.trim();
500
+ continue;
501
+ }
502
+ if (arg === '--name' && args[index + 1] && input.allowName) {
503
+ playName = parseReferencedPlayTarget(args[++index]!).playName;
504
+ continue;
505
+ }
506
+ if (arg.startsWith('--')) {
507
+ continue;
508
+ }
509
+ throw new DeeplineError(
510
+ `Unexpected positional target "${arg}". Use --run-id for run ids.\n${usage}`,
511
+ );
512
+ }
513
+
514
+ const explicitTargets = [runId, playName].filter(Boolean).length;
515
+ if (explicitTargets > 1) {
516
+ throw new DeeplineError(`Choose exactly one play run target.\n${usage}`);
517
+ }
518
+
519
+ if (runId) {
520
+ return { kind: 'run', runId };
521
+ }
522
+ if (playName) {
523
+ return { kind: 'name', name: playName };
524
+ }
525
+ throw new DeeplineError(usage);
526
+ }
527
+
528
+ async function resolvePlayRunId(
529
+ client: DeeplineClient,
530
+ target: PlayRunTarget,
531
+ ): Promise<string> {
532
+ if (target.kind === 'run') {
533
+ try {
534
+ const status = await client.getPlayStatus(target.runId);
535
+ return status.runId;
536
+ } catch (error) {
537
+ if (!(error instanceof DeeplineError) || error.statusCode !== 404) {
538
+ throw error;
539
+ }
540
+ throw new DeeplineError(`No play run found for run id: ${target.runId}`);
541
+ }
542
+ }
543
+
544
+ const runs = await client.listPlayRuns(target.name);
545
+ const workflowId = runs[0]?.workflowId ?? '';
546
+ if (!workflowId) {
547
+ throw new DeeplineError(`No runs found for play: ${target.name}`);
548
+ }
549
+ return workflowId;
550
+ }
551
+
552
+ function isTransientPlayStatusPollError(error: unknown): boolean {
553
+ if (error instanceof DeeplineError && typeof error.statusCode === 'number') {
554
+ // Server-shaped errors with a definite status code are NOT transient by
555
+ // pattern — only network-level failures are. 5xx counts as transient
556
+ // since the server may recover, but 4xx (especially 404 = run gone) is
557
+ // terminal and should not be hidden behind a silent retry loop.
558
+ return error.statusCode >= 500 && error.statusCode < 600;
559
+ }
560
+ const text = error instanceof Error ? error.message : String(error);
561
+ return /auth validation backend timed out|fetch failed|eaddrnotavail|econnreset|etimedout|eai_again|socket hang up/i.test(
562
+ text,
563
+ );
564
+ }
565
+
566
+ function isTerminalPlayStatusPollError(input: {
567
+ error: unknown;
568
+ hasSeenRun: boolean;
569
+ }): boolean {
570
+ // A 404 *after* we've already seen the run means the run was deleted or
571
+ // the backend lost it — never recover, fail loud. A 404 *before* we've
572
+ // seen the run is the persistence race (submit accepted but the Convex
573
+ // record hasn't been written yet); treat that as transient.
574
+ if (
575
+ input.error instanceof DeeplineError &&
576
+ input.error.statusCode === 404 &&
577
+ input.hasSeenRun
578
+ ) {
579
+ return true;
580
+ }
581
+ return false;
582
+ }
583
+
584
+ const TERMINAL_PLAY_STATUSES = new Set<PlayStatus['status']>([
585
+ 'completed',
586
+ 'failed',
587
+ 'cancelled',
588
+ ]);
589
+
590
+ type PlayTailPrintState = {
591
+ lastLogIndex: number;
592
+ emittedRunnerStarted: boolean;
593
+ };
594
+
595
+ function getEventPayload(event: PlayLiveEvent): Record<string, unknown> {
596
+ return event.payload && typeof event.payload === 'object'
597
+ ? (event.payload as Record<string, unknown>)
598
+ : {};
599
+ }
600
+
601
+ function getStatusFromLiveEvent(
602
+ event: PlayLiveEvent,
603
+ ): PlayStatus['status'] | null {
604
+ if (event.type !== 'play.run.status' && event.type !== 'play.run.snapshot') {
605
+ return null;
606
+ }
607
+ const status = getEventPayload(event).status;
608
+ return status === 'queued' ||
609
+ status === 'running' ||
610
+ status === 'waiting' ||
611
+ status === 'completed' ||
612
+ status === 'failed' ||
613
+ status === 'cancelled'
614
+ ? status
615
+ : null;
616
+ }
617
+
618
+ function getFinalStatusFromLiveEvent(event: PlayLiveEvent): PlayStatus | null {
619
+ if (event.type !== 'play.run.final_status') {
620
+ return null;
621
+ }
622
+ const payload = getEventPayload(event);
623
+ const status = payload.status;
624
+ if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
625
+ return null;
626
+ }
627
+ return {
628
+ ...(payload as unknown as Omit<PlayStatus, 'runId' | 'status'>),
629
+ runId: typeof payload.runId === 'string' ? payload.runId : '',
630
+ status,
631
+ };
632
+ }
633
+
634
+ function getLogLinesFromLiveEvent(event: PlayLiveEvent): string[] {
635
+ if (event.type !== 'play.run.log') {
636
+ return [];
637
+ }
638
+ const lines = getEventPayload(event).lines;
639
+ return Array.isArray(lines)
640
+ ? lines.filter((line): line is string => typeof line === 'string')
641
+ : [];
642
+ }
643
+
644
+ function describeLiveEventPhase(event: PlayLiveEvent): string | null {
645
+ const payload = getEventPayload(event);
646
+ if (event.type === 'play.run.status') {
647
+ const status = getStatusFromLiveEvent(event);
648
+ if (status === 'running') {
649
+ return null;
650
+ }
651
+ const runId =
652
+ typeof payload.runId === 'string' && payload.runId
653
+ ? ` ${payload.runId}`
654
+ : '';
655
+ return status ? `${status}${runId}` : null;
656
+ }
657
+ if (
658
+ event.type === 'play.step.status' ||
659
+ event.type === 'play.step.progress'
660
+ ) {
661
+ const label =
662
+ typeof payload.label === 'string' && payload.label.trim()
663
+ ? payload.label.trim()
664
+ : typeof payload.stepId === 'string' && payload.stepId.trim()
665
+ ? payload.stepId.trim()
666
+ : 'step';
667
+ const completed =
668
+ typeof payload.completed === 'number' ? payload.completed : null;
669
+ const total = typeof payload.total === 'number' ? payload.total : null;
670
+ const progress =
671
+ completed !== null && total !== null ? ` ${completed}/${total}` : '';
672
+ return `step ${label}${progress}`;
673
+ }
674
+ if (
675
+ event.type === 'play.sheet.summary' ||
676
+ event.type === 'play.sheet.delta'
677
+ ) {
678
+ const table =
679
+ typeof payload.tableNamespace === 'string' ? payload.tableNamespace : '';
680
+ return table ? `updating table ${table}` : 'updating table';
681
+ }
682
+ return null;
683
+ }
684
+
685
+ function buildPlayDashboardUrl(baseUrl: string, playName: string): string {
686
+ const trimmedBase = baseUrl.replace(/\/$/, '');
687
+ const encodedPlayName = encodeURIComponent(playName);
688
+ return `${trimmedBase}/dashboard/plays/${encodedPlayName}`;
689
+ }
690
+
691
+ function getDashboardUrlFromLiveEvent(event: PlayLiveEvent): string | null {
692
+ const dashboardUrl = getEventPayload(event).dashboardUrl;
693
+ return typeof dashboardUrl === 'string' && dashboardUrl.trim()
694
+ ? dashboardUrl.trim()
695
+ : null;
696
+ }
697
+
698
+ function printPlayLogLines(input: {
699
+ lines: string[];
700
+ status: PlayStatus | null;
701
+ jsonOutput: boolean;
702
+ emitLogs: boolean;
703
+ state: PlayTailPrintState;
704
+ progress: CliProgress;
705
+ }) {
706
+ for (const line of input.lines) {
707
+ if (input.emitLogs) {
708
+ const formatted = formatPlayLogLine(
709
+ line,
710
+ input.status ?? undefined,
711
+ input.state,
712
+ );
713
+ if (formatted) {
714
+ input.progress.writeLogLine(formatted);
715
+ }
716
+ }
717
+ input.state.lastLogIndex += 1;
718
+ }
719
+ }
720
+
721
+ function assertPlayWaitNotTimedOut(input: {
722
+ workflowId: string;
723
+ startedAt: number;
724
+ waitTimeoutMs: number | null;
725
+ lastPhase?: string | null;
726
+ }) {
727
+ if (
728
+ input.waitTimeoutMs !== null &&
729
+ Date.now() - input.startedAt >= input.waitTimeoutMs
730
+ ) {
731
+ const hasRealRunId =
732
+ input.workflowId.length > 0 && input.workflowId !== 'pending';
733
+ const phaseSuffix =
734
+ input.lastPhase && input.lastPhase.trim()
735
+ ? ` (last observed phase: ${input.lastPhase.trim()})`
736
+ : '';
737
+ const tailHint = hasRealRunId
738
+ ? ` Run 'deepline play tail --run-id ${input.workflowId} --json' to inspect it, or rerun with a larger --tail-timeout-ms.`
739
+ : ` The run never reported a workflow id — the start request likely failed before reaching the scheduler. Check server logs and rerun with a larger --tail-timeout-ms.`;
740
+ throw new DeeplineError(
741
+ `Timed out waiting for play ${hasRealRunId ? input.workflowId : '<no run id>'} after ${Math.ceil(input.waitTimeoutMs / 1000)}s${phaseSuffix}.${tailHint}`,
742
+ undefined,
743
+ 'PLAY_WAIT_TIMEOUT',
744
+ {
745
+ ...(hasRealRunId
746
+ ? { runId: input.workflowId, workflowId: input.workflowId }
747
+ : {}),
748
+ ...(input.lastPhase ? { phase: input.lastPhase } : {}),
749
+ timeoutMs: input.waitTimeoutMs,
750
+ },
751
+ );
752
+ }
753
+ }
754
+
755
+ async function waitForPlayCompletionByStream(input: {
756
+ client: DeeplineClient;
757
+ workflowId: string;
758
+ jsonOutput: boolean;
759
+ emitLogs: boolean;
760
+ waitTimeoutMs: number | null;
761
+ startedAt: number;
762
+ state: PlayTailPrintState;
763
+ progress: CliProgress;
764
+ }): Promise<PlayStatus> {
765
+ const controller = new AbortController();
766
+ let timedOut = false;
767
+ let lastPhase: string | null = null;
768
+ const timeout =
769
+ input.waitTimeoutMs === null
770
+ ? null
771
+ : setTimeout(
772
+ () => {
773
+ timedOut = true;
774
+ controller.abort();
775
+ },
776
+ Math.max(1, input.waitTimeoutMs - (Date.now() - input.startedAt)),
777
+ );
778
+
779
+ try {
780
+ for await (const event of input.client.streamPlayRunEvents(
781
+ input.workflowId,
782
+ { signal: controller.signal },
783
+ )) {
784
+ assertPlayWaitNotTimedOut({ ...input, lastPhase });
785
+ const phase = describeLiveEventPhase(event);
786
+ if (phase) {
787
+ lastPhase = phase;
788
+ input.progress.phase(phase);
789
+ }
790
+ printPlayLogLines({
791
+ lines: getLogLinesFromLiveEvent(event),
792
+ status: null,
793
+ jsonOutput: input.jsonOutput,
794
+ emitLogs: input.emitLogs,
795
+ state: input.state,
796
+ progress: input.progress,
797
+ });
798
+
799
+ const status = getStatusFromLiveEvent(event);
800
+ if (status && TERMINAL_PLAY_STATUSES.has(status)) {
801
+ const finalStatus = await input.client.getPlayStatus(input.workflowId);
802
+ if (TERMINAL_PLAY_STATUSES.has(finalStatus.status)) {
803
+ return finalStatus;
804
+ }
805
+ }
806
+ }
807
+ } catch (error) {
808
+ if (timedOut) {
809
+ assertPlayWaitNotTimedOut({ ...input, lastPhase });
810
+ }
811
+ throw error;
812
+ } finally {
813
+ if (timeout) {
814
+ clearTimeout(timeout);
815
+ }
816
+ }
817
+
818
+ const phaseSuffix =
819
+ lastPhase && lastPhase.trim()
820
+ ? ` (last observed phase: ${lastPhase.trim()})`
821
+ : '';
822
+ throw new DeeplineError(
823
+ `Play live stream ended before the run reached a terminal state runId=${input.workflowId}${phaseSuffix}.`,
824
+ undefined,
825
+ 'PLAY_LIVE_STREAM_ENDED',
826
+ {
827
+ runId: input.workflowId,
828
+ workflowId: input.workflowId,
829
+ ...(lastPhase ? { phase: lastPhase } : {}),
830
+ },
831
+ );
832
+ }
833
+
834
+ async function startAndWaitForPlayCompletionByStream(input: {
835
+ client: DeeplineClient;
836
+ request: Parameters<DeeplineClient['startPlayRun']>[0];
837
+ playName: string;
838
+ jsonOutput: boolean;
839
+ emitLogs: boolean;
840
+ waitTimeoutMs: number | null;
841
+ progress: CliProgress;
842
+ }): Promise<PlayStatus> {
843
+ const startedAt = Date.now();
844
+ const state: PlayTailPrintState = {
845
+ lastLogIndex: 0,
846
+ emittedRunnerStarted: false,
847
+ };
848
+ const controller = new AbortController();
849
+ let timedOut = false;
850
+ let emittedDashboardUrl = false;
851
+ let lastKnownWorkflowId = '';
852
+ let lastPhase: string | null = null;
853
+ const timeout =
854
+ input.waitTimeoutMs === null
855
+ ? null
856
+ : setTimeout(
857
+ () => {
858
+ timedOut = true;
859
+ controller.abort();
860
+ },
861
+ Math.max(1, input.waitTimeoutMs),
862
+ );
863
+
864
+ recordCliTrace({
865
+ phase: 'cli.start_stream_request',
866
+ playName: input.playName,
867
+ });
868
+ try {
869
+ let eventCount = 0;
870
+ for await (const event of input.client.startPlayRunStream(input.request, {
871
+ signal: controller.signal,
872
+ })) {
873
+ eventCount += 1;
874
+ if (eventCount === 1) {
875
+ recordCliTrace({
876
+ phase: 'cli.start_stream_first_event',
877
+ ms: Date.now() - startedAt,
878
+ playName: input.playName,
879
+ eventType: event.type,
880
+ });
881
+ }
882
+ const eventRunId = getEventPayload(event).runId;
883
+ if (
884
+ typeof eventRunId === 'string' &&
885
+ eventRunId &&
886
+ eventRunId !== 'pending'
887
+ ) {
888
+ lastKnownWorkflowId = eventRunId;
889
+ }
890
+ const workflowId = lastKnownWorkflowId || 'pending';
891
+ if (workflowId !== 'pending' && !emittedDashboardUrl) {
892
+ const dashboardUrl =
893
+ getDashboardUrlFromLiveEvent(event) ??
894
+ buildPlayDashboardUrl(input.client.baseUrl, input.playName);
895
+ input.progress.phase(
896
+ `loading play on ${dashboardUrl}`,
897
+ );
898
+ emittedDashboardUrl = true;
899
+ }
900
+ assertPlayWaitNotTimedOut({
901
+ workflowId,
902
+ startedAt,
903
+ waitTimeoutMs: input.waitTimeoutMs,
904
+ lastPhase,
905
+ });
906
+ const phase = describeLiveEventPhase(event);
907
+ if (phase) {
908
+ lastPhase = phase;
909
+ input.progress.phase(phase);
910
+ }
911
+ printPlayLogLines({
912
+ lines: getLogLinesFromLiveEvent(event),
913
+ status: null,
914
+ jsonOutput: input.jsonOutput,
915
+ emitLogs: input.emitLogs,
916
+ state,
917
+ progress: input.progress,
918
+ });
919
+
920
+ const finalStatus = getFinalStatusFromLiveEvent(event);
921
+ if (finalStatus) {
922
+ recordCliTrace({
923
+ phase: 'cli.start_stream_final_event',
924
+ ms: Date.now() - startedAt,
925
+ playName: input.playName,
926
+ runId: finalStatus.runId,
927
+ status: finalStatus.status,
928
+ eventCount,
929
+ });
930
+ return finalStatus;
931
+ }
932
+ }
933
+ } catch (error) {
934
+ if (timedOut) {
935
+ assertPlayWaitNotTimedOut({
936
+ workflowId: lastKnownWorkflowId,
937
+ startedAt,
938
+ waitTimeoutMs: input.waitTimeoutMs,
939
+ lastPhase,
940
+ });
941
+ }
942
+ if (lastKnownWorkflowId && isTransientPlayStatusPollError(error)) {
943
+ if (timeout) {
944
+ clearTimeout(timeout);
945
+ }
946
+ const reason = error instanceof Error ? error.message : String(error);
947
+ process.stderr.write(
948
+ `[play watch] start stream failed after run ${lastKnownWorkflowId}; falling back to polling (${reason})\n`,
949
+ );
950
+ return waitForPlayCompletionByPolling({
951
+ client: input.client,
952
+ workflowId: lastKnownWorkflowId,
953
+ pollIntervalMs: 500,
954
+ jsonOutput: input.jsonOutput,
955
+ emitLogs: input.emitLogs,
956
+ waitTimeoutMs: input.waitTimeoutMs,
957
+ startedAt,
958
+ state,
959
+ progress: input.progress,
960
+ });
961
+ }
962
+ throw error;
963
+ } finally {
964
+ if (timeout) {
965
+ clearTimeout(timeout);
966
+ }
967
+ }
968
+
969
+ const phaseSuffix =
970
+ lastPhase && lastPhase.trim()
971
+ ? ` (last observed phase: ${lastPhase.trim()})`
972
+ : '';
973
+ const idSuffix = lastKnownWorkflowId ? ` runId=${lastKnownWorkflowId}` : '';
974
+ throw new DeeplineError(
975
+ `Play start stream ended before the run reached a terminal state${idSuffix}${phaseSuffix}.`,
976
+ undefined,
977
+ 'PLAY_START_STREAM_ENDED',
978
+ {
979
+ ...(lastKnownWorkflowId
980
+ ? { runId: lastKnownWorkflowId, workflowId: lastKnownWorkflowId }
981
+ : {}),
982
+ ...(lastPhase ? { phase: lastPhase } : {}),
983
+ },
984
+ );
985
+ }
986
+
987
+ async function waitForPlayCompletionByPolling(input: {
988
+ client: DeeplineClient;
989
+ workflowId: string;
990
+ pollIntervalMs: number;
991
+ jsonOutput: boolean;
992
+ emitLogs: boolean;
993
+ waitTimeoutMs: number | null;
994
+ startedAt: number;
995
+ state: PlayTailPrintState;
996
+ progress: CliProgress;
997
+ }): Promise<PlayStatus> {
998
+ let lastTransientPollWarningAt = 0;
999
+ let hasSeenRun = false;
1000
+
1001
+ while (true) {
1002
+ assertPlayWaitNotTimedOut(input);
1003
+
1004
+ let status: PlayStatus;
1005
+ try {
1006
+ status = await input.client.getPlayTailStatus(input.workflowId, {
1007
+ afterLogIndex: input.state.lastLogIndex,
1008
+ // Keep the server-side tail wait close to the caller's requested poll
1009
+ // cadence. A long wait makes tiny remote runs look slow whenever the
1010
+ // terminal update lands just after the held request starts.
1011
+ waitMs: Math.max(50, Math.min(input.pollIntervalMs, 1_000)),
1012
+ });
1013
+ } catch (error) {
1014
+ if (isTerminalPlayStatusPollError({ error, hasSeenRun })) {
1015
+ throw new DeeplineError(
1016
+ `Play run ${input.workflowId} no longer exists on the server (404). The run was deleted or the backend lost it.`,
1017
+ 404,
1018
+ 'PLAY_RUN_NOT_FOUND',
1019
+ );
1020
+ }
1021
+ if (!isTransientPlayStatusPollError(error)) {
1022
+ throw error;
1023
+ }
1024
+ const now = Date.now();
1025
+ if (now - lastTransientPollWarningAt >= 30_000) {
1026
+ const message = error instanceof Error ? error.message : String(error);
1027
+ process.stderr.write(
1028
+ `[play tail] transient status poll failed; retrying: ${message}\n`,
1029
+ );
1030
+ lastTransientPollWarningAt = now;
1031
+ }
1032
+ await new Promise((resolvePromise) =>
1033
+ setTimeout(resolvePromise, input.pollIntervalMs),
1034
+ );
1035
+ continue;
1036
+ }
1037
+ hasSeenRun = true;
1038
+ const logs = status.progress?.logs ?? [];
1039
+ input.progress.phase(status.status);
1040
+ printPlayLogLines({
1041
+ lines: logs.slice(input.state.lastLogIndex),
1042
+ status,
1043
+ jsonOutput: input.jsonOutput,
1044
+ emitLogs: input.emitLogs,
1045
+ state: input.state,
1046
+ progress: input.progress,
1047
+ });
1048
+
1049
+ if (TERMINAL_PLAY_STATUSES.has(status.status)) {
1050
+ return status.result !== undefined
1051
+ ? status
1052
+ : await input.client.getPlayStatus(input.workflowId);
1053
+ }
1054
+
1055
+ const authoritativeStatus = await input.client.getPlayStatus(
1056
+ input.workflowId,
1057
+ );
1058
+ if (TERMINAL_PLAY_STATUSES.has(authoritativeStatus.status)) {
1059
+ return authoritativeStatus;
1060
+ }
1061
+
1062
+ if ((status.progress?.logs ?? []).length === input.state.lastLogIndex) {
1063
+ await new Promise((resolvePromise) =>
1064
+ setTimeout(resolvePromise, input.pollIntervalMs),
1065
+ );
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ async function waitForPlayCompletion(input: {
1071
+ client: DeeplineClient;
1072
+ workflowId: string;
1073
+ pollIntervalMs: number;
1074
+ jsonOutput: boolean;
1075
+ emitLogs: boolean;
1076
+ waitTimeoutMs: number | null;
1077
+ progress: CliProgress;
1078
+ }): Promise<PlayStatus> {
1079
+ const startedAt = Date.now();
1080
+ const state: PlayTailPrintState = {
1081
+ lastLogIndex: 0,
1082
+ emittedRunnerStarted: false,
1083
+ };
1084
+ try {
1085
+ return await waitForPlayCompletionByStream({
1086
+ ...input,
1087
+ startedAt,
1088
+ state,
1089
+ progress: input.progress,
1090
+ });
1091
+ } catch (error) {
1092
+ assertPlayWaitNotTimedOut({
1093
+ workflowId: input.workflowId,
1094
+ startedAt,
1095
+ waitTimeoutMs: input.waitTimeoutMs,
1096
+ });
1097
+ // Loud one-line note so the user can tell SSE failed and we are now
1098
+ // polling. Repeated SSE failures should be diagnosed, not hidden.
1099
+ const reason = error instanceof Error ? error.message : String(error);
1100
+ process.stderr.write(
1101
+ `[play watch] SSE stream failed; falling back to polling (${reason})\n`,
1102
+ );
1103
+ return waitForPlayCompletionByPolling({
1104
+ ...input,
1105
+ startedAt,
1106
+ state,
1107
+ progress: input.progress,
1108
+ });
1109
+ }
1110
+ }
1111
+
1112
+ function formatInteger(value: unknown): string {
1113
+ return typeof value === 'number' && Number.isFinite(value)
1114
+ ? value.toLocaleString('en-US')
1115
+ : String(value ?? '-');
1116
+ }
1117
+
1118
+ function formatPercent(numerator: number, denominator: number): string {
1119
+ if (denominator <= 0) {
1120
+ return '-';
1121
+ }
1122
+ const percent = (numerator / denominator) * 100;
1123
+ return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
1124
+ }
1125
+
1126
+ function isFilledValue(value: unknown): boolean {
1127
+ if (value === null || value === undefined) {
1128
+ return false;
1129
+ }
1130
+ if (typeof value === 'string') {
1131
+ return value.trim().length > 0;
1132
+ }
1133
+ if (Array.isArray(value)) {
1134
+ return value.length > 0;
1135
+ }
1136
+ if (typeof value === 'object') {
1137
+ return Object.keys(value).length > 0;
1138
+ }
1139
+ return true;
1140
+ }
1141
+
1142
+ const BULKY_RETURN_KEYS = new Set([
1143
+ 'contract',
1144
+ 'staticPipeline',
1145
+ 'batchingTrace',
1146
+ 'sequenceTrace',
1147
+ 'logs',
1148
+ ]);
1149
+
1150
+ function formatPlayLogLine(
1151
+ line: string,
1152
+ status: PlayStatus | undefined,
1153
+ state: PlayTailPrintState,
1154
+ ): string | null {
1155
+ const timestampMatch = line.match(/^\[([^\]]+)\]\s*(.*)$/);
1156
+ const parsedTimestamp = timestampMatch?.[1]
1157
+ ? new Date(timestampMatch[1])
1158
+ : null;
1159
+ const timestamp =
1160
+ parsedTimestamp && !Number.isNaN(parsedTimestamp.getTime())
1161
+ ? parsedTimestamp.toLocaleTimeString('en-US', {
1162
+ hour12: false,
1163
+ hour: '2-digit',
1164
+ minute: '2-digit',
1165
+ second: '2-digit',
1166
+ })
1167
+ : null;
1168
+ const message = timestampMatch?.[2] ?? line;
1169
+ const prefix = timestamp ? `${timestamp} ` : '';
1170
+
1171
+ if (/\[worker\] picked up run\b/.test(message)) {
1172
+ if (state.emittedRunnerStarted) {
1173
+ return null;
1174
+ }
1175
+ state.emittedRunnerStarted = true;
1176
+ return `${prefix}runner started`;
1177
+ }
1178
+
1179
+ if (
1180
+ /\[worker\] step started\b/.test(message) ||
1181
+ /\[worker\] Preparing run files\b/.test(message) ||
1182
+ /\[worker\] Run files ready\b/.test(message) ||
1183
+ /\[worker\] Runtime ready\b/.test(message) ||
1184
+ /\[worker\] Sandbox (?:starting|create start|create done|workspace ready|upload start|runner uploaded)\b/.test(
1185
+ message,
1186
+ )
1187
+ ) {
1188
+ return null;
1189
+ }
1190
+
1191
+ const stages = (
1192
+ (
1193
+ status?.contract as
1194
+ | {
1195
+ staticPipeline?: {
1196
+ stages?: Array<{
1197
+ tableNamespace?: string;
1198
+ sourceRange?: { startLine?: number; endLine?: number };
1199
+ }>;
1200
+ };
1201
+ }
1202
+ | null
1203
+ | undefined
1204
+ )?.staticPipeline?.stages ?? []
1205
+ ).filter((stage) => stage.tableNamespace);
1206
+ const sourceLabelForNamespace = (namespace: string): string => {
1207
+ const stage = stages.find((entry) => entry.tableNamespace === namespace);
1208
+ const range = stage?.sourceRange;
1209
+ return range?.startLine && range?.endLine
1210
+ ? `${namespace} lines ${range.startLine}-${range.endLine}`
1211
+ : namespace;
1212
+ };
1213
+
1214
+ const mapStart = message.match(
1215
+ /^Starting map over (\d+) items with (\d+) fields \(key: ([^;]+); (\d+) already satisfied; (\d+) pending\)$/,
1216
+ );
1217
+ if (mapStart) {
1218
+ const [, rows, fields, namespace, cached, pending] = mapStart;
1219
+ return `${prefix}map ${sourceLabelForNamespace(namespace!)}: ${formatInteger(Number(rows))} rows, ${fields} fields, ${formatInteger(Number(cached))} cached, ${formatInteger(Number(pending))} pending`;
1220
+ }
1221
+
1222
+ const mapDone = message.match(
1223
+ /^Map completed: (\d+) results \((\d+) executed, (\d+) already satisfied\)$/,
1224
+ );
1225
+ if (mapDone) {
1226
+ const [, results, executed, cached] = mapDone;
1227
+ return `${prefix}done: ${formatInteger(Number(results))} results, ${formatInteger(Number(executed))} executed, ${formatInteger(Number(cached))} cached`;
1228
+ }
1229
+
1230
+ return `${prefix}${message}`;
1231
+ }
1232
+
1233
+ function compactReturnValue(value: unknown, depth = 0): unknown {
1234
+ if (depth >= 4) {
1235
+ return value && typeof value === 'object' ? '[Object]' : value;
1236
+ }
1237
+ if (Array.isArray(value)) {
1238
+ const compacted = value
1239
+ .slice(0, 5)
1240
+ .map((entry) => compactReturnValue(entry, depth + 1));
1241
+ return value.length > 5
1242
+ ? [...compacted, `... ${value.length - 5} more`]
1243
+ : compacted;
1244
+ }
1245
+ if (!value || typeof value !== 'object') {
1246
+ return value;
1247
+ }
1248
+
1249
+ const output: Record<string, unknown> = {};
1250
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
1251
+ if (depth === 0 && key === '_metadata') {
1252
+ continue;
1253
+ }
1254
+ if (BULKY_RETURN_KEYS.has(key)) {
1255
+ continue;
1256
+ }
1257
+ if (key === 'access') {
1258
+ continue;
1259
+ }
1260
+ output[key] = compactReturnValue(entry, depth + 1);
1261
+ }
1262
+ return output;
1263
+ }
1264
+
1265
+ function extractResultRowsInfo(result: Record<string, unknown>): {
1266
+ rows: unknown[];
1267
+ totalRows: number | null;
1268
+ sampled: boolean;
1269
+ } {
1270
+ const output = result.output as Record<string, unknown> | undefined;
1271
+ const candidate =
1272
+ (output?.results as unknown[] | undefined) ??
1273
+ (output?.rows as unknown[] | undefined) ??
1274
+ (result.results as unknown[] | undefined) ??
1275
+ (result.rows as unknown[] | undefined) ??
1276
+ (result.previewRows as unknown[] | undefined);
1277
+ if (Array.isArray(candidate)) {
1278
+ return { rows: candidate, totalRows: candidate.length, sampled: false };
1279
+ }
1280
+
1281
+ const dataset =
1282
+ (result.rows &&
1283
+ typeof result.rows === 'object' &&
1284
+ !Array.isArray(result.rows)
1285
+ ? (result.rows as Record<string, unknown>)
1286
+ : null) ??
1287
+ (output?.rows &&
1288
+ typeof output.rows === 'object' &&
1289
+ !Array.isArray(output.rows)
1290
+ ? (output.rows as Record<string, unknown>)
1291
+ : null);
1292
+ if (dataset && Array.isArray(dataset.preview)) {
1293
+ return {
1294
+ rows: dataset.preview,
1295
+ totalRows:
1296
+ typeof dataset.count === 'number' && Number.isFinite(dataset.count)
1297
+ ? dataset.count
1298
+ : dataset.preview.length,
1299
+ sampled: true,
1300
+ };
1301
+ }
1302
+
1303
+ return { rows: [], totalRows: null, sampled: false };
1304
+ }
1305
+
1306
+ function formatJsonPreview(value: unknown): string[] {
1307
+ const json = JSON.stringify(value, null, 2);
1308
+ if (!json || json === '{}') {
1309
+ return [];
1310
+ }
1311
+
1312
+ const MAX_CHARS = 4_000;
1313
+ const truncated =
1314
+ json.length > MAX_CHARS
1315
+ ? `${json.slice(0, MAX_CHARS)}\n... truncated; use --json for full output`
1316
+ : json;
1317
+ return truncated.split('\n').map((line) => ` ${line}`);
1318
+ }
1319
+
1320
+ function formatReturnValue(result: Record<string, unknown>): string[] {
1321
+ const previewLines = formatJsonPreview(compactReturnValue(result));
1322
+ if (previewLines.length === 0) {
1323
+ return [];
1324
+ }
1325
+ const lines = [' return value:'];
1326
+ lines.push(...previewLines);
1327
+ const hiddenKeys = [...BULKY_RETURN_KEYS].filter(
1328
+ (key) => result[key] !== undefined,
1329
+ );
1330
+ if (hiddenKeys.length > 0) {
1331
+ lines.push(` omitted metadata from preview: ${hiddenKeys.join(', ')}`);
1332
+ }
1333
+ return lines;
1334
+ }
1335
+
1336
+ function formatTableSummary(result: Record<string, unknown>): string[] {
1337
+ const { rows, totalRows, sampled } = extractResultRowsInfo(result);
1338
+ const recordRows = rows.filter(
1339
+ (row): row is Record<string, unknown> =>
1340
+ Boolean(row) && typeof row === 'object' && !Array.isArray(row),
1341
+ );
1342
+ if (recordRows.length === 0) {
1343
+ return [];
1344
+ }
1345
+
1346
+ const columns = [...new Set(recordRows.flatMap((row) => Object.keys(row)))];
1347
+ const displayedRows = recordRows.length;
1348
+ const denominator =
1349
+ sampled && totalRows !== null ? displayedRows : recordRows.length;
1350
+ const lines = [
1351
+ ` rows=${formatInteger(totalRows ?? recordRows.length)} columns=${formatInteger(columns.length)}${sampled ? ` previewRows=${formatInteger(displayedRows)}` : ''}`,
1352
+ ];
1353
+ const fillStats = columns
1354
+ .filter((column) => !column.startsWith('_'))
1355
+ .map((column) => {
1356
+ const filled = recordRows.filter((row) =>
1357
+ isFilledValue(row[column]),
1358
+ ).length;
1359
+ return { column, filled };
1360
+ })
1361
+ .sort((a, b) => b.filled - a.filled)
1362
+ .slice(0, 8);
1363
+ if (fillStats.length > 0) {
1364
+ lines.push(
1365
+ ` fill rates: ${fillStats
1366
+ .map(
1367
+ (stat) =>
1368
+ `${stat.column}=${formatInteger(stat.filled)}/${formatInteger(denominator)} (${formatPercent(stat.filled, denominator)})${sampled ? ' sample' : ''}`,
1369
+ )
1370
+ .join(', ')}`,
1371
+ );
1372
+ }
1373
+ return lines;
1374
+ }
1375
+
1376
+ function buildOutputSummary(
1377
+ rowsInfo: CanonicalRowsInfo | null,
1378
+ exportedPath?: string | null,
1379
+ ): Record<string, unknown> | null {
1380
+ if (!rowsInfo) {
1381
+ return exportedPath ? { csv_path: exportedPath } : null;
1382
+ }
1383
+ return {
1384
+ kind: 'rows',
1385
+ rowCount: rowsInfo.totalRows,
1386
+ previewRowCount: rowsInfo.rows.length,
1387
+ complete: rowsInfo.complete,
1388
+ columns: rowsInfo.columns,
1389
+ source: rowsInfo.source,
1390
+ ...(exportedPath ? { csv_path: exportedPath } : {}),
1391
+ };
1392
+ }
1393
+
1394
+ function buildRunWarnings(
1395
+ status: PlayStatus,
1396
+ rowsInfo: CanonicalRowsInfo | null,
1397
+ ): string[] {
1398
+ if (status.status === 'completed' && rowsInfo?.totalRows === 0) {
1399
+ return ['Run completed with 0 output rows.'];
1400
+ }
1401
+ return [];
1402
+ }
1403
+
1404
+ function buildRunNextCommands(runId: string): Record<string, string> {
1405
+ return {
1406
+ exportCsv: `deepline runs export ${runId} --out output.csv`,
1407
+ status: `deepline runs status ${runId} --json`,
1408
+ fullStatus: `deepline runs status ${runId} --json --full`,
1409
+ logs: `deepline runs logs ${runId}`,
1410
+ };
1411
+ }
1412
+
1413
+ function isTerminalPlayStatus(status: PlayStatus): boolean {
1414
+ return TERMINAL_PLAY_STATUSES.has(status.status);
1415
+ }
1416
+
1417
+ function compactPlayStatus(
1418
+ status: PlayStatus,
1419
+ options?: { exportedPath?: string | null },
1420
+ ): Record<string, unknown> {
1421
+ const rowsInfo = extractCanonicalRowsInfo(status);
1422
+ const result =
1423
+ status && typeof status === 'object'
1424
+ ? (status as unknown as { result?: unknown }).result
1425
+ : null;
1426
+ const warnings = buildRunWarnings(status, rowsInfo);
1427
+ const datasetStats =
1428
+ rowsInfo && rowsInfo.complete
1429
+ ? buildDatasetStats(rowsInfo.rows, rowsInfo.totalRows, rowsInfo.columns)
1430
+ : null;
1431
+ const billing =
1432
+ status && typeof status === 'object'
1433
+ ? (status as unknown as { billing?: unknown }).billing
1434
+ : null;
1435
+ const progressError = (
1436
+ status.progress as unknown as Record<string, unknown> | undefined
1437
+ )?.error;
1438
+ const error =
1439
+ typeof progressError === 'string'
1440
+ ? progressError
1441
+ : typeof (status as unknown as { error?: unknown }).error === 'string'
1442
+ ? String((status as unknown as { error?: unknown }).error)
1443
+ : null;
1444
+ return {
1445
+ runId: status.runId,
1446
+ apiVersion: status.apiVersion ?? 1,
1447
+ ...(typeof (status as unknown as { name?: unknown }).name === 'string'
1448
+ ? { name: (status as unknown as { name: string }).name }
1449
+ : {}),
1450
+ ...(typeof (status as unknown as { playName?: unknown }).playName ===
1451
+ 'string'
1452
+ ? { playName: (status as unknown as { playName: string }).playName }
1453
+ : {}),
1454
+ status: status.status,
1455
+ ...(error ? { error } : {}),
1456
+ ...(warnings.length > 0 ? { warnings } : {}),
1457
+ output:
1458
+ buildOutputSummary(rowsInfo, options?.exportedPath) ?? result ?? null,
1459
+ ...(result !== undefined ? { result } : {}),
1460
+ ...(status.resultView ? { resultView: status.resultView } : {}),
1461
+ ...(datasetStats ? { dataset_stats: datasetStats } : {}),
1462
+ ...(rowsInfo ? { previewRows: rowsInfo.rows.slice(0, 10) } : {}),
1463
+ ...(billing ? { billing } : {}),
1464
+ ...(status.run ? { run: status.run } : {}),
1465
+ next: buildRunNextCommands(status.runId),
1466
+ };
1467
+ }
1468
+
1469
+ function formatDatasetStatsLines(datasetStats: DatasetStats | null): string[] {
1470
+ if (!datasetStats) {
1471
+ return [];
1472
+ }
1473
+ const lines = [' column stats:'];
1474
+ for (const [column, stat] of Object.entries(datasetStats.columnStats).slice(
1475
+ 0,
1476
+ 12,
1477
+ )) {
1478
+ const topValues = stat.top_values
1479
+ ? `, ${Object.entries(stat.top_values)
1480
+ .slice(0, 3)
1481
+ .map(([value, count]) => `${value}=${count}`)
1482
+ .join(', ')}`
1483
+ : '';
1484
+ const sample =
1485
+ stat.sample_value !== undefined
1486
+ ? `, sample=${JSON.stringify(stat.sample_value)}`
1487
+ : '';
1488
+ lines.push(
1489
+ ` ${column}: ${stat.non_empty}, unique=${stat.unique}${topValues}${sample}`,
1490
+ );
1491
+ }
1492
+ return lines;
1493
+ }
1494
+
1495
+ function writePlayResult(
1496
+ status: PlayStatus,
1497
+ jsonOutput: boolean,
1498
+ options?: { exportedPath?: string | null; fullJson?: boolean },
1499
+ ): void {
1500
+ if (jsonOutput) {
1501
+ process.stdout.write(
1502
+ `${JSON.stringify(
1503
+ options?.fullJson ? status : compactPlayStatus(status, options),
1504
+ )}\n`,
1505
+ );
1506
+ return;
1507
+ }
1508
+
1509
+ const result = status.result as Record<string, unknown> | undefined;
1510
+ const publicStatus = status.status ?? 'running';
1511
+ const success = publicStatus === 'completed';
1512
+ const runId = status.runId ?? 'unknown';
1513
+
1514
+ const lines: string[] = [];
1515
+ lines.push(`${success ? '✓' : '✗'} ${publicStatus} ${runId}`);
1516
+ const rowsInfo = extractCanonicalRowsInfo(status);
1517
+ const warnings = buildRunWarnings(status, rowsInfo);
1518
+ const datasetStats =
1519
+ rowsInfo && rowsInfo.complete
1520
+ ? buildDatasetStats(rowsInfo.rows, rowsInfo.totalRows, rowsInfo.columns)
1521
+ : null;
1522
+ const outputSummary = buildOutputSummary(rowsInfo, options?.exportedPath);
1523
+ if (outputSummary) {
1524
+ const columns = Array.isArray(outputSummary.columns)
1525
+ ? outputSummary.columns.length
1526
+ : 0;
1527
+ const path =
1528
+ typeof outputSummary.csv_path === 'string'
1529
+ ? ` file=${outputSummary.csv_path}`
1530
+ : '';
1531
+ lines.push(
1532
+ ` output: rows=${formatInteger(outputSummary.rowCount)} columns=${formatInteger(columns)}${path}`,
1533
+ );
1534
+ }
1535
+ for (const warning of warnings) {
1536
+ lines.push(` warning: ${warning}`);
1537
+ }
1538
+ lines.push(...formatDatasetStatsLines(datasetStats));
1539
+
1540
+ const progressError = (
1541
+ status.progress as unknown as Record<string, unknown> | undefined
1542
+ )?.error;
1543
+ if (progressError && typeof progressError === 'string') {
1544
+ lines.push(` error: ${progressError.slice(0, 200)}`);
1545
+ }
1546
+
1547
+ const renderedServerView = renderServerResultView(status.resultView);
1548
+ if (result) {
1549
+ lines.push(...formatReturnValue(result));
1550
+ }
1551
+ if (renderedServerView.lines.length > 0) {
1552
+ lines.push(...renderedServerView.lines);
1553
+ }
1554
+ lines.push(...renderedServerView.actions);
1555
+
1556
+ console.log(lines.join('\n'));
1557
+ }
1558
+
1559
+ function exportPlayStatusRows(
1560
+ status: PlayStatus,
1561
+ outPath: string | null,
1562
+ ): string | null {
1563
+ if (!outPath) {
1564
+ return null;
1565
+ }
1566
+ const rowsInfo = extractCanonicalRowsInfo(status);
1567
+ if (!rowsInfo) {
1568
+ throw new DeeplineError(
1569
+ `Run ${status.runId} did not expose a row-shaped final output to export.`,
1570
+ );
1571
+ }
1572
+ return writeCanonicalRowsCsv(rowsInfo, outPath);
1573
+ }
1574
+
1575
+ function renderServerResultView(value: unknown): {
1576
+ lines: string[];
1577
+ actions: string[];
1578
+ } {
1579
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1580
+ return { lines: [], actions: [] };
1581
+ }
1582
+ const view = value as {
1583
+ render?: {
1584
+ sections?: Array<{ title?: unknown; lines?: unknown }>;
1585
+ actions?: Array<{ label?: unknown; command?: unknown }>;
1586
+ };
1587
+ scalars?: Array<{ key?: unknown; value?: unknown }>;
1588
+ byStep?: Array<{ stepId?: unknown; count?: unknown; rate?: unknown }>;
1589
+ tables?: Array<{
1590
+ tableNamespace?: unknown;
1591
+ rowCount?: unknown;
1592
+ sourceLines?: unknown;
1593
+ inputFields?: unknown;
1594
+ outputFields?: unknown;
1595
+ waterfallIds?: unknown;
1596
+ columnCounts?: unknown;
1597
+ }>;
1598
+ datasetAccess?: {
1599
+ sqlTableName?: unknown;
1600
+ tableNamespace?: unknown;
1601
+ sqlSchemaName?: unknown;
1602
+ cliCommand?: unknown;
1603
+ toolCommand?: unknown;
1604
+ api?: unknown;
1605
+ note?: unknown;
1606
+ } | null;
1607
+ };
1608
+ const renderedSections = Array.isArray(view.render?.sections)
1609
+ ? view.render.sections
1610
+ : [];
1611
+ const renderedActions = Array.isArray(view.render?.actions)
1612
+ ? view.render.actions
1613
+ : [];
1614
+ if (renderedSections.length > 0 || renderedActions.length > 0) {
1615
+ const lines: string[] = [];
1616
+ for (const section of renderedSections) {
1617
+ if (typeof section.title !== 'string') {
1618
+ continue;
1619
+ }
1620
+ const sectionLines = Array.isArray(section.lines)
1621
+ ? section.lines.filter(
1622
+ (line): line is string => typeof line === 'string',
1623
+ )
1624
+ : [];
1625
+ if (sectionLines.length === 0) {
1626
+ continue;
1627
+ }
1628
+ lines.push(` ${section.title}:`);
1629
+ lines.push(...sectionLines.map((line) => ` ${line}`));
1630
+ }
1631
+ const actions = renderedActions
1632
+ .filter(
1633
+ (action): action is { label: string; command: string } =>
1634
+ typeof action.label === 'string' &&
1635
+ typeof action.command === 'string',
1636
+ )
1637
+ .map((action) => ` ${action.label}: ${action.command}`);
1638
+ return { lines, actions };
1639
+ }
1640
+
1641
+ const lines = [' execution statistics:'];
1642
+ const scalars = Array.isArray(view.scalars) ? view.scalars : [];
1643
+ const scalarParts = scalars
1644
+ .filter((entry) => typeof entry.key === 'string')
1645
+ .map((entry) => `${entry.key}=${formatInteger(entry.value)}`);
1646
+ if (scalarParts.length > 0) {
1647
+ lines.push(` ${scalarParts.join(' ')}`);
1648
+ }
1649
+
1650
+ const byStep = Array.isArray(view.byStep) ? view.byStep : [];
1651
+ if (byStep.length > 0) {
1652
+ lines.push(
1653
+ ` byStep=${byStep
1654
+ .slice(0, 8)
1655
+ .map((entry) => {
1656
+ const rate =
1657
+ typeof entry.rate === 'number'
1658
+ ? ` (${(entry.rate * 100).toFixed(entry.rate >= 0.1 ? 0 : 1)}%)`
1659
+ : '';
1660
+ return `${String(entry.stepId)}:${formatInteger(entry.count)}${rate}`;
1661
+ })
1662
+ .join(', ')}`,
1663
+ );
1664
+ }
1665
+
1666
+ const tables = Array.isArray(view.tables) ? view.tables : [];
1667
+ if (tables.length > 0) {
1668
+ lines.push(' tables:');
1669
+ for (const table of tables.slice(0, 6)) {
1670
+ const details = [
1671
+ Array.isArray(table.inputFields) && table.inputFields.length
1672
+ ? `inputs=${table.inputFields.join(',')}`
1673
+ : null,
1674
+ Array.isArray(table.outputFields) && table.outputFields.length
1675
+ ? `outputs=${table.outputFields.join(',')}`
1676
+ : null,
1677
+ Array.isArray(table.waterfallIds) && table.waterfallIds.length
1678
+ ? `waterfalls=${table.waterfallIds.join(',')}`
1679
+ : null,
1680
+ table.columnCounts &&
1681
+ typeof table.columnCounts === 'object' &&
1682
+ !Array.isArray(table.columnCounts)
1683
+ ? `columns=${Object.entries(
1684
+ table.columnCounts as Record<string, unknown>,
1685
+ )
1686
+ .map(([key, count]) => `${key}:${String(count)}`)
1687
+ .join(',')}`
1688
+ : null,
1689
+ ].filter(Boolean);
1690
+ const rowLabel =
1691
+ typeof table.rowCount === 'number'
1692
+ ? `rows=${formatInteger(table.rowCount)} `
1693
+ : '';
1694
+ const lineLabel =
1695
+ typeof table.sourceLines === 'string'
1696
+ ? ` lines ${table.sourceLines}`
1697
+ : '';
1698
+ lines.push(
1699
+ ` ${String(table.tableNamespace ?? 'table')}${lineLabel}: ${rowLabel}${details.join(' ')}`,
1700
+ );
1701
+ }
1702
+ }
1703
+
1704
+ return { lines: lines.length > 1 ? lines : [], actions: [] };
1705
+ }
1706
+
1707
+ function writeStartedPlayRun(input: {
1708
+ runId: string;
1709
+ playName: string;
1710
+ status?: string;
1711
+ statusUrl?: string;
1712
+ dashboardUrl?: string;
1713
+ jsonOutput: boolean;
1714
+ }): void {
1715
+ const payload = {
1716
+ runId: input.runId,
1717
+ workflowId: input.runId,
1718
+ name: input.playName,
1719
+ status: input.status ?? 'started',
1720
+ statusUrl: input.statusUrl,
1721
+ dashboardUrl: input.dashboardUrl,
1722
+ };
1723
+
1724
+ if (input.jsonOutput) {
1725
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
1726
+ return;
1727
+ }
1728
+
1729
+ const lines = [
1730
+ `Started ${input.playName}`,
1731
+ ` run id: ${input.runId}`,
1732
+ ` check status: deepline play status --run-id ${input.runId}`,
1733
+ ` tail logs: deepline play tail --run-id ${input.runId}`,
1734
+ ` stop run: deepline play stop --run-id ${input.runId}`,
1735
+ ` result JSON: deepline play status --run-id ${input.runId} --json`,
1736
+ ];
1737
+
1738
+ if (input.dashboardUrl) {
1739
+ lines.push(` play page: ${input.dashboardUrl}`);
1740
+ }
1741
+
1742
+ console.log(lines.join('\n'));
1743
+ }
1744
+
1745
+ function parsePlayRunOptions(args: string[]): PlayRunCommandOptions {
1746
+ const usage =
1747
+ "Usage: deepline plays run <play-name> [--input '{...}'] [--csv file.csv] [--live|--latest|--revision-id <id>] [--watch] [--out output.csv] [--tail-timeout-ms 30000] [--force]\n" +
1748
+ " deepline plays run <play-file.ts> [--input '{...}'] [--csv file.csv] [--watch] [--out output.csv] [--tail-timeout-ms 30000] [--force]\n" +
1749
+ " deepline plays run --file <play-file.ts> [--input '{...}'] [--csv file.csv] [--watch] [--out output.csv] [--tail-timeout-ms 30000] [--force]\n" +
1750
+ " deepline plays run --name <name> [--input '{...}'] [--csv file.csv] [--live|--latest|--revision-id <id>] [--watch] [--out output.csv] [--tail-timeout-ms 30000] [--force] [--json]";
1751
+ let filePath: string | null = null;
1752
+ let playName: string | null = null;
1753
+ let csvPath: string | null = null;
1754
+ let input: Record<string, unknown> | null = null;
1755
+ let revisionId: string | null = null;
1756
+ let revisionSelector: 'live' | 'latest' | null = null;
1757
+ const watch = args.includes('--watch');
1758
+ let jsonOutput = watch ? args.includes('--json') : argsWantJson(args);
1759
+ const emitLogs = !jsonOutput || args.includes('--logs');
1760
+ const force = args.includes('--force');
1761
+ let outPath: string | null = null;
1762
+ let pollIntervalMs = 500;
1763
+ let waitTimeoutMs: number | null = null;
1764
+
1765
+ for (let index = 0; index < args.length; index += 1) {
1766
+ const arg = args[index]!;
1767
+ if (arg === '--file' && args[index + 1]) {
1768
+ filePath = args[++index]!;
1769
+ continue;
1770
+ }
1771
+ if (arg === '--name' && args[index + 1]) {
1772
+ playName = parseReferencedPlayTarget(args[++index]!).playName;
1773
+ continue;
1774
+ }
1775
+ if (arg === '--csv' && args[index + 1]) {
1776
+ csvPath = resolve(args[++index]!);
1777
+ continue;
1778
+ }
1779
+ if ((arg === '--input' || arg === '-i') && args[index + 1]) {
1780
+ input = parseJsonInput(args[++index]!);
1781
+ continue;
1782
+ }
1783
+ if (arg === '--revision-id' && args[index + 1]) {
1784
+ revisionId = args[++index]!;
1785
+ continue;
1786
+ }
1787
+ if (arg === '--live') {
1788
+ revisionSelector = 'live';
1789
+ continue;
1790
+ }
1791
+ if (arg === '--latest') {
1792
+ revisionSelector = 'latest';
1793
+ continue;
1794
+ }
1795
+ if (arg === '--out' && args[index + 1]) {
1796
+ outPath = resolve(args[++index]!);
1797
+ continue;
1798
+ }
1799
+ if (
1800
+ (arg === '--poll-interval-ms' || arg === '--interval-ms') &&
1801
+ args[index + 1]
1802
+ ) {
1803
+ pollIntervalMs = parsePositiveInteger(args[++index]!, arg);
1804
+ continue;
1805
+ }
1806
+ if (
1807
+ (arg === '--tail-timeout-ms' || arg === '--timeout-ms') &&
1808
+ args[index + 1]
1809
+ ) {
1810
+ waitTimeoutMs = parsePositiveInteger(args[++index]!, arg);
1811
+ continue;
1812
+ }
1813
+ if (
1814
+ arg === '--json' ||
1815
+ arg === '--wait' ||
1816
+ arg === '--tail' ||
1817
+ arg === '--watch' ||
1818
+ arg === '--logs' ||
1819
+ arg === '--force'
1820
+ ) {
1821
+ if (arg === '--watch') {
1822
+ continue;
1823
+ }
1824
+ if (arg === '--json') {
1825
+ jsonOutput = true;
1826
+ continue;
1827
+ }
1828
+ if (arg === '--wait') {
1829
+ throw new Error(
1830
+ '--wait is removed for `plays run`; use `--watch` to stream completion output.',
1831
+ );
1832
+ }
1833
+ if (arg === '--tail') {
1834
+ throw new Error(
1835
+ '--tail is removed for `plays run`; use `--watch` to stream completion output.',
1836
+ );
1837
+ }
1838
+ continue;
1839
+ }
1840
+ if (arg.startsWith('--')) {
1841
+ throw new Error(`Unexpected flag: ${arg}\n${usage}`);
1842
+ }
1843
+ if (!arg.startsWith('--') && !filePath && !playName) {
1844
+ if (isFileTarget(arg) || looksLikeFilePath(arg)) {
1845
+ filePath = arg;
1846
+ } else {
1847
+ playName = parseReferencedPlayTarget(arg).playName;
1848
+ }
1849
+ continue;
1850
+ }
1851
+ }
1852
+
1853
+ if ((filePath && playName) || (!filePath && !playName)) {
1854
+ throw new Error(usage);
1855
+ }
1856
+ const explicitRevisionSelectors = [
1857
+ revisionId ? '--revision-id' : null,
1858
+ revisionSelector === 'live' ? '--live' : null,
1859
+ revisionSelector === 'latest' ? '--latest' : null,
1860
+ ].filter(Boolean);
1861
+ if (explicitRevisionSelectors.length > 1) {
1862
+ throw new Error(
1863
+ `Choose only one revision selector: ${explicitRevisionSelectors.join(', ')}.`,
1864
+ );
1865
+ }
1866
+ if (filePath && explicitRevisionSelectors.length > 0) {
1867
+ throw new Error(
1868
+ '--live, --latest, and --revision-id only apply to named plays.',
1869
+ );
1870
+ }
1871
+ if (outPath && !watch) {
1872
+ throw new Error(
1873
+ '--out requires --watch so the CLI can export the completed run output.',
1874
+ );
1875
+ }
1876
+
1877
+ return {
1878
+ target: filePath
1879
+ ? { kind: 'file', path: filePath }
1880
+ : { kind: 'name', name: playName! },
1881
+ csvPath,
1882
+ input,
1883
+ revisionId,
1884
+ revisionSelector,
1885
+ watch,
1886
+ emitLogs,
1887
+ jsonOutput,
1888
+ pollIntervalMs,
1889
+ waitTimeoutMs,
1890
+ force,
1891
+ outPath,
1892
+ };
1893
+ }
1894
+
1895
+ function parsePlayCheckOptions(args: string[]): PlayCheckCommandOptions {
1896
+ const target = args[0];
1897
+ if (!target) {
1898
+ throw new Error('Usage: deepline play check <play-file.ts> [--json]');
1899
+ }
1900
+
1901
+ const jsonOutput = argsWantJson(args);
1902
+ return { target, jsonOutput };
1903
+ }
1904
+
1905
+ export async function handlePlayCheck(args: string[]): Promise<number> {
1906
+ const options = parsePlayCheckOptions(args);
1907
+ if (!isFileTarget(options.target)) {
1908
+ const resolved = resolve(options.target);
1909
+ console.error(`File not found: ${resolved}`);
1910
+ return 1;
1911
+ }
1912
+
1913
+ const absolutePlayPath = resolve(options.target);
1914
+ const sourceCode = readFileSync(absolutePlayPath, 'utf-8');
1915
+ let graph: {
1916
+ root: BundledPlayFileSuccess;
1917
+ nodes: Map<string, BundledPlayFileSuccess>;
1918
+ };
1919
+ try {
1920
+ graph = await collectBundledPlayGraph(absolutePlayPath);
1921
+ } catch (error) {
1922
+ const message = error instanceof Error ? error.message : String(error);
1923
+ if (options.jsonOutput) {
1924
+ process.stdout.write(
1925
+ `${JSON.stringify({ valid: false, stage: 'bundle', errors: [message] })}\n`,
1926
+ );
1927
+ } else {
1928
+ console.error(message);
1929
+ }
1930
+ return 1;
1931
+ }
1932
+
1933
+ const playName =
1934
+ graph.root.playName ?? extractPlayName(sourceCode, absolutePlayPath);
1935
+ const client = new DeeplineClient();
1936
+ const result = await client.checkPlayArtifact({
1937
+ name: playName,
1938
+ sourceCode: graph.root.sourceCode,
1939
+ artifact: graph.root.artifact,
1940
+ });
1941
+
1942
+ if (options.jsonOutput) {
1943
+ process.stdout.write(`${JSON.stringify({ name: playName, ...result })}\n`);
1944
+ } else if (result.valid) {
1945
+ console.log(`✓ ${playName} passed cloud play check`);
1946
+ if (result.artifactHash) {
1947
+ console.log(` artifact: ${result.artifactHash.slice(0, 12)}`);
1948
+ }
1949
+ } else {
1950
+ console.error(`✗ ${playName} failed cloud play check`);
1951
+ for (const error of result.errors) {
1952
+ console.error(` ${error}`);
1953
+ }
1954
+ }
1955
+
1956
+ return result.valid ? 0 : 1;
1957
+ }
1958
+
1959
+ async function handleFileBackedRun(
1960
+ options: PlayRunCommandOptions,
1961
+ ): Promise<number> {
1962
+ if (options.target.kind !== 'file') {
1963
+ throw new Error('Expected a file-backed play run target.');
1964
+ }
1965
+ const client = new DeeplineClient();
1966
+ const progress =
1967
+ getActiveCliProgress() ?? createCliProgress(!options.jsonOutput);
1968
+ const absolutePlayPath = resolve(options.target.path);
1969
+ recordCliTrace({
1970
+ phase: 'cli.play_run_file_start',
1971
+ playPath: absolutePlayPath,
1972
+ watch: options.watch,
1973
+ hasCsv: Boolean(options.csvPath),
1974
+ force: options.force,
1975
+ });
1976
+ progress.phase('compiling play');
1977
+ const readSourceStartedAt = Date.now();
1978
+ const sourceCode = readFileSync(absolutePlayPath, 'utf-8');
1979
+ recordCliTrace({
1980
+ phase: 'cli.read_play_source',
1981
+ ms: Date.now() - readSourceStartedAt,
1982
+ bytes: sourceCode.length,
1983
+ playPath: absolutePlayPath,
1984
+ });
1985
+ let graph: {
1986
+ root: BundledPlayFileSuccess;
1987
+ nodes: Map<string, BundledPlayFileSuccess>;
1988
+ };
1989
+ try {
1990
+ graph = await traceCliSpan(
1991
+ 'cli.bundle_play_graph',
1992
+ { playPath: absolutePlayPath },
1993
+ () => collectBundledPlayGraph(absolutePlayPath),
1994
+ );
1995
+ await traceCliSpan(
1996
+ 'cli.compile_play_manifest',
1997
+ { playPath: absolutePlayPath, nodeCount: graph.nodes.size },
1998
+ () => compileBundledPlayGraphManifests(client, graph),
1999
+ );
2000
+ progress.phase('compiled play');
2001
+ } catch (error) {
2002
+ progress.fail();
2003
+ console.error(error instanceof Error ? error.message : String(error));
2004
+ return 1;
2005
+ }
2006
+
2007
+ const bundleResult = graph.root;
2008
+ const playName =
2009
+ bundleResult.playName ?? extractPlayName(sourceCode, absolutePlayPath);
2010
+
2011
+ try {
2012
+ progress.phase('publishing imported plays');
2013
+ await traceCliSpan(
2014
+ 'cli.publish_imported_plays',
2015
+ { playName, nodeCount: graph.nodes.size },
2016
+ () => publishImportedPlayDependencies(client, graph),
2017
+ );
2018
+ } catch (error) {
2019
+ progress.fail();
2020
+ console.error(error instanceof Error ? error.message : String(error));
2021
+ return 1;
2022
+ }
2023
+
2024
+ const runtimeInput = options.input ? { ...options.input } : {};
2025
+ const prepareFilesStartedAt = Date.now();
2026
+ const packagedFileUploads = bundleResult.packagedFiles.map((file) =>
2027
+ stageFile(file.logicalPath, file.absolutePath),
2028
+ );
2029
+ const inputFileUpload = options.csvPath
2030
+ ? stageFile(basename(options.csvPath), options.csvPath)
2031
+ : (packagedFileUploads[0] ?? null);
2032
+ if (
2033
+ options.csvPath &&
2034
+ typeof runtimeInput.file !== 'string' &&
2035
+ typeof runtimeInput.csv !== 'string'
2036
+ ) {
2037
+ runtimeInput.file = basename(options.csvPath);
2038
+ }
2039
+ recordCliTrace({
2040
+ phase: 'cli.prepare_input_files',
2041
+ ms: Date.now() - prepareFilesStartedAt,
2042
+ playName,
2043
+ packagedFileCount: packagedFileUploads.length,
2044
+ hasInputFile: Boolean(inputFileUpload),
2045
+ });
2046
+
2047
+ const startRequest = {
2048
+ name: playName,
2049
+ sourceCode: bundleResult.sourceCode,
2050
+ runtimeArtifact: bundleResult.artifact,
2051
+ compilerManifest: requireCompilerManifest(bundleResult),
2052
+ inputFileUpload,
2053
+ packagedFileUploads,
2054
+ ...(Object.keys(runtimeInput).length > 0 ? { input: runtimeInput } : {}),
2055
+ ...(options.force ? { force: true } : {}),
2056
+ };
2057
+
2058
+ if (options.watch) {
2059
+ progress.phase('starting run');
2060
+ const finalStatus = await traceCliSpan(
2061
+ 'cli.start_and_watch',
2062
+ { playName },
2063
+ () =>
2064
+ startAndWaitForPlayCompletionByStream({
2065
+ client,
2066
+ request: startRequest,
2067
+ playName,
2068
+ jsonOutput: options.jsonOutput,
2069
+ emitLogs: options.emitLogs,
2070
+ waitTimeoutMs: options.waitTimeoutMs,
2071
+ progress,
2072
+ }),
2073
+ );
2074
+ const exportStartedAt = Date.now();
2075
+ const exportedPath = exportPlayStatusRows(finalStatus, options.outPath);
2076
+ recordCliTrace({
2077
+ phase: 'cli.export_rows',
2078
+ ms: Date.now() - exportStartedAt,
2079
+ playName,
2080
+ exported: Boolean(exportedPath),
2081
+ });
2082
+ if (finalStatus.status === 'completed') {
2083
+ progress.complete();
2084
+ } else {
2085
+ progress.fail();
2086
+ }
2087
+ recordCliTrace({
2088
+ phase: 'cli.write_play_result',
2089
+ playName,
2090
+ status: finalStatus.status,
2091
+ runId: finalStatus.runId,
2092
+ });
2093
+ writePlayResult(finalStatus, options.jsonOutput, { exportedPath });
2094
+ return finalStatus.status === 'completed' ? 0 : 1;
2095
+ }
2096
+
2097
+ progress.phase('starting run');
2098
+ const started = await traceCliSpan(
2099
+ 'cli.start_run',
2100
+ { playName },
2101
+ () => client.startPlayRun(startRequest),
2102
+ );
2103
+ const fallbackDashboardUrl = buildPlayDashboardUrl(client.baseUrl, playName);
2104
+ const dashboardUrl = started.dashboardUrl ?? fallbackDashboardUrl;
2105
+ progress.phase(`loading play on ${dashboardUrl}`);
2106
+ progress.complete();
2107
+
2108
+ writeStartedPlayRun({
2109
+ runId: started.workflowId,
2110
+ playName,
2111
+ status: started.status,
2112
+ statusUrl: started.statusUrl,
2113
+ dashboardUrl,
2114
+ jsonOutput: options.jsonOutput,
2115
+ });
2116
+ return 0;
2117
+ }
2118
+
2119
+ async function resolveNamedRunRevisionId(input: {
2120
+ client: DeeplineClient;
2121
+ playName: string;
2122
+ revisionId: string | null;
2123
+ selector: 'live' | 'latest' | null;
2124
+ }): Promise<string | null> {
2125
+ if (input.revisionId) {
2126
+ return input.revisionId;
2127
+ }
2128
+ if (input.selector === 'latest') {
2129
+ const versions = await input.client.listPlayVersions(input.playName);
2130
+ const latest = versions[0];
2131
+ if (!latest?._id) {
2132
+ throw new Error(`No saved revisions found for ${input.playName}.`);
2133
+ }
2134
+ return latest._id;
2135
+ }
2136
+ return null;
2137
+ }
2138
+
2139
+ async function handleNamedRun(options: PlayRunCommandOptions): Promise<number> {
2140
+ if (options.target.kind !== 'name') {
2141
+ throw new Error('Expected a named play run target.');
2142
+ }
2143
+ const client = new DeeplineClient();
2144
+ const progress =
2145
+ getActiveCliProgress() ?? createCliProgress(!options.jsonOutput);
2146
+ let stagedInputFile: PlayStagedFileRef | null = null;
2147
+
2148
+ progress.phase('loading play definition');
2149
+ await assertCanonicalNamedPlayReference(client, options.target.name);
2150
+ progress.phase('selecting revision');
2151
+ const selectedRevisionId = await resolveNamedRunRevisionId({
2152
+ client,
2153
+ playName: options.target.name,
2154
+ revisionId: options.revisionId,
2155
+ selector: options.revisionSelector,
2156
+ });
2157
+
2158
+ if (options.csvPath) {
2159
+ progress.phase('staging input file');
2160
+ const [staged] = await client.stagePlayFiles([
2161
+ stageFile(basename(options.csvPath), options.csvPath),
2162
+ ]);
2163
+ stagedInputFile = staged ?? null;
2164
+ }
2165
+
2166
+ const runtimeInput = options.input ? { ...options.input } : {};
2167
+
2168
+ const startRequest = {
2169
+ name: options.target.name,
2170
+ ...(selectedRevisionId ? { revisionId: selectedRevisionId } : {}),
2171
+ ...(Object.keys(runtimeInput).length > 0 ? { input: runtimeInput } : {}),
2172
+ ...(stagedInputFile ? { inputFile: stagedInputFile } : {}),
2173
+ ...(options.force ? { force: true } : {}),
2174
+ };
2175
+
2176
+ if (options.watch) {
2177
+ progress.phase('starting run');
2178
+ const finalStatus = await startAndWaitForPlayCompletionByStream({
2179
+ client,
2180
+ request: startRequest,
2181
+ playName: options.target.name,
2182
+ jsonOutput: options.jsonOutput,
2183
+ emitLogs: options.emitLogs,
2184
+ waitTimeoutMs: options.waitTimeoutMs,
2185
+ progress,
2186
+ });
2187
+ const exportedPath = exportPlayStatusRows(finalStatus, options.outPath);
2188
+ if (finalStatus.status === 'completed') {
2189
+ progress.complete();
2190
+ } else {
2191
+ progress.fail();
2192
+ }
2193
+ writePlayResult(finalStatus, options.jsonOutput, { exportedPath });
2194
+ return finalStatus.status === 'completed' ? 0 : 1;
2195
+ }
2196
+
2197
+ progress.phase('starting run');
2198
+ const started = await client.startPlayRun(startRequest);
2199
+ const fallbackDashboardUrl = buildPlayDashboardUrl(
2200
+ client.baseUrl,
2201
+ options.target.name,
2202
+ );
2203
+ const dashboardUrl = started.dashboardUrl ?? fallbackDashboardUrl;
2204
+ progress.phase(`loading play on ${dashboardUrl}`);
2205
+ progress.complete();
2206
+
2207
+ writeStartedPlayRun({
2208
+ runId: started.workflowId,
2209
+ playName: started.name ?? options.target.name,
2210
+ status: started.status,
2211
+ statusUrl: started.statusUrl,
2212
+ dashboardUrl,
2213
+ jsonOutput: options.jsonOutput,
2214
+ });
2215
+ return 0;
2216
+ }
2217
+
2218
+ export async function handlePlayRun(args: string[]): Promise<number> {
2219
+ const options = parsePlayRunOptions(args);
2220
+ if (options.target.kind === 'file') {
2221
+ if (isFileTarget(options.target.path)) {
2222
+ return handleFileBackedRun(options);
2223
+ }
2224
+ const resolved = resolve(options.target.path);
2225
+ console.error(`File not found: ${resolved}`);
2226
+ // Suggest close matches
2227
+ const dir = dirname(resolved);
2228
+ if (existsSync(dir)) {
2229
+ const base = basename(resolved);
2230
+ try {
2231
+ const siblings = readdirSync(dir).filter(
2232
+ (f) =>
2233
+ f.includes(base.replace(/\.(play\.)?ts$/, '')) ||
2234
+ f.endsWith('.play.ts'),
2235
+ );
2236
+ if (siblings.length > 0) {
2237
+ console.error(`Did you mean one of these?`);
2238
+ for (const s of siblings.slice(0, 5)) {
2239
+ console.error(` ${join(dir, s)}`);
2240
+ }
2241
+ }
2242
+ } catch {}
2243
+ }
2244
+ return 1;
2245
+ }
2246
+ return handleNamedRun(options);
2247
+ }
2248
+
2249
+ export async function handlePlayTail(args: string[]): Promise<number> {
2250
+ const usage =
2251
+ 'Usage: deepline play tail --run-id <run-id> [--interval-ms 1000] [--json]\n' +
2252
+ ' deepline play tail --name <name> [--interval-ms 1000] [--json]';
2253
+ let target: PlayRunTarget;
2254
+ try {
2255
+ target = parsePlayRunTarget({ args, usage, allowName: true });
2256
+ } catch (error) {
2257
+ console.error(error instanceof Error ? error.message : usage);
2258
+ return 1;
2259
+ }
2260
+
2261
+ const client = new DeeplineClient();
2262
+ const jsonOutput = argsWantJson(args);
2263
+ const emitLogs = !jsonOutput || args.includes('--logs');
2264
+ let intervalMs = 500;
2265
+ for (let index = 0; index < args.length; index += 1) {
2266
+ const arg = args[index]!;
2267
+ if (
2268
+ (arg === '--interval-ms' || arg === '--poll-interval-ms') &&
2269
+ args[index + 1]
2270
+ ) {
2271
+ intervalMs = parsePositiveInteger(args[++index]!, arg);
2272
+ }
2273
+ }
2274
+
2275
+ const workflowId = await resolvePlayRunId(client, target);
2276
+ const progress = getActiveCliProgress() ?? createCliProgress(!jsonOutput);
2277
+ progress.phase(`tailing ${workflowId}`);
2278
+ const finalStatus = await waitForPlayCompletion({
2279
+ client,
2280
+ workflowId,
2281
+ pollIntervalMs: intervalMs,
2282
+ jsonOutput,
2283
+ emitLogs,
2284
+ waitTimeoutMs: null,
2285
+ progress,
2286
+ });
2287
+
2288
+ if (finalStatus.status === 'completed') {
2289
+ progress.complete();
2290
+ } else {
2291
+ progress.fail();
2292
+ }
2293
+ writePlayResult(finalStatus, jsonOutput);
2294
+ return finalStatus.status === 'completed' ? 0 : 1;
2295
+ }
2296
+
2297
+ export async function handlePlayStatus(args: string[]): Promise<number> {
2298
+ const usage =
2299
+ 'Usage: deepline play status --run-id <run-id> [--json] [--full]\n' +
2300
+ ' deepline play status --name <name> [--json] [--full]';
2301
+ let target: PlayRunTarget;
2302
+ try {
2303
+ target = parsePlayRunTarget({ args, usage, allowName: true });
2304
+ } catch (error) {
2305
+ console.error(error instanceof Error ? error.message : usage);
2306
+ return 1;
2307
+ }
2308
+
2309
+ const client = new DeeplineClient();
2310
+ const workflowId = await resolvePlayRunId(client, target);
2311
+ const status = await client.getPlayStatus(workflowId);
2312
+ writePlayResult(status, argsWantJson(args), {
2313
+ fullJson: args.includes('--full'),
2314
+ });
2315
+ return 0;
2316
+ }
2317
+
2318
+ function parseRunIdPositional(args: string[], usage: string): string {
2319
+ for (let index = 0; index < args.length; index += 1) {
2320
+ const arg = args[index]!;
2321
+ if (arg === '--json' || arg === '--full' || arg === '--logs') {
2322
+ continue;
2323
+ }
2324
+ if (arg === '--out' && args[index + 1]) {
2325
+ index += 1;
2326
+ continue;
2327
+ }
2328
+ if (!arg.startsWith('--')) {
2329
+ return arg;
2330
+ }
2331
+ }
2332
+ throw new DeeplineError(usage);
2333
+ }
2334
+
2335
+ export async function handleRunStatus(args: string[]): Promise<number> {
2336
+ const usage = 'Usage: deepline runs status <run-id> [--json] [--full]';
2337
+ let runId: string;
2338
+ try {
2339
+ runId = parseRunIdPositional(args, usage);
2340
+ } catch (error) {
2341
+ console.error(error instanceof Error ? error.message : usage);
2342
+ return 1;
2343
+ }
2344
+ const client = new DeeplineClient();
2345
+ const status = await client.getPlayStatus(runId);
2346
+ writePlayResult(status, argsWantJson(args), {
2347
+ fullJson: args.includes('--full'),
2348
+ });
2349
+ return 0;
2350
+ }
2351
+
2352
+ export async function handleRunLogs(args: string[]): Promise<number> {
2353
+ const usage = 'Usage: deepline runs logs <run-id> [--json]';
2354
+ let runId: string;
2355
+ try {
2356
+ runId = parseRunIdPositional(args, usage);
2357
+ } catch (error) {
2358
+ console.error(error instanceof Error ? error.message : usage);
2359
+ return 1;
2360
+ }
2361
+ const client = new DeeplineClient();
2362
+ const status = await client.getPlayStatus(runId);
2363
+ const logs = status.progress?.logs ?? [];
2364
+ if (argsWantJson(args)) {
2365
+ process.stdout.write(`${JSON.stringify({ runId: status.runId, logs })}\n`);
2366
+ } else {
2367
+ process.stdout.write(`${logs.join('\n')}${logs.length > 0 ? '\n' : ''}`);
2368
+ }
2369
+ return 0;
2370
+ }
2371
+
2372
+ export async function handleRunExport(args: string[]): Promise<number> {
2373
+ const usage =
2374
+ 'Usage: deepline runs export <run-id> --out output.csv [--json]';
2375
+ let runId: string;
2376
+ try {
2377
+ runId = parseRunIdPositional(args, usage);
2378
+ } catch (error) {
2379
+ console.error(error instanceof Error ? error.message : usage);
2380
+ return 1;
2381
+ }
2382
+ let outPath: string | null = null;
2383
+ for (let index = 0; index < args.length; index += 1) {
2384
+ const arg = args[index]!;
2385
+ if (arg === '--out' && args[index + 1]) {
2386
+ outPath = resolve(args[++index]!);
2387
+ }
2388
+ }
2389
+ if (!outPath) {
2390
+ console.error(usage);
2391
+ return 1;
2392
+ }
2393
+ const client = new DeeplineClient();
2394
+ const status = await client.getPlayStatus(runId);
2395
+ const exportedPath = exportPlayStatusRows(status, outPath);
2396
+ if (argsWantJson(args)) {
2397
+ const rowsInfo = extractCanonicalRowsInfo(status);
2398
+ process.stdout.write(
2399
+ `${JSON.stringify({
2400
+ runId: status.runId,
2401
+ csv_path: exportedPath,
2402
+ rowCount: rowsInfo?.totalRows ?? null,
2403
+ columns: rowsInfo?.columns ?? [],
2404
+ })}\n`,
2405
+ );
2406
+ } else {
2407
+ console.log(`Exported ${status.runId} to ${exportedPath}`);
2408
+ }
2409
+ return 0;
2410
+ }
2411
+
2412
+ export async function handlePlayStop(args: string[]): Promise<number> {
2413
+ const usage =
2414
+ 'Usage: deepline play stop --run-id <run-id> [--reason "text"] [--json]';
2415
+ let target: PlayRunTarget;
2416
+ try {
2417
+ target = parsePlayRunTarget({ args, usage, allowName: false });
2418
+ } catch (error) {
2419
+ console.error(error instanceof Error ? error.message : usage);
2420
+ return 1;
2421
+ }
2422
+
2423
+ const client = new DeeplineClient();
2424
+ const jsonOutput = argsWantJson(args);
2425
+ let reason: string | undefined;
2426
+ for (let index = 0; index < args.length; index += 1) {
2427
+ const arg = args[index]!;
2428
+ if (arg === '--reason' && args[index + 1]) {
2429
+ reason = args[++index]!;
2430
+ }
2431
+ }
2432
+
2433
+ const workflowId = await resolvePlayRunId(client, target);
2434
+ const result = await client.stopPlay(workflowId, { reason });
2435
+ if (jsonOutput) {
2436
+ process.stdout.write(`${JSON.stringify(result)}\n`);
2437
+ } else {
2438
+ console.log(`Stopped ${result.runId}`);
2439
+ if (result.hitlCancelledCount > 0) {
2440
+ console.log(` cancelled HITL waits: ${result.hitlCancelledCount}`);
2441
+ }
2442
+ }
2443
+ return 0;
2444
+ }
2445
+
2446
+ export async function handlePlayGet(args: string[]): Promise<number> {
2447
+ const target = args[0];
2448
+ if (!target) {
2449
+ console.error('Usage: deepline play get <play-file.ts|play-name> [--json]');
2450
+ return 1;
2451
+ }
2452
+
2453
+ const client = new DeeplineClient();
2454
+ const jsonOutput = argsWantJson(args);
2455
+ const sourceOutput = args.includes('--source');
2456
+ let outPath: string | null = null;
2457
+ for (let index = 1; index < args.length; index += 1) {
2458
+ const arg = args[index]!;
2459
+ if (arg === '--out' && args[index + 1]) {
2460
+ outPath = resolve(args[++index]!);
2461
+ }
2462
+ }
2463
+ const playName = isFileTarget(target)
2464
+ ? extractPlayName(readFileSync(resolve(target), 'utf-8'), resolve(target))
2465
+ : parseReferencedPlayTarget(target).playName;
2466
+ const detail = isFileTarget(target)
2467
+ ? await client.getPlay(playName)
2468
+ : await assertCanonicalNamedPlayReference(client, target);
2469
+ const resolvedSource =
2470
+ detail.play.workingRevision?.sourceCode ??
2471
+ detail.play.liveRevision?.sourceCode ??
2472
+ detail.play.currentRevision?.sourceCode ??
2473
+ detail.play.sourceCode ??
2474
+ '';
2475
+ const materializedFile =
2476
+ sourceOutput || outPath
2477
+ ? materializeRemotePlaySource({
2478
+ target,
2479
+ playName,
2480
+ sourceCode: resolvedSource,
2481
+ outPath,
2482
+ })
2483
+ : null;
2484
+ const loadedMessage = materializedFile
2485
+ ? formatLoadedPlayMessage(materializedFile)
2486
+ : null;
2487
+
2488
+ if (jsonOutput) {
2489
+ process.stdout.write(
2490
+ `${JSON.stringify({
2491
+ ...detail,
2492
+ ...(materializedFile
2493
+ ? {
2494
+ message: loadedMessage,
2495
+ materializedFile: {
2496
+ path: materializedFile.path,
2497
+ created: materializedFile.created,
2498
+ status: materializedFile.status,
2499
+ message: loadedMessage,
2500
+ },
2501
+ }
2502
+ : {}),
2503
+ })}\n`,
2504
+ );
2505
+ return 0;
2506
+ }
2507
+
2508
+ if (sourceOutput) {
2509
+ if (!resolvedSource.trim()) {
2510
+ console.error(`No source code available for ${playName}.`);
2511
+ return 1;
2512
+ }
2513
+ if (materializedFile) {
2514
+ console.log(loadedMessage);
2515
+ return 0;
2516
+ }
2517
+ process.stdout.write(resolvedSource);
2518
+ if (!resolvedSource.endsWith('\n')) {
2519
+ process.stdout.write('\n');
2520
+ }
2521
+ return 0;
2522
+ }
2523
+
2524
+ console.log(`Play: ${formatPlayReference(detail.play)}`);
2525
+ console.log(
2526
+ `Working version: ${detail.play.workingRevision?.version ?? '—'}`,
2527
+ );
2528
+ console.log(`Live version: ${detail.play.liveRevision?.version ?? '—'}`);
2529
+ console.log(`Draft dirty: ${detail.play.isDraftDirty ? 'yes' : 'no'}`);
2530
+ console.log(`Runs: ${detail.latestRuns.length}`);
2531
+ console.log(`Updated: ${formatTimestamp(detail.play.updatedAt)}`);
2532
+ console.log(`Sheet rows: ${detail.sheetSummary?.stats?.total ?? 0}`);
2533
+ if (detail.customerDbUrl) {
2534
+ console.log(`Customer DB: ${detail.customerDbUrl}`);
2535
+ }
2536
+ if (materializedFile) {
2537
+ console.log(loadedMessage);
2538
+ }
2539
+ if (detail.latestRuns.length > 0) {
2540
+ console.log('Latest runs:');
2541
+ for (const run of detail.latestRuns.slice(0, 5)) {
2542
+ console.log(` ${formatRunLine(run)}`);
2543
+ }
2544
+ }
2545
+ return 0;
2546
+ }
2547
+
2548
+ export async function handlePlayRuns(args: string[]): Promise<number> {
2549
+ const nameIndex = args.indexOf('--name');
2550
+ const name = nameIndex >= 0 ? args[nameIndex + 1] : undefined;
2551
+ if (!name) {
2552
+ console.error('Usage: deepline play runs --name <name> [--json]');
2553
+ return 1;
2554
+ }
2555
+
2556
+ const client = new DeeplineClient();
2557
+ const jsonOutput = argsWantJson(args);
2558
+ await assertCanonicalNamedPlayReference(client, name);
2559
+ const runs = await client.listPlayRuns(
2560
+ parseReferencedPlayTarget(name).playName,
2561
+ );
2562
+ if (jsonOutput) {
2563
+ process.stdout.write(`${JSON.stringify({ runs })}\n`);
2564
+ return 0;
2565
+ }
2566
+
2567
+ if (runs.length === 0) {
2568
+ console.log(`No runs found for ${name}.`);
2569
+ return 0;
2570
+ }
2571
+
2572
+ for (const run of runs) {
2573
+ console.log(formatRunLine(run));
2574
+ }
2575
+ return 0;
2576
+ }
2577
+
2578
+ function formatVersionLine(version: PlayRevisionSummary): string {
2579
+ const revisionLabel =
2580
+ version.artifactHash?.slice(0, 12) ?? 'unknown-revision';
2581
+ return `v${version.version} ${revisionLabel} ${formatTimestamp(version.createdAt)}`;
2582
+ }
2583
+
2584
+ export async function handlePlayVersions(args: string[]): Promise<number> {
2585
+ const nameIndex = args.indexOf('--name');
2586
+ const playName = nameIndex >= 0 ? args[nameIndex + 1] : undefined;
2587
+ if (!playName) {
2588
+ console.error('Usage: deepline play versions --name <name> [--json]');
2589
+ return 1;
2590
+ }
2591
+
2592
+ const client = new DeeplineClient();
2593
+ const jsonOutput = argsWantJson(args);
2594
+ await assertCanonicalNamedPlayReference(client, playName);
2595
+ const versions = await client.listPlayVersions(
2596
+ parseReferencedPlayTarget(playName).playName,
2597
+ );
2598
+ if (jsonOutput) {
2599
+ process.stdout.write(`${JSON.stringify({ versions })}\n`);
2600
+ return 0;
2601
+ }
2602
+
2603
+ if (versions.length === 0) {
2604
+ console.log(`No versions found for ${playName}.`);
2605
+ return 0;
2606
+ }
2607
+
2608
+ for (const version of versions) {
2609
+ console.log(formatVersionLine(version));
2610
+ }
2611
+ return 0;
2612
+ }
2613
+
2614
+ export async function handlePlayList(args: string[]): Promise<number> {
2615
+ const jsonOutput = argsWantJson(args);
2616
+ const client = new DeeplineClient();
2617
+ const plays = await client.listPlays();
2618
+
2619
+ if (jsonOutput) {
2620
+ process.stdout.write(`${JSON.stringify(plays)}\n`);
2621
+ return 0;
2622
+ }
2623
+
2624
+ process.stdout.write(`${plays.length} plays available:\n\n`);
2625
+ for (const play of plays) {
2626
+ const flags = [
2627
+ play.origin === 'prebuilt' || play.ownerType === 'deepline'
2628
+ ? 'prebuilt'
2629
+ : 'owned',
2630
+ play.canEdit ? 'editable' : 'readonly',
2631
+ play.isDraftDirty ? 'draft-dirty' : null,
2632
+ ]
2633
+ .filter(Boolean)
2634
+ .join(', ');
2635
+ const reference = formatPlayListReference(play);
2636
+ process.stdout.write(` ${reference}${flags ? ` [${flags}]` : ''}\n`);
2637
+ if (play.inputSchema) {
2638
+ process.stdout.write(' inputSchema: yes\n');
2639
+ }
2640
+ process.stdout.write(` run: deepline plays run ${reference} --watch\n`);
2641
+ }
2642
+ return 0;
2643
+ }
2644
+
2645
+ function parsePlaySearchOptions(args: string[]): PlaySearchOptions {
2646
+ const query = args[0]?.trim();
2647
+ if (!query) {
2648
+ throw new Error(
2649
+ 'Usage: deepline plays search <query> [--origin prebuilt|owned] [--compact] [--json]',
2650
+ );
2651
+ }
2652
+
2653
+ let origin: 'prebuilt' | 'owned' | undefined;
2654
+ for (let index = 1; index < args.length; index += 1) {
2655
+ const arg = args[index]!;
2656
+ if (arg === '--origin' && args[index + 1]) {
2657
+ const rawOrigin = args[++index]!.trim().toLowerCase();
2658
+ if (rawOrigin !== 'prebuilt' && rawOrigin !== 'owned') {
2659
+ throw new Error(`Invalid value for --origin: ${rawOrigin}`);
2660
+ }
2661
+ origin = rawOrigin;
2662
+ }
2663
+ }
2664
+
2665
+ return {
2666
+ query,
2667
+ jsonOutput: argsWantJson(args),
2668
+ compact: args.includes('--compact'),
2669
+ origin,
2670
+ };
2671
+ }
2672
+
2673
+ function printPlayDescription(play: PlayDescription): void {
2674
+ const reference = formatPlayListReference(play);
2675
+ const labels = [
2676
+ play.origin ?? null,
2677
+ play.ownerType ?? null,
2678
+ play.canEdit ? 'editable' : 'readonly',
2679
+ play.isDraftDirty ? 'draft-dirty' : null,
2680
+ ].filter(Boolean);
2681
+ console.log(
2682
+ `Play: ${reference}${labels.length ? ` [${labels.join(', ')}]` : ''}`,
2683
+ );
2684
+ if (play.displayName && play.displayName !== play.name) {
2685
+ console.log(` Display name: ${play.displayName}`);
2686
+ }
2687
+ if (play.aliases.length > 0) {
2688
+ console.log(` Aliases: ${play.aliases.join(', ')}`);
2689
+ }
2690
+ if (play.inputSchema) {
2691
+ console.log(' Input schema:');
2692
+ const rendered = JSON.stringify(play.inputSchema, null, 2);
2693
+ for (const line of rendered.split('\n')) {
2694
+ console.log(` ${line}`);
2695
+ }
2696
+ }
2697
+ if (play.outputSchema) {
2698
+ console.log(' Output schema:');
2699
+ const rendered = JSON.stringify(play.outputSchema, null, 2);
2700
+ for (const line of rendered.split('\n')) {
2701
+ console.log(` ${line}`);
2702
+ }
2703
+ }
2704
+ console.log(` Run: ${play.runCommand}`);
2705
+ }
2706
+
2707
+ export async function handlePlaySearch(args: string[]): Promise<number> {
2708
+ let options: PlaySearchOptions;
2709
+ try {
2710
+ options = parsePlaySearchOptions(args);
2711
+ } catch (error) {
2712
+ console.error(error instanceof Error ? error.message : String(error));
2713
+ return 1;
2714
+ }
2715
+
2716
+ const client = new DeeplineClient();
2717
+ const plays = await client.searchPlays({
2718
+ query: options.query,
2719
+ ...(options.origin ? { origin: options.origin } : {}),
2720
+ compact: options.compact,
2721
+ });
2722
+
2723
+ if (options.jsonOutput) {
2724
+ process.stdout.write(`${JSON.stringify({ plays })}\n`);
2725
+ return 0;
2726
+ }
2727
+
2728
+ process.stdout.write(`${plays.length} plays found:\n\n`);
2729
+ for (const play of plays) {
2730
+ printPlayDescription(play);
2731
+ console.log('');
2732
+ }
2733
+ return 0;
2734
+ }
2735
+
2736
+ export async function handlePlayDescribe(args: string[]): Promise<number> {
2737
+ const playName = args[0];
2738
+ if (!playName) {
2739
+ console.error(
2740
+ 'Usage: deepline plays describe <play-name> [--compact] [--json]',
2741
+ );
2742
+ return 1;
2743
+ }
2744
+
2745
+ const client = new DeeplineClient();
2746
+ await assertCanonicalNamedPlayReference(client, playName);
2747
+ const play = await client.describePlay(
2748
+ parseReferencedPlayTarget(playName).playName,
2749
+ {
2750
+ compact: args.includes('--compact'),
2751
+ },
2752
+ );
2753
+
2754
+ if (argsWantJson(args)) {
2755
+ process.stdout.write(`${JSON.stringify(play)}\n`);
2756
+ return 0;
2757
+ }
2758
+
2759
+ printPlayDescription(play);
2760
+ return 0;
2761
+ }
2762
+
2763
+ export async function handlePlayPublish(args: string[]): Promise<number> {
2764
+ const playName = args[0];
2765
+ if (!playName) {
2766
+ console.error(
2767
+ 'Usage: deepline play publish <play-file.ts|play-name> [--latest|--revision-id <id>] [--json]',
2768
+ );
2769
+ return 1;
2770
+ }
2771
+
2772
+ let revisionId: string | undefined;
2773
+ let useLatest = false;
2774
+ for (let index = 1; index < args.length; index += 1) {
2775
+ const arg = args[index]!;
2776
+ if (arg === '--revision-id' && args[index + 1]) {
2777
+ revisionId = args[++index]!;
2778
+ }
2779
+ if (arg === '--latest') {
2780
+ useLatest = true;
2781
+ }
2782
+ }
2783
+ if (revisionId && useLatest) {
2784
+ console.error('Choose only one live target: --latest or --revision-id.');
2785
+ return 1;
2786
+ }
2787
+
2788
+ const client = new DeeplineClient();
2789
+ if (isFileTarget(playName)) {
2790
+ if (revisionId || useLatest) {
2791
+ console.error(
2792
+ '--latest and --revision-id cannot be used when the target is a local play file.',
2793
+ );
2794
+ return 1;
2795
+ }
2796
+
2797
+ let graph: {
2798
+ root: BundledPlayFileSuccess;
2799
+ nodes: Map<string, BundledPlayFileSuccess>;
2800
+ };
2801
+ try {
2802
+ graph = await collectBundledPlayGraph(resolve(playName));
2803
+ await compileBundledPlayGraphManifests(client, graph);
2804
+ await publishImportedPlayDependencies(client, graph);
2805
+ } catch (error) {
2806
+ console.error(error instanceof Error ? error.message : String(error));
2807
+ return 1;
2808
+ }
2809
+
2810
+ const rootPlayName =
2811
+ graph.root.playName ??
2812
+ extractPlayName(graph.root.sourceCode, graph.root.filePath);
2813
+ const published = await client.registerPlayArtifact({
2814
+ name: rootPlayName,
2815
+ sourceCode: graph.root.sourceCode,
2816
+ artifact: graph.root.artifact,
2817
+ compilerManifest: requireCompilerManifest(graph.root),
2818
+ publish: true,
2819
+ });
2820
+ process.stdout.write(
2821
+ `${JSON.stringify({
2822
+ success: true,
2823
+ name: rootPlayName,
2824
+ liveVersion: published.version ?? null,
2825
+ revisionId: published.revisionId ?? null,
2826
+ triggerMetadata: published.triggerMetadata ?? null,
2827
+ triggerBindings: published.triggerBindings ?? [],
2828
+ })}\n`,
2829
+ );
2830
+ return 0;
2831
+ }
2832
+
2833
+ const resolvedName = parseReferencedPlayTarget(playName).playName;
2834
+ if (useLatest) {
2835
+ const versions = await client.listPlayVersions(resolvedName);
2836
+ const latest = versions[0];
2837
+ if (!latest?._id) {
2838
+ console.error(`No saved revisions found for ${resolvedName}.`);
2839
+ return 1;
2840
+ }
2841
+ revisionId = latest._id;
2842
+ }
2843
+ try {
2844
+ await ensureEditableRemotePlay(client, resolvedName);
2845
+ } catch (error) {
2846
+ console.error(error instanceof Error ? error.message : String(error));
2847
+ return 1;
2848
+ }
2849
+ const result = await client.publishPlayVersion(
2850
+ resolvedName,
2851
+ revisionId ? { revisionId } : {},
2852
+ );
2853
+ process.stdout.write(`${JSON.stringify(result)}\n`);
2854
+ return result.success ? 0 : 1;
2855
+ }
2856
+
2857
+ export function registerPlayCommands(program: Command): void {
2858
+ const play = program
2859
+ .command('plays')
2860
+ .alias('play')
2861
+ .description('Search, validate, run, and manage cloud plays.')
2862
+ .addHelpText(
2863
+ 'after',
2864
+ `
2865
+ Concepts:
2866
+ Plays are durable Deepline cloud workflows. Local .play.ts files are bundled locally,
2867
+ then validated and executed in Deepline cloud.
2868
+
2869
+ Common commands:
2870
+ deepline plays search email --json
2871
+ deepline plays describe person-linkedin-to-email --json
2872
+ deepline plays check my.play.ts
2873
+ deepline plays run my.play.ts --input '{"domain":"stripe.com"}' --watch
2874
+ deepline plays get person-linkedin-to-email --json
2875
+ `,
2876
+ );
2877
+
2878
+ play
2879
+ .command('check <target>')
2880
+ .description('Bundle-check a local play file.')
2881
+ .addHelpText(
2882
+ 'after',
2883
+ `
2884
+ Notes:
2885
+ Validates a local play without storing it, promoting it, or starting a run.
2886
+ This uses the authoritative cloud preflight path.
2887
+
2888
+ Examples:
2889
+ deepline plays check my.play.ts
2890
+ deepline plays check my.play.ts --json
2891
+ `,
2892
+ )
2893
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
2894
+ .action(async (target, options) => {
2895
+ process.exitCode = await handlePlayCheck([
2896
+ target,
2897
+ ...(options.json ? ['--json'] : []),
2898
+ ]);
2899
+ });
2900
+
2901
+ play
2902
+ .command('run [target]')
2903
+ .description('Run a play file or named play.')
2904
+ .addHelpText(
2905
+ 'after',
2906
+ `
2907
+ Notes:
2908
+ Local play files are bundled locally, then validated and executed in Deepline cloud.
2909
+ Named plays run the stored live cloud revision.
2910
+ Run performs server preflight automatically. Use \`deepline plays check <file>\`
2911
+ to validate without starting a run.
2912
+
2913
+ Examples:
2914
+ deepline plays run my.play.ts --input '{"domain":"stripe.com"}' --watch
2915
+ deepline plays run person-linkedin-to-email --input '{"linkedin_url":"..."}' --watch
2916
+ deepline plays run enrich.play.ts --csv leads.csv --watch --out leads-enriched.csv
2917
+ `,
2918
+ )
2919
+ .option('--file <path>', 'Local play file to run')
2920
+ .option('--name <name>', 'Saved play name to run')
2921
+ .option('--csv <path>', 'Attach a CSV file')
2922
+ .option('-i, --input <json>', 'Input JSON object or @file path')
2923
+ .option('--live', 'Run the current live revision explicitly')
2924
+ .option('--latest', 'Run the newest saved revision, even if it is not live')
2925
+ .option(
2926
+ '--revision-id <id>',
2927
+ 'Run a specific saved revision instead of the live revision',
2928
+ )
2929
+ .option(
2930
+ '--out <path>',
2931
+ 'Write the completed row output to CSV; requires --watch',
2932
+ )
2933
+ .option('--watch', 'Stream logs until completion')
2934
+ .option(
2935
+ '--logs',
2936
+ 'When output is non-interactive, stream play logs to stderr while waiting',
2937
+ )
2938
+ .option('--poll-interval-ms <ms>', 'Polling interval while tailing')
2939
+ .option('--tail-timeout-ms <ms>', 'Timeout while tailing')
2940
+ .option('--force', 'Supersede any active runs for this play')
2941
+ .option('--json', 'Emit JSON output')
2942
+ .action(async (target, options) => {
2943
+ process.exitCode = await handlePlayRun([
2944
+ ...(target ? [target] : []),
2945
+ ...(options.file ? ['--file', options.file] : []),
2946
+ ...(options.name ? ['--name', options.name] : []),
2947
+ ...(options.csv ? ['--csv', options.csv] : []),
2948
+ ...(options.input ? ['--input', options.input] : []),
2949
+ ...(options.live ? ['--live'] : []),
2950
+ ...(options.latest ? ['--latest'] : []),
2951
+ ...(options.revisionId ? ['--revision-id', options.revisionId] : []),
2952
+ ...(options.out ? ['--out', options.out] : []),
2953
+ ...(options.watch ? ['--watch'] : []),
2954
+ ...(options.logs ? ['--logs'] : []),
2955
+ ...(options.pollIntervalMs
2956
+ ? ['--poll-interval-ms', options.pollIntervalMs]
2957
+ : []),
2958
+ ...(options.tailTimeoutMs
2959
+ ? ['--tail-timeout-ms', options.tailTimeoutMs]
2960
+ : []),
2961
+ ...(options.force ? ['--force'] : []),
2962
+ ...(options.json ? ['--json'] : []),
2963
+ ]);
2964
+ });
2965
+
2966
+ play
2967
+ .command('get <target>')
2968
+ .description('Fetch full play details.')
2969
+ .addHelpText(
2970
+ 'after',
2971
+ `
2972
+ Notes:
2973
+ Full-detail read for metadata, revisions/source fields, latest runs, sheet state,
2974
+ customer DB state, and local file comparison. Use \`describe\` for the compact
2975
+ contract and examples.
2976
+
2977
+ Examples:
2978
+ deepline plays get person-linkedin-to-email
2979
+ deepline plays get person-linkedin-to-email --json | jq '.play.liveRevision'
2980
+ `,
2981
+ )
2982
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
2983
+ .addOption(
2984
+ new Option('--source', 'Materialize or print the source code').hideHelp(),
2985
+ )
2986
+ .addOption(
2987
+ new Option('--out <path>', 'Write source to a specific path').hideHelp(),
2988
+ )
2989
+ .action(async (target, options) => {
2990
+ process.exitCode = await handlePlayGet([
2991
+ target,
2992
+ ...(options.json ? ['--json'] : []),
2993
+ ...(options.source ? ['--source'] : []),
2994
+ ...(options.out ? ['--out', options.out] : []),
2995
+ ]);
2996
+ });
2997
+
2998
+ play
2999
+ .command('list')
3000
+ .description('List saved and prebuilt plays.')
3001
+ .option('--json', 'Emit JSON output')
3002
+ .action(async (options) => {
3003
+ process.exitCode = await handlePlayList([
3004
+ ...(options.json ? ['--json'] : []),
3005
+ ]);
3006
+ });
3007
+
3008
+ play
3009
+ .command('search <query>')
3010
+ .description('Search saved and prebuilt plays.')
3011
+ .option('--origin <origin>', 'Filter to prebuilt or owned plays')
3012
+ .option('--compact', 'Emit compact schemas')
3013
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3014
+ .action(async (query, options) => {
3015
+ process.exitCode = await handlePlaySearch([
3016
+ query,
3017
+ ...(options.origin ? ['--origin', options.origin] : []),
3018
+ ...(options.compact ? ['--compact'] : []),
3019
+ ...(options.json ? ['--json'] : []),
3020
+ ]);
3021
+ });
3022
+
3023
+ play
3024
+ .command('describe <target>')
3025
+ .description('Describe a play contract and how to run it.')
3026
+ .addHelpText(
3027
+ 'after',
3028
+ `
3029
+ Notes:
3030
+ Compact contract read for schemas, aliases, examples, ownership, and the run command.
3031
+ Use \`get\` when you need full metadata, revisions, source fields, or latest runs.
3032
+
3033
+ Examples:
3034
+ deepline plays describe person-linkedin-to-email
3035
+ deepline plays describe person-linkedin-to-email --json
3036
+ `,
3037
+ )
3038
+ .option('--compact', 'Emit compact schemas')
3039
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3040
+ .action(async (target, options) => {
3041
+ process.exitCode = await handlePlayDescribe([
3042
+ target,
3043
+ ...(options.compact ? ['--compact'] : []),
3044
+ ...(options.json ? ['--json'] : []),
3045
+ ]);
3046
+ });
3047
+
3048
+ play
3049
+ .command('runs')
3050
+ .description('List runs for a named play.')
3051
+ .option('--name <name>', 'Saved play name')
3052
+ .option('--json', 'Emit JSON output')
3053
+ .action(async (options) => {
3054
+ process.exitCode = await handlePlayRuns([
3055
+ ...(options.name ? ['--name', options.name] : []),
3056
+ ...(options.json ? ['--json'] : []),
3057
+ ]);
3058
+ });
3059
+
3060
+ play
3061
+ .command('versions')
3062
+ .description('List revisions for a named play.')
3063
+ .option('--name <name>', 'Saved play name')
3064
+ .option('--json', 'Emit JSON output')
3065
+ .action(async (options) => {
3066
+ process.exitCode = await handlePlayVersions([
3067
+ ...(options.name ? ['--name', options.name] : []),
3068
+ ...(options.json ? ['--json'] : []),
3069
+ ]);
3070
+ });
3071
+
3072
+ play
3073
+ .command('tail')
3074
+ .description('Tail events for a play run.')
3075
+ .option('--run-id <runId>', 'Run id to tail')
3076
+ .option('--name <name>', 'Tail the latest run for a named play')
3077
+ .option('--interval-ms <ms>', 'Polling interval while tailing')
3078
+ .option('--logs', 'With --json, stream play logs to stderr while waiting')
3079
+ .option('--json', 'Emit JSON output')
3080
+ .action(async (options) => {
3081
+ process.exitCode = await handlePlayTail([
3082
+ ...(options.runId ? ['--run-id', options.runId] : []),
3083
+ ...(options.name ? ['--name', options.name] : []),
3084
+ ...(options.intervalMs ? ['--interval-ms', options.intervalMs] : []),
3085
+ ...(options.logs ? ['--logs'] : []),
3086
+ ...(options.json ? ['--json'] : []),
3087
+ ]);
3088
+ });
3089
+
3090
+ play
3091
+ .command('status')
3092
+ .description('Show status for a play run.')
3093
+ .option('--run-id <runId>', 'Run id to inspect')
3094
+ .option('--name <name>', 'Inspect the latest run for a named play')
3095
+ .option('--json', 'Emit JSON output')
3096
+ .option('--full', 'With --json, emit the full raw status payload')
3097
+ .action(async (options) => {
3098
+ process.exitCode = await handlePlayStatus([
3099
+ ...(options.runId ? ['--run-id', options.runId] : []),
3100
+ ...(options.name ? ['--name', options.name] : []),
3101
+ ...(options.json ? ['--json'] : []),
3102
+ ...(options.full ? ['--full'] : []),
3103
+ ]);
3104
+ });
3105
+
3106
+ play
3107
+ .command('export')
3108
+ .description('Export a completed play run to CSV.')
3109
+ .requiredOption('--run-id <runId>', 'Run id to export')
3110
+ .requiredOption('--out <path>', 'Output CSV path')
3111
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3112
+ .action(async (options) => {
3113
+ process.exitCode = await handleRunExport([
3114
+ options.runId,
3115
+ '--out',
3116
+ options.out,
3117
+ ...(options.json ? ['--json'] : []),
3118
+ ]);
3119
+ });
3120
+
3121
+ play
3122
+ .command('stop')
3123
+ .description('Stop a play run.')
3124
+ .option('--run-id <runId>', 'Run id to stop')
3125
+ .option('--reason <text>', 'Reason to include with the stop request')
3126
+ .option('--json', 'Emit JSON output')
3127
+ .action(async (options) => {
3128
+ process.exitCode = await handlePlayStop([
3129
+ ...(options.runId ? ['--run-id', options.runId] : []),
3130
+ ...(options.reason ? ['--reason', options.reason] : []),
3131
+ ...(options.json ? ['--json'] : []),
3132
+ ]);
3133
+ });
3134
+
3135
+ play
3136
+ .command('publish <target>')
3137
+ .description('Bundle, validate, save, and publish a play.')
3138
+ .option('--latest', 'Promote the newest saved revision')
3139
+ .option('--revision-id <id>', 'Revision to promote')
3140
+ .option('--json', 'Emit JSON output')
3141
+ .action(async (target, options) => {
3142
+ process.exitCode = await handlePlayPublish([
3143
+ target,
3144
+ ...(options.latest ? ['--latest'] : []),
3145
+ ...(options.revisionId ? ['--revision-id', options.revisionId] : []),
3146
+ ...(options.json ? ['--json'] : []),
3147
+ ]);
3148
+ });
3149
+
3150
+ const runs = program
3151
+ .command('runs')
3152
+ .description('Inspect and export play runs.')
3153
+ .addHelpText(
3154
+ 'after',
3155
+ `
3156
+ Examples:
3157
+ deepline runs status play/my-play/run/20260501t000000-000
3158
+ deepline runs export play/my-play/run/20260501t000000-000 --out output.csv
3159
+ deepline runs logs play/my-play/run/20260501t000000-000
3160
+ `,
3161
+ );
3162
+
3163
+ runs
3164
+ .command('status <runId>')
3165
+ .description('Show compact status for a play run.')
3166
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3167
+ .option('--full', 'With --json, emit the full raw status payload')
3168
+ .action(async (runId, options) => {
3169
+ process.exitCode = await handleRunStatus([
3170
+ runId,
3171
+ ...(options.json ? ['--json'] : []),
3172
+ ...(options.full ? ['--full'] : []),
3173
+ ]);
3174
+ });
3175
+
3176
+ runs
3177
+ .command('export <runId>')
3178
+ .description('Export the completed row output for a play run to CSV.')
3179
+ .requiredOption('--out <path>', 'Output CSV path')
3180
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3181
+ .action(async (runId, options) => {
3182
+ process.exitCode = await handleRunExport([
3183
+ runId,
3184
+ '--out',
3185
+ options.out,
3186
+ ...(options.json ? ['--json'] : []),
3187
+ ]);
3188
+ });
3189
+
3190
+ runs
3191
+ .command('logs <runId>')
3192
+ .description('Print logs for a play run.')
3193
+ .option('--json', 'Emit JSON output. Also automatic when stdout is piped')
3194
+ .action(async (runId, options) => {
3195
+ process.exitCode = await handleRunLogs([
3196
+ runId,
3197
+ ...(options.json ? ['--json'] : []),
3198
+ ]);
3199
+ });
3200
+ }