deepline 0.1.210 → 0.1.212

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 (40) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +203 -12
  2. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/customer-console.ts +23 -0
  3. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +15 -0
  4. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-latency-profile.ts +90 -0
  5. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +162 -100
  6. package/dist/bundling-sources/sdk/src/client.ts +118 -3
  7. package/dist/bundling-sources/sdk/src/play.ts +2 -0
  8. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  9. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  10. package/dist/bundling-sources/sdk/src/types.ts +47 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +253 -30
  12. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +365 -103
  13. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +12 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/dataset-id.ts +10 -4
  15. package/dist/bundling-sources/shared_libs/play-runtime/db-session.ts +9 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +45 -8
  17. package/dist/bundling-sources/shared_libs/play-runtime/ledger-safe-payload.ts +14 -2
  18. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +4 -2
  19. package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +21 -1
  20. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +128 -6
  21. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +7 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +5 -1
  23. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +37 -5
  24. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +19 -10
  25. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +63 -7
  26. package/dist/bundling-sources/shared_libs/play-runtime/secret-capability.ts +18 -4
  27. package/dist/bundling-sources/shared_libs/play-runtime/single-flight.ts +31 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +12 -1
  29. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +29 -9
  30. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +22 -1
  31. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +79 -1
  32. package/dist/bundling-sources/shared_libs/plays/dataset.ts +41 -5
  33. package/dist/cli/index.js +728 -239
  34. package/dist/cli/index.mjs +728 -239
  35. package/dist/index.d.mts +93 -7
  36. package/dist/index.d.ts +93 -7
  37. package/dist/index.js +245 -46
  38. package/dist/index.mjs +245 -46
  39. package/dist/plays/bundle-play-file.mjs +65 -4
  40. package/package.json +1 -1
@@ -68,6 +68,7 @@ import {
68
68
  } from './child-play-await';
69
69
  import { submitChildPlayThroughCoordinator } from './child-play-submit';
70
70
  import { isRetryableWorkerRuntimeApiError } from './runtime-api-retry';
71
+ import { formatCustomerConsoleValue } from './runtime/customer-console';
71
72
  import {
72
73
  attachToolResultListDataset,
73
74
  createToolExecuteResult,
@@ -116,6 +117,7 @@ import {
116
117
  isQueryResultDatasetTool,
117
118
  } from '../../../shared_libs/play-runtime/query-result-dataset';
118
119
  import { DEDUPE_DUPLICATE_KEY_SAMPLE_CAP } from '../../../shared_libs/play-runtime/map-row-identity';
120
+ import { createRuntimeDatasetId } from '../../../shared_libs/play-runtime/dataset-id';
119
121
  import {
120
122
  getTopLevelPipelineSubsteps,
121
123
  getCompiledPipelineSubsteps,
@@ -166,6 +168,7 @@ import {
166
168
  createWorkerWorkBudgetMeter,
167
169
  type WorkerWorkBudgetMeter,
168
170
  } from './runtime/work-budget';
171
+ import { MapLatencyProfile } from './runtime/map-latency-profile';
169
172
  import {
170
173
  WORKER_RUN_DISPATCHER_DEFAULT_MAX_RESIDENT_ROW_BYTES,
171
174
  WorkerRunWorkDispatcher,
@@ -299,7 +302,7 @@ import type {
299
302
  LiveNodeProgressMap,
300
303
  LiveNodeProgressSnapshot,
301
304
  } from './runtime/live-progress';
302
- import { extractErrorBilling } from './runtime/tool-http-errors';
305
+ import { extractErrorBilling, ToolHttpError } from './runtime/tool-http-errors';
303
306
  import {
304
307
  WorkflowAbortError,
305
308
  isAbortLikeError,
@@ -323,6 +326,55 @@ import type { PlayRunInputPayload } from '../../../shared_libs/play-runtime/sche
323
326
  // @ts-ignore - virtual module substituted by esbuild plugin at bundle time.
324
327
  import playFn from 'deepline-play-entry';
325
328
 
329
+ const customerConsoleStorage = new AsyncLocalStorage<
330
+ (message: string) => void
331
+ >();
332
+
333
+ type CustomerConsoleGlobal = typeof globalThis & {
334
+ __deeplineCaptureCustomerConsole?: (
335
+ method: string,
336
+ args: unknown[],
337
+ ) => unknown;
338
+ };
339
+
340
+ (globalThis as CustomerConsoleGlobal).__deeplineCaptureCustomerConsole = (
341
+ method,
342
+ args,
343
+ ) => {
344
+ const log = customerConsoleStorage.getStore();
345
+ if (log) {
346
+ assertNoSecretTaint(args, `console.${method}`);
347
+ const message = args.map(formatCustomerConsoleValue).join(' ');
348
+ log(`[console.${method}] ${message}`.slice(0, 2_000));
349
+ }
350
+ };
351
+
352
+ function runCustomerPlay(
353
+ ctx: unknown,
354
+ input: PlayRunInputPayload,
355
+ ): Promise<unknown> {
356
+ return customerConsoleStorage.run(
357
+ (ctx as { log(message: string): void }).log,
358
+ () => (playFn as typeof runCustomerPlay)(ctx, input),
359
+ );
360
+ }
361
+
362
+ function runtimeDatasetIdentity(playName: string, tableNamespace: string) {
363
+ return {
364
+ datasetId: createRuntimeDatasetId(playName, tableNamespace),
365
+ path: `datasets.${tableNamespace}`,
366
+ tableNamespace,
367
+ };
368
+ }
369
+
370
+ function datasetAvailableLog(
371
+ runId: string,
372
+ dataset: ReturnType<typeof runtimeDatasetIdentity>,
373
+ count: number,
374
+ ): string {
375
+ return `Dataset ${dataset.path} available: ${count} persisted rows; export: deepline runs export ${runId} --dataset ${dataset.path} --out ${dataset.tableNamespace}.csv`;
376
+ }
377
+
326
378
  type RunRequest = {
327
379
  runId: string;
328
380
  callbackUrl: string;
@@ -998,6 +1050,17 @@ type WorkerCtxCallbacks = {
998
1050
  }) => void | Promise<void>;
999
1051
  onMapStarted?: (nodeId: string, at?: number) => void;
1000
1052
  onMapCompleted?: (nodeId: string, at?: number) => void;
1053
+ onDatasetLifecycle?: (event: {
1054
+ datasetId: string;
1055
+ path: string;
1056
+ tableNamespace: string;
1057
+ phase: 'registered' | 'available' | 'failed';
1058
+ persistedRows?: number;
1059
+ succeededRows?: number;
1060
+ failedRows?: number;
1061
+ complete?: boolean;
1062
+ at: number;
1063
+ }) => void | Promise<void>;
1001
1064
  onToolCalled?: (toolId: string, at?: number) => void;
1002
1065
  onToolFailed?: (toolId: string, at?: number) => void;
1003
1066
  };
@@ -2072,6 +2135,7 @@ async function callToolDirect(
2072
2135
  while (true) {
2073
2136
  requestAttempt += 1;
2074
2137
  let res: Response;
2138
+ const providerCallStartedAt = nowMs();
2075
2139
  try {
2076
2140
  const runtimeApiTimeout = resolveToolRuntimeApiTimeout({
2077
2141
  requestInput: input,
@@ -2160,8 +2224,11 @@ async function callToolDirect(
2160
2224
  } catch (error) {
2161
2225
  transportAttempt += 1;
2162
2226
  const message = error instanceof Error ? error.message : String(error);
2163
- lastError = new Error(
2227
+ lastError = new ToolHttpError(
2164
2228
  `Tool ${toolId} transport failed calling ${path} for run ${req.runId} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}: ${message}`,
2229
+ null,
2230
+ 0,
2231
+ 'repairable',
2165
2232
  );
2166
2233
  if (
2167
2234
  transportAttempt >= TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS ||
@@ -2211,6 +2278,7 @@ async function callToolDirect(
2211
2278
  bodyText: text,
2212
2279
  retryAfterHeader: res.headers.get('retry-after'),
2213
2280
  transientHttpRetrySafe,
2281
+ providerLatencyMs: nowMs() - providerCallStartedAt,
2214
2282
  });
2215
2283
  lastError = failure.error;
2216
2284
  if (failure.backpressureDelayMs !== null) {
@@ -4598,6 +4666,11 @@ function createMinimalWorkerCtx(
4598
4666
  opts?: WorkerMapOptions,
4599
4667
  ): Promise<unknown> => {
4600
4668
  const mapStartedAt = nowMs();
4669
+ const mapLatencyProfile = new MapLatencyProfile(
4670
+ req.testPolicyOverrides?.mapLatencyProfile === true,
4671
+ name,
4672
+ mapStartedAt,
4673
+ );
4601
4674
  const mapNodeId = `map:${name}`;
4602
4675
  const inputRows = rows;
4603
4676
  const rowCountHint = datasetRowCountHint(inputRows);
@@ -4859,6 +4932,7 @@ function createMinimalWorkerCtx(
4859
4932
  };
4860
4933
 
4861
4934
  let totalRowsWritten = 0;
4935
+ let datasetLifecycleRegistered = false;
4862
4936
 
4863
4937
  const volatileWorkflowChunkRows = new Map<
4864
4938
  number,
@@ -4909,6 +4983,22 @@ function createMinimalWorkerCtx(
4909
4983
  budgetMeter: workBudgetMeter,
4910
4984
  force: !!req.force,
4911
4985
  });
4986
+ if (!datasetLifecycleRegistered) {
4987
+ datasetLifecycleRegistered = true;
4988
+ const dataset = runtimeDatasetIdentity(req.playName, name);
4989
+ await callbacks?.onDatasetLifecycle?.({
4990
+ ...dataset,
4991
+ phase: 'registered',
4992
+ complete: false,
4993
+ at: nowMs(),
4994
+ });
4995
+ emitEvent({
4996
+ type: 'log',
4997
+ level: 'info',
4998
+ message: `Dataset ${dataset.path} registered`,
4999
+ ts: nowMs(),
5000
+ });
5001
+ }
4912
5002
  const activeRowAttempt = {
4913
5003
  attemptId: prepared.attemptId ?? rowAttempt.attemptId,
4914
5004
  attemptOwnerRunId:
@@ -4935,6 +5025,7 @@ function createMinimalWorkerCtx(
4935
5025
  blockedRows: prepared.blockedRows.length,
4936
5026
  },
4937
5027
  });
5028
+ mapLatencyProfile.recordPhase('prepare_rows', nowMs() - prepareStartedAt);
4938
5029
  const progressTotalRows = rowCountHint ?? chunkRows.length;
4939
5030
  const preparedCompletedRows = Math.min(
4940
5031
  progressTotalRows,
@@ -5215,7 +5306,10 @@ function createMinimalWorkerCtx(
5215
5306
  deserializeDurableValue: deserializeDurableStepValue,
5216
5307
  nowMs,
5217
5308
  batchGraceMs: req.testPolicyOverrides?.batchGraceMs,
5218
- recordPerfTrace: (trace) => recordRunnerPerfTrace({ req, ...trace }),
5309
+ recordPerfTrace: (trace) => {
5310
+ mapLatencyProfile.recordToolTrace(trace);
5311
+ recordRunnerPerfTrace({ req, ...trace });
5312
+ },
5219
5313
  abortSignal,
5220
5314
  onRequestsSettled: reportSettledToolRequests,
5221
5315
  callbacks,
@@ -5505,9 +5599,16 @@ function createMinimalWorkerCtx(
5505
5599
  if (circuitError) throw circuitError;
5506
5600
  const myIndex = idx++;
5507
5601
  if (myIndex >= rowsToExecute.length) return;
5602
+ const rowQueueStartedAt = mapLatencyProfile.enabled ? nowMs() : 0;
5508
5603
  const rowSlot = await governor.acquireRowSlot({
5509
5604
  signal: abortSignal,
5510
5605
  });
5606
+ if (mapLatencyProfile.enabled) {
5607
+ mapLatencyProfile.recordRowQueue(nowMs() - rowQueueStartedAt);
5608
+ }
5609
+ const rowExecutionStartedAt = mapLatencyProfile.enabled
5610
+ ? nowMs()
5611
+ : 0;
5511
5612
  let rowMarkedActive = false;
5512
5613
  try {
5513
5614
  startedExecutedRows += 1;
@@ -5744,6 +5845,11 @@ function createMinimalWorkerCtx(
5744
5845
  // single clean run failure.
5745
5846
  }
5746
5847
  } finally {
5848
+ if (mapLatencyProfile.enabled) {
5849
+ mapLatencyProfile.recordRowExecution(
5850
+ nowMs() - rowExecutionStartedAt,
5851
+ );
5852
+ }
5747
5853
  if (rowMarkedActive) {
5748
5854
  activeExecutedRows = Math.max(0, activeExecutedRows - 1);
5749
5855
  reportExecutionHeartbeat(false);
@@ -5796,6 +5902,10 @@ function createMinimalWorkerCtx(
5796
5902
  concurrency,
5797
5903
  },
5798
5904
  });
5905
+ mapLatencyProfile.recordPhase(
5906
+ 'worker_execution',
5907
+ nowMs() - workersStartedAt,
5908
+ );
5799
5909
  const buildRowFailureSamples = () =>
5800
5910
  failedRowEntries
5801
5911
  .map((failure, executedIndex) =>
@@ -5923,6 +6033,10 @@ function createMinimalWorkerCtx(
5923
6033
  rowsToExecute: rowsToExecute.length,
5924
6034
  },
5925
6035
  });
6036
+ mapLatencyProfile.recordPhase(
6037
+ 'persist_rows',
6038
+ nowMs() - persistRowsStartedAt,
6039
+ );
5926
6040
  } catch (error) {
5927
6041
  if (
5928
6042
  workflowStep &&
@@ -6028,6 +6142,7 @@ function createMinimalWorkerCtx(
6028
6142
  ms: nowMs() - hashStartedAt,
6029
6143
  extra: { mapName: name, chunkIndex, rows: out.length },
6030
6144
  });
6145
+ mapLatencyProfile.recordPhase('hash', nowMs() - hashStartedAt);
6031
6146
  recordRunnerPerfTrace({
6032
6147
  req,
6033
6148
  phase: 'runner.map_chunk.total',
@@ -6230,6 +6345,23 @@ function createMinimalWorkerCtx(
6230
6345
  finalizedRowsWritten,
6231
6346
  ),
6232
6347
  });
6348
+ const dataset = runtimeDatasetIdentity(req.playName, name);
6349
+ const persistedRows = finalizedRowsWritten + totalRowsFailed;
6350
+ await callbacks?.onDatasetLifecycle?.({
6351
+ ...dataset,
6352
+ phase: 'available',
6353
+ persistedRows,
6354
+ succeededRows: finalizedRowsWritten,
6355
+ failedRows: totalRowsFailed,
6356
+ complete: true,
6357
+ at: completedAt,
6358
+ });
6359
+ emitEvent({
6360
+ type: 'log',
6361
+ level: totalRowsFailed > 0 ? 'warn' : 'info',
6362
+ message: datasetAvailableLog(req.runId, dataset, persistedRows),
6363
+ ts: completedAt,
6364
+ });
6233
6365
  callbacks?.onMapCompleted?.(mapNodeId, completedAt);
6234
6366
  emitEvent({
6235
6367
  type: 'log',
@@ -6503,7 +6635,8 @@ function createMinimalWorkerCtx(
6503
6635
  if (chunkResult.fatalError) {
6504
6636
  throw new Error(
6505
6637
  `ctx.dataset("${name}") stopped after a runtime persistence failure ` +
6506
- `outside the retryable chunk step. Provider calls already executed for ` +
6638
+ `outside the retryable chunk step. Circuit breaker prevented queued row ` +
6639
+ `dispatch. Provider calls already executed for ` +
6507
6640
  `${chunkResult.rowsExecuted} row(s), so the chunk was not retried. ` +
6508
6641
  `Fix the runtime persistence cause and re-run to resume. ` +
6509
6642
  `First error: ${chunkResult.fatalError}`,
@@ -6643,6 +6776,27 @@ function createMinimalWorkerCtx(
6643
6776
  chunks: dispatchResult.chunksExecuted,
6644
6777
  },
6645
6778
  });
6779
+ if (mapLatencyProfile.enabled) {
6780
+ const endedAt = nowMs();
6781
+ const summary = mapLatencyProfile.summary(endedAt, {
6782
+ chunks: dispatchResult.chunksExecuted,
6783
+ rows: totalRowsWritten,
6784
+ });
6785
+ recordRunnerPerfTrace({
6786
+ req,
6787
+ phase: 'runner.map.profile',
6788
+ ms: endedAt - mapStartedAt,
6789
+ extra: summary,
6790
+ });
6791
+ // Local Absurd does not always forward dynamic-worker perf traces. Keep
6792
+ // one bounded copy in the durable run log for the diagnostic command.
6793
+ emitEvent({
6794
+ type: 'log',
6795
+ level: 'info',
6796
+ message: `[perf] map_latency_profile ${JSON.stringify(summary)}`,
6797
+ ts: endedAt,
6798
+ });
6799
+ }
6646
6800
  return dataset;
6647
6801
  };
6648
6802
 
@@ -7555,9 +7709,7 @@ async function handleRun(request: Request, env: WorkerEnv): Promise<Response> {
7555
7709
  captureHarnessBinding(env);
7556
7710
  await probeHarnessOnce(env, runPrefix);
7557
7711
  const ctx = createMinimalWorkerCtx(req, emit, env);
7558
- const result = await (
7559
- playFn as (ctx: unknown, input: PlayRunInputPayload) => Promise<unknown>
7560
- )(ctx, req.runtimeInput);
7712
+ const result = await runCustomerPlay(ctx, req.runtimeInput);
7561
7713
  emit({
7562
7714
  type: 'result',
7563
7715
  result,
@@ -7971,6 +8123,17 @@ async function executeRunRequest(
7971
8123
  },
7972
8124
  onMapStarted: (nodeId, at) => stepLifecycle?.onMapStarted(nodeId, at),
7973
8125
  onMapCompleted: (nodeId, at) => stepLifecycle?.onMapCompleted(nodeId, at),
8126
+ onDatasetLifecycle: async (event) => {
8127
+ const { at, ...dataset } = event;
8128
+ pendingLedgerEvents.push({
8129
+ ...dataset,
8130
+ type: 'dataset.lifecycle',
8131
+ runId: req.runId,
8132
+ source: 'worker',
8133
+ occurredAt: at,
8134
+ });
8135
+ await flushLedgerEvents(event.complete === true);
8136
+ },
7974
8137
  onToolCalled: (toolId, at) => stepLifecycle?.onToolCalled(toolId, at),
7975
8138
  onToolFailed: (toolId, at) => stepLifecycle?.onToolFailed(toolId, at),
7976
8139
  };
@@ -8037,9 +8200,7 @@ async function executeRunRequest(
8037
8200
  : null;
8038
8201
  try {
8039
8202
  const playStartedAt = nowMs();
8040
- const result = await (
8041
- playFn as (ctx: unknown, input: PlayRunInputPayload) => Promise<unknown>
8042
- )(ctx, req.runtimeInput);
8203
+ const result = await runCustomerPlay(ctx, req.runtimeInput);
8043
8204
  recordRunnerPerfTrace({
8044
8205
  req,
8045
8206
  phase: 'runner.play_function',
@@ -8083,7 +8244,33 @@ async function executeRunRequest(
8083
8244
  ms: nowMs() - ledgerFlushWaitStartedAt,
8084
8245
  });
8085
8246
  const resultDatasetStartedAt = nowMs();
8086
- await persistResultDatasets(req, promoted.handles, serializedResult);
8247
+ const persistedResultCounts = await persistResultDatasets(
8248
+ req,
8249
+ promoted.handles,
8250
+ serializedResult,
8251
+ );
8252
+ for (const dataset of promoted.handles) {
8253
+ if (dataset.datasetKind === 'map') continue;
8254
+ const identity = runtimeDatasetIdentity(
8255
+ req.playName,
8256
+ dataset.tableNamespace,
8257
+ );
8258
+ const count = persistedResultCounts.get(dataset.tableNamespace) ?? 0;
8259
+ pendingLedgerEvents.push({
8260
+ ...identity,
8261
+ type: 'dataset.lifecycle',
8262
+ runId: req.runId,
8263
+ source: 'worker',
8264
+ occurredAt: nowMs(),
8265
+ phase: 'available',
8266
+ persistedRows: count,
8267
+ succeededRows: count,
8268
+ failedRows: 0,
8269
+ complete: true,
8270
+ });
8271
+ appendRunLogLine(datasetAvailableLog(req.runId, identity, count));
8272
+ }
8273
+ await flushLedgerEvents(true);
8087
8274
  resultDatasetsPersisted = true;
8088
8275
  recordRunnerPerfTrace({
8089
8276
  req,
@@ -8547,8 +8734,9 @@ async function persistResultDatasets(
8547
8734
  req: RunRequest,
8548
8735
  resultDatasets: ProjectedResultDatasetHandle[],
8549
8736
  serializedResult: unknown,
8550
- ): Promise<void> {
8737
+ ): Promise<Map<string, number>> {
8551
8738
  const persistedNamespaces = new Set<string>();
8739
+ const persistedCounts = new Map<string, number>();
8552
8740
  for (const dataset of resultDatasets) {
8553
8741
  if (dataset.datasetKind === 'map') continue;
8554
8742
  const handle = dataset.handle as WorkerDatasetHandle<
@@ -8587,6 +8775,7 @@ async function persistResultDatasets(
8587
8775
  inputOffset += chunk.length;
8588
8776
  }
8589
8777
  persistedNamespaces.add(dataset.tableNamespace);
8778
+ persistedCounts.set(dataset.tableNamespace, inputOffset);
8590
8779
  }
8591
8780
 
8592
8781
  const datasets = collectDatasetEnvelopes(serializedResult);
@@ -8620,7 +8809,9 @@ async function persistResultDatasets(
8620
8809
  attemptLeaseTtlMs: runtimeSheetAttemptLeaseTtlMs(req),
8621
8810
  userEmail: req.userEmail,
8622
8811
  });
8812
+ persistedCounts.set(dataset.tableNamespace, dataset.rows.length);
8623
8813
  }
8814
+ return persistedCounts;
8624
8815
  }
8625
8816
 
8626
8817
  const RESULT_DATASET_PERSIST_CHUNK_ROWS = 5_000;
@@ -0,0 +1,23 @@
1
+ export function formatCustomerConsoleValue(value: unknown): string {
2
+ if (typeof value === 'string') return value;
3
+ try {
4
+ if (value instanceof Error) return value.stack ?? value.message;
5
+ if (value && typeof value === 'object') {
6
+ const seen = new WeakSet<object>();
7
+ const serialized = JSON.stringify(value, (_key, nested) => {
8
+ if (typeof nested === 'symbol' || typeof nested === 'bigint') {
9
+ return nested.toString();
10
+ }
11
+ if (nested && typeof nested === 'object') {
12
+ if (seen.has(nested)) return '[Circular]';
13
+ seen.add(nested);
14
+ }
15
+ return nested;
16
+ });
17
+ if (serialized !== undefined) return serialized;
18
+ }
19
+ return String(value);
20
+ } catch {
21
+ return '[Unprintable]';
22
+ }
23
+ }
@@ -337,6 +337,21 @@ function countToolSubstep(substep: PlayStaticSubstep): MapToolStats {
337
337
  minProviderBranchCoalescedCount(branchStats),
338
338
  ];
339
339
  }
340
+ if (substep.kind === 'loop') {
341
+ // The static extractor records every switch arm in `steps` when a loop
342
+ // walks a fixed provider list. Each arm can execute on a different
343
+ // iteration, so using the conditional's max-one-branch estimate would
344
+ // budget a seven-provider waterfall as one provider call per row.
345
+ return substep.steps.reduce<MapToolStats>((stats, step) => {
346
+ const stepStats =
347
+ step.type === 'control_flow' &&
348
+ step.kind === 'conditional' &&
349
+ step.steps.length > 0
350
+ ? countToolSubsteps(step.steps)
351
+ : countToolSubstep(step);
352
+ return addStats(stats, stepStats);
353
+ }, [0, 0, 0, 0, 0]);
354
+ }
340
355
  return countToolSubsteps(substep.steps);
341
356
  }
342
357
 
@@ -0,0 +1,90 @@
1
+ type ToolTrace = {
2
+ phase: string;
3
+ ms?: number;
4
+ extra?: Record<string, unknown>;
5
+ };
6
+
7
+ type Totals = Record<string, number>;
8
+
9
+ function add(totals: Totals, key: string, ms: number | undefined): void {
10
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
11
+ totals[key] = (totals[key] ?? 0) + ms;
12
+ }
13
+
14
+ /**
15
+ * Opt-in map profiler. It aggregates in memory and produces one terminal
16
+ * trace, so normal runs do not pay per-row/per-tool telemetry cost.
17
+ */
18
+ export class MapLatencyProfile {
19
+ private readonly phaseWorkMs: Totals = {};
20
+ private readonly toolWorkMs: Totals = {};
21
+ private readonly counts: Totals = {};
22
+
23
+ constructor(
24
+ readonly enabled: boolean,
25
+ private readonly mapName: string,
26
+ private readonly startedAtMs: number,
27
+ ) {}
28
+
29
+ recordPhase(phase: string, ms: number, count = 1): void {
30
+ if (!this.enabled) return;
31
+ add(this.phaseWorkMs, phase, ms);
32
+ this.counts[`${phase}_count`] =
33
+ (this.counts[`${phase}_count`] ?? 0) + count;
34
+ }
35
+
36
+ recordRowQueue(ms: number): void {
37
+ this.recordPhase('row_slot_queue', ms);
38
+ }
39
+
40
+ recordRowExecution(ms: number): void {
41
+ this.recordPhase('row_execution', ms);
42
+ }
43
+
44
+ recordToolTrace(trace: ToolTrace): void {
45
+ if (!this.enabled) return;
46
+ switch (trace.phase) {
47
+ case 'runner.tool.request':
48
+ add(this.toolWorkMs, 'request_end_to_end', trace.ms);
49
+ this.counts.tool_requests = (this.counts.tool_requests ?? 0) + 1;
50
+ break;
51
+ case 'runner.tool.drain':
52
+ add(this.toolWorkMs, 'drain', trace.ms);
53
+ this.counts.tool_drains = (this.counts.tool_drains ?? 0) + 1;
54
+ break;
55
+ case 'runner.tool.group':
56
+ add(this.toolWorkMs, 'group', trace.ms);
57
+ this.counts.tool_groups = (this.counts.tool_groups ?? 0) + 1;
58
+ break;
59
+ case 'runner.tool.receipt_prepare':
60
+ add(this.toolWorkMs, 'receipt_prepare', trace.ms);
61
+ break;
62
+ case 'runner.tool.admission':
63
+ add(this.toolWorkMs, 'admission_wait', trace.ms);
64
+ break;
65
+ case 'runner.tool.provider':
66
+ add(this.toolWorkMs, 'provider_execute', trace.ms);
67
+ break;
68
+ case 'runner.tool.receipt_complete':
69
+ add(this.toolWorkMs, 'receipt_complete', trace.ms);
70
+ break;
71
+ default:
72
+ return;
73
+ }
74
+ }
75
+
76
+ summary(endedAtMs: number, input: { chunks: number; rows: number }) {
77
+ return {
78
+ mapName: this.mapName,
79
+ mapWallMs: Math.max(0, endedAtMs - this.startedAtMs),
80
+ chunks: input.chunks,
81
+ rows: input.rows,
82
+ // These are summed work durations. They can exceed mapWallMs when rows
83
+ // or provider calls overlap; the terminal trace must not mislabel them
84
+ // as a wall-clock decomposition.
85
+ phaseWorkMs: this.phaseWorkMs,
86
+ toolWorkMs: this.toolWorkMs,
87
+ counts: this.counts,
88
+ };
89
+ }
90
+ }