deepline 0.1.100 → 0.1.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -260,10 +260,15 @@ var SDK_RELEASE = {
260
260
  // 0.1.94 is claimed by PR #1527 — this watch-render fix ships as 0.1.95.
261
261
  // 0.1.98 ships the duplicate-browser-tab fix (default-browser detection).
262
262
  // 0.1.99 ships prebuilt job-change source-column preservation and validation fixes.
263
- version: "0.1.100",
263
+ // 0.1.101 ships retryable play artifact publish failures and CI retry hardening.
264
+ // 0.1.102 ships the job-change ledger fixes: recovered-dataset export on
265
+ // failed runs, persisted/succeeded/failed row counts, strict local CSV
266
+ // preflight (existence, data rows, quotes, duplicate headers), HTML error
267
+ // scrubbing, and word-boundary watch truncation.
268
+ version: "0.1.102",
264
269
  apiContract: "2026-06-dataset-column-cell-stale-hard-cutover",
265
270
  supportPolicy: {
266
- latest: "0.1.100",
271
+ latest: "0.1.102",
267
272
  minimumSupported: "0.1.53",
268
273
  deprecatedBelow: "0.1.53"
269
274
  }
@@ -414,6 +419,22 @@ var HttpClient = class {
414
419
  parsed = body;
415
420
  }
416
421
  if (!response.ok) {
422
+ const htmlError = detectHtmlErrorBody(
423
+ body,
424
+ response.headers.get("content-type")
425
+ );
426
+ if (htmlError) {
427
+ throw new DeeplineError(
428
+ htmlError.message(response.status),
429
+ response.status,
430
+ "API_ERROR",
431
+ {
432
+ htmlErrorPage: true,
433
+ ...htmlError.title ? { title: htmlError.title } : {},
434
+ ...htmlError.workerThrewException ? { workerThrewException: true } : {}
435
+ }
436
+ );
437
+ }
417
438
  const errorValue = typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : void 0;
418
439
  const msg = typeof errorValue === "string" ? errorValue : errorValue && typeof errorValue === "object" && "message" in errorValue && typeof errorValue.message === "string" ? errorValue.message : typeof parsed === "object" && parsed && "message" in parsed && typeof parsed.message === "string" ? parsed.message : `HTTP ${response.status}`;
419
440
  throw new DeeplineError(msg, response.status, "API_ERROR", {
@@ -474,6 +495,22 @@ var HttpClient = class {
474
495
  }
475
496
  if (!response.ok) {
476
497
  const body = await response.text();
498
+ const htmlError = detectHtmlErrorBody(
499
+ body,
500
+ response.headers.get("content-type")
501
+ );
502
+ if (htmlError) {
503
+ throw new DeeplineError(
504
+ htmlError.message(response.status),
505
+ response.status,
506
+ "API_ERROR",
507
+ {
508
+ htmlErrorPage: true,
509
+ ...htmlError.title ? { title: htmlError.title } : {},
510
+ ...htmlError.workerThrewException ? { workerThrewException: true } : {}
511
+ }
512
+ );
513
+ }
477
514
  const parsed = parseResponseBody(body);
478
515
  throw new DeeplineError(
479
516
  apiErrorMessage(parsed, response.status),
@@ -539,6 +576,31 @@ function parseResponseBody(body) {
539
576
  return body;
540
577
  }
541
578
  }
579
+ function detectHtmlErrorBody(body, contentType) {
580
+ const trimmed = body.trim();
581
+ const lower = trimmed.toLowerCase();
582
+ const isHtml = (contentType ?? "").toLowerCase().includes("text/html") || lower.startsWith("<!doctype") || lower.startsWith("<html");
583
+ if (!isHtml) {
584
+ return null;
585
+ }
586
+ const titleMatch = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
587
+ const title = titleMatch?.[1]?.replace(/\s+/g, " ").trim() || void 0;
588
+ const workerThrewException = /worker threw exception/i.test(trimmed);
589
+ return {
590
+ title,
591
+ workerThrewException,
592
+ message: (status) => {
593
+ const segments = [`HTTP ${status}`];
594
+ if (workerThrewException) {
595
+ segments.push("Worker threw exception");
596
+ }
597
+ if (title) {
598
+ segments.push(title);
599
+ }
600
+ return `${segments.join(": ")} (Cloudflare HTML error page suppressed)`;
601
+ }
602
+ };
603
+ }
542
604
  function apiErrorMessage(parsed, status) {
543
605
  const errorValue = typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : void 0;
544
606
  if (typeof errorValue === "string") {
@@ -634,7 +696,7 @@ function isTransientPlayStreamError(error) {
634
696
  return error.statusCode >= 500 && error.statusCode < 600;
635
697
  }
636
698
  const text = error instanceof Error ? error.message : String(error);
637
- return /auth validation backend timed out|fetch failed|eaddrnotavail|econnreset|etimedout|eai_again|socket hang up/i.test(
699
+ return /auth validation backend timed out|coordinator \/submit(?:\?[^ ]*)? 5\d\d|Worker threw exception|Internal Server Error|Service Unavailable|fetch failed|eaddrnotavail|econnreset|etimedout|eai_again|socket hang up/i.test(
638
700
  text
639
701
  );
640
702
  }
@@ -866,6 +928,10 @@ function buildSnapshotFromLedger(snapshot) {
866
928
  return {
867
929
  runId: snapshot.runId,
868
930
  status: normalizePlayRunLiveStatus(snapshot.status),
931
+ createdAt: snapshot.createdAt ?? null,
932
+ startedAt: snapshot.startedAt ?? null,
933
+ finishedAt: snapshot.finishedAt ?? null,
934
+ durationMs: snapshot.durationMs ?? null,
869
935
  updatedAt: snapshot.updatedAt ?? snapshot.finishedAt ?? snapshot.startedAt ?? null,
870
936
  logs: snapshot.logTail,
871
937
  totalLogCount: snapshot.totalLogCount,
package/dist/index.mjs CHANGED
@@ -182,10 +182,15 @@ var SDK_RELEASE = {
182
182
  // 0.1.94 is claimed by PR #1527 — this watch-render fix ships as 0.1.95.
183
183
  // 0.1.98 ships the duplicate-browser-tab fix (default-browser detection).
184
184
  // 0.1.99 ships prebuilt job-change source-column preservation and validation fixes.
185
- version: "0.1.100",
185
+ // 0.1.101 ships retryable play artifact publish failures and CI retry hardening.
186
+ // 0.1.102 ships the job-change ledger fixes: recovered-dataset export on
187
+ // failed runs, persisted/succeeded/failed row counts, strict local CSV
188
+ // preflight (existence, data rows, quotes, duplicate headers), HTML error
189
+ // scrubbing, and word-boundary watch truncation.
190
+ version: "0.1.102",
186
191
  apiContract: "2026-06-dataset-column-cell-stale-hard-cutover",
187
192
  supportPolicy: {
188
- latest: "0.1.100",
193
+ latest: "0.1.102",
189
194
  minimumSupported: "0.1.53",
190
195
  deprecatedBelow: "0.1.53"
191
196
  }
@@ -336,6 +341,22 @@ var HttpClient = class {
336
341
  parsed = body;
337
342
  }
338
343
  if (!response.ok) {
344
+ const htmlError = detectHtmlErrorBody(
345
+ body,
346
+ response.headers.get("content-type")
347
+ );
348
+ if (htmlError) {
349
+ throw new DeeplineError(
350
+ htmlError.message(response.status),
351
+ response.status,
352
+ "API_ERROR",
353
+ {
354
+ htmlErrorPage: true,
355
+ ...htmlError.title ? { title: htmlError.title } : {},
356
+ ...htmlError.workerThrewException ? { workerThrewException: true } : {}
357
+ }
358
+ );
359
+ }
339
360
  const errorValue = typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : void 0;
340
361
  const msg = typeof errorValue === "string" ? errorValue : errorValue && typeof errorValue === "object" && "message" in errorValue && typeof errorValue.message === "string" ? errorValue.message : typeof parsed === "object" && parsed && "message" in parsed && typeof parsed.message === "string" ? parsed.message : `HTTP ${response.status}`;
341
362
  throw new DeeplineError(msg, response.status, "API_ERROR", {
@@ -396,6 +417,22 @@ var HttpClient = class {
396
417
  }
397
418
  if (!response.ok) {
398
419
  const body = await response.text();
420
+ const htmlError = detectHtmlErrorBody(
421
+ body,
422
+ response.headers.get("content-type")
423
+ );
424
+ if (htmlError) {
425
+ throw new DeeplineError(
426
+ htmlError.message(response.status),
427
+ response.status,
428
+ "API_ERROR",
429
+ {
430
+ htmlErrorPage: true,
431
+ ...htmlError.title ? { title: htmlError.title } : {},
432
+ ...htmlError.workerThrewException ? { workerThrewException: true } : {}
433
+ }
434
+ );
435
+ }
399
436
  const parsed = parseResponseBody(body);
400
437
  throw new DeeplineError(
401
438
  apiErrorMessage(parsed, response.status),
@@ -461,6 +498,31 @@ function parseResponseBody(body) {
461
498
  return body;
462
499
  }
463
500
  }
501
+ function detectHtmlErrorBody(body, contentType) {
502
+ const trimmed = body.trim();
503
+ const lower = trimmed.toLowerCase();
504
+ const isHtml = (contentType ?? "").toLowerCase().includes("text/html") || lower.startsWith("<!doctype") || lower.startsWith("<html");
505
+ if (!isHtml) {
506
+ return null;
507
+ }
508
+ const titleMatch = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
509
+ const title = titleMatch?.[1]?.replace(/\s+/g, " ").trim() || void 0;
510
+ const workerThrewException = /worker threw exception/i.test(trimmed);
511
+ return {
512
+ title,
513
+ workerThrewException,
514
+ message: (status) => {
515
+ const segments = [`HTTP ${status}`];
516
+ if (workerThrewException) {
517
+ segments.push("Worker threw exception");
518
+ }
519
+ if (title) {
520
+ segments.push(title);
521
+ }
522
+ return `${segments.join(": ")} (Cloudflare HTML error page suppressed)`;
523
+ }
524
+ };
525
+ }
464
526
  function apiErrorMessage(parsed, status) {
465
527
  const errorValue = typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : void 0;
466
528
  if (typeof errorValue === "string") {
@@ -556,7 +618,7 @@ function isTransientPlayStreamError(error) {
556
618
  return error.statusCode >= 500 && error.statusCode < 600;
557
619
  }
558
620
  const text = error instanceof Error ? error.message : String(error);
559
- return /auth validation backend timed out|fetch failed|eaddrnotavail|econnreset|etimedout|eai_again|socket hang up/i.test(
621
+ return /auth validation backend timed out|coordinator \/submit(?:\?[^ ]*)? 5\d\d|Worker threw exception|Internal Server Error|Service Unavailable|fetch failed|eaddrnotavail|econnreset|etimedout|eai_again|socket hang up/i.test(
560
622
  text
561
623
  );
562
624
  }
@@ -788,6 +850,10 @@ function buildSnapshotFromLedger(snapshot) {
788
850
  return {
789
851
  runId: snapshot.runId,
790
852
  status: normalizePlayRunLiveStatus(snapshot.status),
853
+ createdAt: snapshot.createdAt ?? null,
854
+ startedAt: snapshot.startedAt ?? null,
855
+ finishedAt: snapshot.finishedAt ?? null,
856
+ durationMs: snapshot.durationMs ?? null,
791
857
  updatedAt: snapshot.updatedAt ?? snapshot.finishedAt ?? snapshot.startedAt ?? null,
792
858
  logs: snapshot.logTail,
793
859
  totalLogCount: snapshot.totalLogCount,
@@ -2854,6 +2854,21 @@ export class DynamicWorkflow extends WorkflowEntrypoint<
2854
2854
  instanceId: entryTrace.instanceId,
2855
2855
  },
2856
2856
  });
2857
+ if (entryTrace.runId) {
2858
+ this.ctx.waitUntil(
2859
+ appendCoordinatorRunEvent(this.env, {
2860
+ runId: entryTrace.runId,
2861
+ type: 'status',
2862
+ status: 'running',
2863
+ ts: Date.now(),
2864
+ }).catch((error) => {
2865
+ console.warn('[coordinator] workflow entry status event failed', {
2866
+ runId: entryTrace.runId,
2867
+ error: error instanceof Error ? error.message : String(error),
2868
+ });
2869
+ }),
2870
+ );
2871
+ }
2857
2872
  if (entryTrace.submittedAt !== null) {
2858
2873
  trace({
2859
2874
  runId: entryTrace.runId,
@@ -3030,74 +3045,143 @@ const coordinatorEntrypoint = {
3030
3045
  env: CoordinatorEnv,
3031
3046
  ctx?: ExecutionContext,
3032
3047
  ): Promise<Response> {
3033
- const url = new URL(request.url);
3034
- if (url.pathname === '/health') {
3035
- return new Response('ok', { status: 200 });
3048
+ // Top-level guard: no coordinator route may let an exception escape to
3049
+ // Cloudflare's default "Worker threw exception" HTML error page. Each
3050
+ // route group below additionally wraps itself for precise phase/runId
3051
+ // context; this outer catch is the backstop for URL parsing, route
3052
+ // matching, and any future route added without its own envelope.
3053
+ try {
3054
+ return await coordinatorRouteFetch(request, env, ctx);
3055
+ } catch (error) {
3056
+ return coordinatorRouteErrorResponse({
3057
+ logTag: '[coordinator.fetch.error]',
3058
+ code: 'COORDINATOR_ROUTE_FAILED',
3059
+ phase: 'coordinator.fetch',
3060
+ runId: null,
3061
+ error,
3062
+ });
3036
3063
  }
3037
- if (url.pathname === '/warmup/submit') {
3038
- const authError = authorizeCoordinatorControlRequest({ request, env });
3039
- if (authError) return authError;
3064
+ },
3065
+ async tail(events: unknown[], env: CoordinatorEnv): Promise<void> {
3066
+ await flushTailRunLogs(events, env);
3067
+ },
3068
+ async scheduled(
3069
+ _controller: unknown,
3070
+ _env: CoordinatorEnv,
3071
+ _ctx?: ExecutionContext,
3072
+ ): Promise<void> {},
3073
+ };
3074
+
3075
+ export default coordinatorEntrypoint;
3076
+
3077
+ /**
3078
+ * Route dispatcher for the coordinator fetch handler. Extracted as a standalone
3079
+ * function so the outer {@link coordinatorEntrypoint.fetch} try/catch backstop
3080
+ * is not coupled to `this` binding. Every route group wraps itself with a JSON
3081
+ * envelope; the outer catch covers URL parsing / route matching escapes.
3082
+ */
3083
+ async function coordinatorRouteFetch(
3084
+ request: Request,
3085
+ env: CoordinatorEnv,
3086
+ ctx?: ExecutionContext,
3087
+ ): Promise<Response> {
3088
+ const url = new URL(request.url);
3089
+ if (url.pathname === '/health') {
3090
+ return new Response('ok', { status: 200 });
3091
+ }
3092
+ if (url.pathname === '/warmup/submit') {
3093
+ const authError = authorizeCoordinatorControlRequest({ request, env });
3094
+ if (authError) return authError;
3095
+ try {
3040
3096
  return await handleCoordinatorWarmup(request, env, ctx);
3041
- }
3042
- if (url.pathname === '/tail-log-token/probe') {
3043
- const authError = authorizeCoordinatorControlRequest({ request, env });
3044
- if (authError) return authError;
3045
- const expectedTailLogToken = env.DEEPLINE_TAIL_LOG_TOKEN?.trim();
3046
- if (!expectedTailLogToken) {
3047
- return Response.json(
3048
- { ok: false, error: 'tail log token is not configured' },
3049
- { status: 503 },
3050
- );
3051
- }
3052
- const actualTailLogToken =
3053
- request.headers.get('x-deepline-tail-log-token')?.trim() ?? '';
3054
- if (actualTailLogToken !== expectedTailLogToken) {
3055
- return Response.json(
3056
- { ok: false, error: 'tail log token mismatch' },
3057
- { status: 401 },
3058
- );
3059
- }
3060
- return Response.json({
3061
- ok: true,
3062
- deployMarker: env.DEEPLINE_COORDINATOR_DEPLOY_MARKER ?? null,
3097
+ } catch (error) {
3098
+ return coordinatorRouteErrorResponse({
3099
+ logTag: '[coordinator.warmup.error]',
3100
+ code: 'COORDINATOR_WARMUP_FAILED',
3101
+ phase: 'coordinator.warmup',
3102
+ runId: null,
3103
+ error,
3063
3104
  });
3064
3105
  }
3065
- if (url.pathname === '/staged-files/put') {
3066
- const authError = authorizeCoordinatorControlRequest({ request, env });
3067
- if (authError) return authError;
3106
+ }
3107
+ if (url.pathname === '/tail-log-token/probe') {
3108
+ const authError = authorizeCoordinatorControlRequest({ request, env });
3109
+ if (authError) return authError;
3110
+ const expectedTailLogToken = env.DEEPLINE_TAIL_LOG_TOKEN?.trim();
3111
+ if (!expectedTailLogToken) {
3112
+ return Response.json(
3113
+ { ok: false, error: 'tail log token is not configured' },
3114
+ { status: 503 },
3115
+ );
3116
+ }
3117
+ const actualTailLogToken =
3118
+ request.headers.get('x-deepline-tail-log-token')?.trim() ?? '';
3119
+ if (actualTailLogToken !== expectedTailLogToken) {
3120
+ return Response.json(
3121
+ { ok: false, error: 'tail log token mismatch' },
3122
+ { status: 401 },
3123
+ );
3124
+ }
3125
+ return Response.json({
3126
+ ok: true,
3127
+ deployMarker: env.DEEPLINE_COORDINATOR_DEPLOY_MARKER ?? null,
3128
+ });
3129
+ }
3130
+ if (url.pathname === '/staged-files/put') {
3131
+ const authError = authorizeCoordinatorControlRequest({ request, env });
3132
+ if (authError) return authError;
3133
+ try {
3068
3134
  return await handleStagedFilePut(request, env);
3135
+ } catch (error) {
3136
+ return coordinatorRouteErrorResponse({
3137
+ logTag: '[coordinator.staged_file_put.error]',
3138
+ code: 'COORDINATOR_STAGED_FILE_PUT_FAILED',
3139
+ phase: 'coordinator.staged_files_put',
3140
+ runId: null,
3141
+ error,
3142
+ });
3069
3143
  }
3070
- // Workflow routes: /workflow/{runId}/{action}
3071
- const wfMatch = url.pathname.match(/^\/workflow\/([^/]+)(?:\/(.+))?$/);
3072
- if (wfMatch) {
3073
- const runId = decodeURIComponent(wfMatch[1]!);
3074
- const action = wfMatch[2] ?? '';
3075
- const authError = authorizeCoordinatorControlRequest({
3076
- request,
3077
- env,
3144
+ }
3145
+ // Workflow routes: /workflow/{runId}/{action}
3146
+ const wfMatch = url.pathname.match(/^\/workflow\/([^/]+)(?:\/(.+))?$/);
3147
+ if (wfMatch) {
3148
+ const runId = decodeURIComponent(wfMatch[1]!);
3149
+ const action = wfMatch[2] ?? '';
3150
+ const authError = authorizeCoordinatorControlRequest({
3151
+ request,
3152
+ env,
3153
+ runId,
3154
+ requireRunScope: isWorkflowMutatingAction(action),
3155
+ });
3156
+ if (authError) return authError;
3157
+ try {
3158
+ return await handleWorkflowRoute({ runId, action, request, env, ctx });
3159
+ } catch (error) {
3160
+ return coordinatorWorkflowRouteErrorResponse({
3078
3161
  runId,
3079
- requireRunScope: isWorkflowMutatingAction(action),
3162
+ action,
3163
+ error,
3080
3164
  });
3081
- if (authError) return authError;
3082
- return await handleWorkflowRoute({ runId, action, request, env, ctx });
3083
3165
  }
3166
+ }
3084
3167
 
3085
- // Dedup routes: /dedup/{runId}/{action}
3086
- const dedupMatch = url.pathname.match(/^\/dedup\/([^/]+)(?:\/(.+))?$/);
3087
- if (dedupMatch) {
3088
- const runId = decodeURIComponent(dedupMatch[1]!);
3089
- const action = dedupMatch[2] ?? '';
3090
- const authError = authorizeCoordinatorControlRequest({
3091
- request,
3092
- env,
3093
- runId,
3094
- requireRunScope: request.method !== 'GET' && request.method !== 'HEAD',
3095
- });
3096
- if (authError) return authError;
3168
+ // Dedup routes: /dedup/{runId}/{action}
3169
+ const dedupMatch = url.pathname.match(/^\/dedup\/([^/]+)(?:\/(.+))?$/);
3170
+ if (dedupMatch) {
3171
+ const runId = decodeURIComponent(dedupMatch[1]!);
3172
+ const action = dedupMatch[2] ?? '';
3173
+ const authError = authorizeCoordinatorControlRequest({
3174
+ request,
3175
+ env,
3176
+ runId,
3177
+ requireRunScope: request.method !== 'GET' && request.method !== 'HEAD',
3178
+ });
3179
+ if (authError) return authError;
3180
+ try {
3097
3181
  const doId = env.PLAY_DEDUP.idFromName(runId);
3098
3182
  const stub = env.PLAY_DEDUP.get(doId);
3099
3183
  const internalUrl = `https://internal/${action}`;
3100
- return stub.fetch(internalUrl, {
3184
+ return await stub.fetch(internalUrl, {
3101
3185
  method: request.method,
3102
3186
  headers: request.headers,
3103
3187
  body:
@@ -3105,21 +3189,19 @@ const coordinatorEntrypoint = {
3105
3189
  ? undefined
3106
3190
  : request.body,
3107
3191
  });
3192
+ } catch (error) {
3193
+ return coordinatorRouteErrorResponse({
3194
+ logTag: '[coordinator.dedup_route.error]',
3195
+ code: 'COORDINATOR_DEDUP_ROUTE_FAILED',
3196
+ phase: `coordinator.dedup.${action || 'unknown'}`,
3197
+ runId,
3198
+ error,
3199
+ });
3108
3200
  }
3201
+ }
3109
3202
 
3110
- return new Response('not found', { status: 404 });
3111
- },
3112
- async tail(events: unknown[], env: CoordinatorEnv): Promise<void> {
3113
- await flushTailRunLogs(events, env);
3114
- },
3115
- async scheduled(
3116
- _controller: unknown,
3117
- _env: CoordinatorEnv,
3118
- _ctx?: ExecutionContext,
3119
- ): Promise<void> {},
3120
- };
3121
-
3122
- export default coordinatorEntrypoint;
3203
+ return new Response('not found', { status: 404 });
3204
+ }
3123
3205
 
3124
3206
  const RUN_LOG_PREFIX_RE = /\[deepline-run:([^\]]+)\]\s*(.*)/;
3125
3207
  const RUN_ID_RE = /\bplay\/[^/\s]+\/run\/[0-9a-zTt-]+/;
@@ -3211,6 +3293,65 @@ function formatTailLogPart(value: unknown): string {
3211
3293
  }
3212
3294
  }
3213
3295
 
3296
+ function coordinatorWorkflowRouteErrorResponse(input: {
3297
+ runId: string;
3298
+ action: string;
3299
+ error: unknown;
3300
+ }): Response {
3301
+ const message =
3302
+ input.error instanceof Error ? input.error.message : String(input.error);
3303
+ console.error('[coordinator.workflow_route.error]', {
3304
+ runId: input.runId,
3305
+ action: input.action,
3306
+ error: message,
3307
+ });
3308
+ return Response.json(
3309
+ {
3310
+ error: {
3311
+ code: 'COORDINATOR_WORKFLOW_ROUTE_FAILED',
3312
+ message,
3313
+ phase: `coordinator.${input.action || 'unknown'}`,
3314
+ runId: input.runId,
3315
+ },
3316
+ },
3317
+ { status: 500 },
3318
+ );
3319
+ }
3320
+
3321
+ /**
3322
+ * Generic JSON envelope for any thrown error escaping a coordinator fetch route.
3323
+ * Mirrors {@link coordinatorWorkflowRouteErrorResponse} so that NO route group
3324
+ * in the fetch handler can leak Cloudflare's raw "Worker threw exception" HTML
3325
+ * page to clients/agents. Keep the envelope shape `{ error: { code, message,
3326
+ * phase, runId } }` and the console.error logging convention identical.
3327
+ */
3328
+ function coordinatorRouteErrorResponse(input: {
3329
+ logTag: string;
3330
+ code: string;
3331
+ phase: string;
3332
+ runId: string | null;
3333
+ error: unknown;
3334
+ }): Response {
3335
+ const message =
3336
+ input.error instanceof Error ? input.error.message : String(input.error);
3337
+ console.error(input.logTag, {
3338
+ runId: input.runId,
3339
+ phase: input.phase,
3340
+ error: message,
3341
+ });
3342
+ return Response.json(
3343
+ {
3344
+ error: {
3345
+ code: input.code,
3346
+ message,
3347
+ phase: input.phase,
3348
+ runId: input.runId,
3349
+ },
3350
+ },
3351
+ { status: 500 },
3352
+ );
3353
+ }
3354
+
3214
3355
  // Operator-diagnostic console lines that carry the [deepline-run:] prefix but
3215
3356
  // are not user-facing run output. The console scrape fans run-prefixed lines
3216
3357
  // back into the run's durable Run Log Stream ('system' channel), so harness/