deepline 0.2.55 → 0.2.57

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 (49) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +14 -0
  2. package/dist/bundling-sources/sdk/src/http.ts +19 -1
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/stream-reconnect.ts +6 -0
  5. package/dist/bundling-sources/sdk/src/types.ts +7 -0
  6. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +146 -12
  7. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +6 -3
  8. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1631 -748
  9. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +20 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +81 -52
  11. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +146 -6
  12. package/dist/bundling-sources/shared_libs/play-runtime/gateway-auth-session.ts +56 -22
  13. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +14 -11
  14. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +21 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +81 -21
  16. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +7 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +3 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +21 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +27 -2
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +49 -10
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +96 -3
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +341 -67
  23. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +10 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +8 -1
  25. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-pg.ts +17 -2
  26. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver.ts +4 -1
  27. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-store-adapter.ts +17 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-writer.ts +16 -2
  29. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-writer.ts +25 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +3 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +2 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +80 -1
  33. package/dist/bundling-sources/shared_libs/plays/docflow.ts +113 -14
  34. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +53 -4
  35. package/dist/bundling-sources/shared_libs/security/safe-fetch.ts +9 -0
  36. package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +85 -48
  37. package/dist/cli/index.js +429 -54
  38. package/dist/cli/index.mjs +409 -28
  39. package/dist/{compiler-manifest-Bl8kmLx9.d.mts → compiler-manifest-TgaC4DeD.d.mts} +13 -0
  40. package/dist/{compiler-manifest-Bl8kmLx9.d.ts → compiler-manifest-TgaC4DeD.d.ts} +13 -0
  41. package/dist/index.d.mts +21 -3
  42. package/dist/index.d.ts +21 -3
  43. package/dist/index.js +29 -2
  44. package/dist/index.mjs +29 -2
  45. package/dist/install-integrity.json +2 -2
  46. package/dist/plays/bundle-play-file.d.mts +2 -2
  47. package/dist/plays/bundle-play-file.d.ts +2 -2
  48. package/dist/plays/bundle-play-file.mjs +78 -18
  49. package/package.json +1 -1
@@ -11,6 +11,20 @@ import {
11
11
  } from './runtime-pg-driver';
12
12
 
13
13
  function wrapPgClient(client: PoolClient): RuntimePoolClient {
14
+ let released = false;
15
+ const releaseClient = (destroy = false) => {
16
+ if (released) return;
17
+ released = true;
18
+ client.release(destroy);
19
+ };
20
+ const destroyTransport = () => {
21
+ // `release(true)` is node-postgres' supported pool-client destruction
22
+ // path. The pool removes the client and Client.end() destroys the socket
23
+ // when a query is active, while keeping pool accounting correct. Runtime
24
+ // callers still run their ordinary `finally { release() }` path after a
25
+ // deadline destroys the transport, so this wrapper owns idempotence.
26
+ releaseClient(true);
27
+ };
14
28
  return {
15
29
  query: <R extends Record<string, unknown> = Record<string, unknown>>(
16
30
  text: string,
@@ -24,9 +38,10 @@ function wrapPgClient(client: PoolClient): RuntimePoolClient {
24
38
  )(text, params).then((result) => ({
25
39
  rows: result.rows,
26
40
  })),
27
- release: () => {
28
- client.release();
41
+ release: (destroy = false) => {
42
+ releaseClient(destroy);
29
43
  },
44
+ destroy: destroyTransport,
30
45
  };
31
46
  }
32
47
 
@@ -13,7 +13,10 @@ export interface RuntimePoolClient {
13
13
  text: string,
14
14
  params?: unknown[],
15
15
  ): Promise<{ rows: R[] }>;
16
- release(): void;
16
+ /** Destroy the physical connection when an active query exceeded its bound. */
17
+ release(destroy?: boolean): void;
18
+ /** Interrupt an active query by destroying the physical transport. */
19
+ destroy?(error?: Error): void;
17
20
  }
18
21
 
19
22
  export interface RuntimePool {
@@ -9,7 +9,10 @@ import type {
9
9
  RuntimeStepReceipt,
10
10
  SkipRuntimeStepReceiptInput,
11
11
  } from './ctx-types';
12
- import { RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES } from './output-size-limits';
12
+ import {
13
+ RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
14
+ RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
15
+ } from './output-size-limits';
13
16
  import {
14
17
  acquireRuntimeReceiptExecutionLockViaAppRuntime,
15
18
  AppRuntimeApiResponseError,
@@ -106,6 +109,12 @@ export type RuntimeReceiptStoreHandlers = Required<
106
109
  Pick<ContextOptions, ReceiptHandlerName>
107
110
  >;
108
111
 
112
+ // Four ambiguous 30-second receipt requests previously consumed the writer's
113
+ // entire two-minute budget. Receipt operations are idempotent and buffered, so
114
+ // keep retry ownership here and allow independent transports enough attempts
115
+ // to recover from a short gateway/network impairment.
116
+ export const RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS = 5 * 60_000;
117
+
109
118
  /**
110
119
  * The runner's single Work Receipt Adapter.
111
120
  *
@@ -116,6 +125,7 @@ export type RuntimeReceiptStoreHandlers = Required<
116
125
  */
117
126
  export class RuntimeReceiptStoreAdapter {
118
127
  readonly handlers: RuntimeReceiptStoreHandlers;
128
+ readonly claimsEstablishExecutionFence = true;
119
129
  readonly #writer: RuntimeReceiptWriter<ReceiptCommand, ReceiptOutput>;
120
130
  #serial = 0;
121
131
 
@@ -125,6 +135,7 @@ export class RuntimeReceiptStoreAdapter {
125
135
  runId: string;
126
136
  maxBatchSize?: number;
127
137
  maxBatchBytes?: number;
138
+ targetBatchBytes?: number;
128
139
  maxFlushMs?: number;
129
140
  maxBufferedBytes?: number;
130
141
  onRetryTelemetry?: (line: string) => void;
@@ -138,8 +149,11 @@ export class RuntimeReceiptStoreAdapter {
138
149
  maxBatchSize: options.maxBatchSize,
139
150
  maxBatchBytes:
140
151
  options.maxBatchBytes ?? RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
152
+ targetBatchBytes:
153
+ options.targetBatchBytes ?? RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
141
154
  maxFlushMs: options.maxFlushMs,
142
155
  maxBufferedBytes: options.maxBufferedBytes,
156
+ maxRetryElapsedMs: RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS,
143
157
  classifyRetryableError: classifyReceiptRetry,
144
158
  onRetryEvent: (event) =>
145
159
  emitReceiptWriterRetryTelemetry({
@@ -309,6 +323,7 @@ function receiptRetryErrorTelemetry(error: unknown): Record<string, unknown> {
309
323
  errorName: error.name,
310
324
  transportAttempts: error.attempts,
311
325
  transportAction: error.action,
326
+ transportAttemptId: error.transportAttemptId,
312
327
  };
313
328
  }
314
329
  if (error instanceof AppRuntimeApiResponseError) {
@@ -343,6 +358,7 @@ function emitReceiptWriterRetryTelemetry(input: {
343
358
  action: receiptCommandTelemetryAction(event.firstInput),
344
359
  batchId: event.batchId,
345
360
  batchSize: event.batchSize,
361
+ batchBytes: event.batchBytes,
346
362
  attempt: event.attempt,
347
363
  elapsedMs: event.elapsedMs,
348
364
  retryAfterMs: event.retryAfterMs,
@@ -27,6 +27,7 @@ export type RuntimeReceiptWriterRetryEvent<Input> = {
27
27
  attempt: number;
28
28
  batchId: number;
29
29
  batchSize: number;
30
+ batchBytes: number;
30
31
  firstInput: Input;
31
32
  elapsedMs: number;
32
33
  retryAfterMs: number;
@@ -103,6 +104,7 @@ export class RuntimeReceiptWriter<Input, Output> {
103
104
  readonly #estimateBytes: (input: Input) => number;
104
105
  readonly #maxBatchSize: number;
105
106
  readonly #maxBatchBytes: number;
107
+ readonly #targetBatchBytes: number;
106
108
  readonly #maxBufferedBytes: number;
107
109
  readonly #maxFlushMs: number;
108
110
  readonly #maxRetryElapsedMs: number;
@@ -135,6 +137,7 @@ export class RuntimeReceiptWriter<Input, Output> {
135
137
  estimateBytes?: (input: Input) => number;
136
138
  maxBatchSize?: number;
137
139
  maxBatchBytes?: number;
140
+ targetBatchBytes?: number;
138
141
  maxBufferedBytes?: number;
139
142
  maxFlushMs?: number;
140
143
  maxRetryElapsedMs?: number;
@@ -154,6 +157,10 @@ export class RuntimeReceiptWriter<Input, Output> {
154
157
  normalizePositiveInteger(options.maxBatchBytes, this.#maxBufferedBytes),
155
158
  this.#maxBufferedBytes,
156
159
  );
160
+ this.#targetBatchBytes = Math.min(
161
+ normalizePositiveInteger(options.targetBatchBytes, this.#maxBatchBytes),
162
+ this.#maxBatchBytes,
163
+ );
157
164
  this.#maxFlushMs = Math.max(0, Math.floor(options.maxFlushMs ?? 5));
158
165
  this.#maxRetryElapsedMs = normalizePositiveInteger(
159
166
  options.maxRetryElapsedMs,
@@ -251,7 +258,7 @@ export class RuntimeReceiptWriter<Input, Output> {
251
258
  this.#bufferedBytes += pending.bytes;
252
259
  if (
253
260
  this.#queued.length >= this.#maxBatchSize ||
254
- this.#queuedBytesAtHead() >= this.#maxBatchBytes
261
+ this.#queuedBytesAtHead() >= this.#targetBatchBytes
255
262
  ) {
256
263
  this.#clearFlushTimer();
257
264
  this.#startPump();
@@ -334,7 +341,10 @@ export class RuntimeReceiptWriter<Input, Output> {
334
341
  while (batch.length < this.#maxBatchSize) {
335
342
  const next = this.#queued[0];
336
343
  if (!next || !Object.is(this.#batchKey(next.input), key)) break;
337
- if (batch.length > 0 && batchBytes + next.bytes > this.#maxBatchBytes) {
344
+ if (
345
+ batch.length > 0 &&
346
+ batchBytes + next.bytes > this.#targetBatchBytes
347
+ ) {
338
348
  break;
339
349
  }
340
350
  this.#queued.shift();
@@ -427,6 +437,10 @@ export class RuntimeReceiptWriter<Input, Output> {
427
437
  attempt: input.attempt,
428
438
  batchId: input.batchId,
429
439
  batchSize: input.batch.length,
440
+ batchBytes: input.batch.reduce(
441
+ (total, pending) => total + pending.bytes,
442
+ 0,
443
+ ),
430
444
  firstInput,
431
445
  elapsedMs: Date.now() - input.startedAt,
432
446
  retryAfterMs: input.retryAfterMs,
@@ -70,6 +70,14 @@ export type RuntimeSheetRowWriterSummary = {
70
70
  terminalRows: number;
71
71
  };
72
72
 
73
+ export type RuntimeSheetRowWriterDiagnostics = {
74
+ activeKind: 'terminal' | 'checkpoint' | null;
75
+ activeRows: number;
76
+ activeStartedAt: number | null;
77
+ blockedTerminalRows: number;
78
+ queuedTerminalRows: number;
79
+ };
80
+
73
81
  export type RuntimeSheetRowSettlement = {
74
82
  /**
75
83
  * Resolves once the bounded live buffer owns this row. Awaiting this promise
@@ -159,6 +167,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
159
167
  readonly #controller = new AbortController();
160
168
 
161
169
  #active: PendingWrite<Row, Update>[] | null = null;
170
+ #activeStartedAt: number | null = null;
162
171
  #bufferedBytes = 0;
163
172
  #liveBytes = 0;
164
173
  #timer: ReturnType<typeof setTimeout> | null = null;
@@ -264,6 +273,20 @@ export class RuntimeSheetRowWriter<Row, Update> {
264
273
  return this.#finishPromise;
265
274
  }
266
275
 
276
+ diagnostics(): RuntimeSheetRowWriterDiagnostics {
277
+ return {
278
+ activeKind: this.#active?.[0]?.kind ?? null,
279
+ activeRows: this.#active?.length ?? 0,
280
+ activeStartedAt: this.#activeStartedAt,
281
+ blockedTerminalRows: this.#blocked.filter(
282
+ (entry) => entry.kind === 'terminal',
283
+ ).length,
284
+ queuedTerminalRows: [...this.#live.values()].filter(
285
+ (entry) => entry.kind === 'terminal',
286
+ ).length,
287
+ };
288
+ }
289
+
267
290
  #enqueueCheckpointUpdate(update: Update): Promise<void> {
268
291
  const key = normalizedKey(this.#checkpointKey(update));
269
292
  if (this.#terminalAcceptedKeys.has(key)) return Promise.resolve();
@@ -403,6 +426,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
403
426
  if (batch.length === 0) return;
404
427
  const batchKind = batch[0]!.kind;
405
428
  this.#active = batch;
429
+ this.#activeStartedAt = Date.now();
406
430
  try {
407
431
  const result = await this.#writeIdenticalBatchWithRetry(batch);
408
432
  const resultCommitted = result ? result.committed : undefined;
@@ -429,6 +453,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
429
453
  );
430
454
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - releasedBytes);
431
455
  this.#active = null;
456
+ this.#activeStartedAt = null;
432
457
  this.#admitBlocked();
433
458
  this.#notifyStateChanged();
434
459
  }
@@ -58,6 +58,9 @@ export type PlaySchedulerSubmitInput = {
58
58
  force?: boolean;
59
59
  /** Explicit cache bypass for completed ctx.tools.execute receipts. */
60
60
  forceToolRefresh?: boolean | null;
61
+ /** Validated per-run ceiling for provider-tool executions and ctx.fetch. */
62
+ maxConcurrentExternalCalls?: number | null;
63
+ maxConcurrentRows?: number | null;
61
64
  inputFile?: {
62
65
  name?: string;
63
66
  path?: string;
@@ -56,6 +56,8 @@ export type PlayExecutionSuspension =
56
56
  exitCodePath: string;
57
57
  /** Exact customer-code completion fence inside the Daytona sandbox. */
58
58
  runtimeCompletedPath?: string;
59
+ /** Bounded watchdog-owned cause marker for crash-only diagnostics. */
60
+ terminationDiagnosticPath?: string;
59
61
  startedAtMs: number;
60
62
  /** Scheduler-owned liveness deadline. The sandbox can renew it only by
61
63
  * persisting `heartbeat_at` through the receipt gateway. */
@@ -1,4 +1,5 @@
1
1
  import { getRuntimeEnv } from '../runtime-env';
2
+ import type { PlayRunnerEvent } from './protocol';
2
3
 
3
4
  export const PLAY_RUNTIME_TEST_FAULT_HEADER = 'x-deepline-test-fault';
4
5
 
@@ -13,7 +14,23 @@ export type RuntimeTestFaultName =
13
14
  * deterministic real-Pg-pool pressure window without changing an action's
14
15
  * SQL or outcome.
15
16
  */
16
- | 'receipt_gateway_hold_ms';
17
+ | 'receipt_gateway_hold_ms'
18
+ /**
19
+ * Synthetic-only bulk-claim fault. The runner forwards this token on the
20
+ * first qualifying physical claim request only. The gateway holds the real
21
+ * checked-out scheduler client for the supplied milliseconds, allowing the
22
+ * claim-operation deadline and connection destruction path to be exercised
23
+ * end to end without making the fault Machine-local.
24
+ */
25
+ | 'receipt_claim_query_hold_once_ms'
26
+ /**
27
+ * Synthetic-only response-boundary fault. The runner fully receives a
28
+ * successful bulk receipt-claim response, then reports the configured first
29
+ * N physical requests as request timeouts. This proves ambiguous-delivery
30
+ * replay without depending on which gateway Machine accepted the request.
31
+ */
32
+ | 'receipt_claim_response_timeout'
33
+ | 'runtime_sheet_page_tail_hold_ms';
17
34
 
18
35
  export type RuntimeTestFaultRegistry = {
19
36
  consume(name: RuntimeTestFaultName): boolean;
@@ -75,6 +92,9 @@ const SUPPORTED_RUNTIME_TEST_FAULTS = new Set<RuntimeTestFaultName>([
75
92
  'worker_receipt_complete_write_fail',
76
93
  'invocation_response_delivery_abort',
77
94
  'receipt_gateway_hold_ms',
95
+ 'receipt_claim_query_hold_once_ms',
96
+ 'receipt_claim_response_timeout',
97
+ 'runtime_sheet_page_tail_hold_ms',
78
98
  ]);
79
99
 
80
100
  const RUNTIME_TEST_POLICY_MS_FIELDS = new Set([
@@ -149,6 +169,65 @@ function parseRuntimeTestFaultHeader(
149
169
  return faults;
150
170
  }
151
171
 
172
+ /** Parse a known fault count without authorizing a request. Unknown/skewed headers return 0. */
173
+ export function recognizedRuntimeTestFaultCount(
174
+ rawHeader: string | null | undefined,
175
+ name: RuntimeTestFaultName,
176
+ ): number {
177
+ const header = rawHeader?.trim();
178
+ if (!header) return 0;
179
+ const parsed = parseRuntimeTestFaultHeader(header);
180
+ if ('error' in parsed) return 0;
181
+ return parsed.get(name) ?? 0;
182
+ }
183
+
184
+ export function runtimeSheetPageTailRowsContainTarget(
185
+ rows: readonly unknown[],
186
+ ): boolean {
187
+ return rows.some((row) => {
188
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return false;
189
+ const record = row as Record<string, unknown>;
190
+ return record.inputIndex === 999 || record.input_index === 999;
191
+ });
192
+ }
193
+
194
+ export function runtimeSheetPageTailWriteMarkerEvent(
195
+ runtimeTestFaultHeader: string | null | undefined,
196
+ rows: readonly Record<string, unknown>[],
197
+ tableNamespace: string,
198
+ dbSessionStrategy: string | null | undefined,
199
+ ): PlayRunnerEvent | null {
200
+ if (
201
+ dbSessionStrategy !== 'gateway_only' ||
202
+ recognizedRuntimeTestFaultCount(
203
+ runtimeTestFaultHeader,
204
+ 'runtime_sheet_page_tail_hold_ms',
205
+ ) <= 0 ||
206
+ !runtimeSheetPageTailRowsContainTarget(rows)
207
+ ) {
208
+ return null;
209
+ }
210
+ return {
211
+ type: 'log',
212
+ at: new Date().toISOString(),
213
+ source: 'play',
214
+ line: `[runtime.sheet-page-tail-write] phase=start table=${tableNamespace} rows=${rows.length}`,
215
+ };
216
+ }
217
+
218
+ export function runtimeSheetPageTailHoldCompletedEvent(
219
+ holdMs: number | null | undefined,
220
+ tableNamespace: string,
221
+ ): PlayRunnerEvent | null {
222
+ if (!Number.isInteger(holdMs) || (holdMs ?? 0) <= 0) return null;
223
+ return {
224
+ type: 'log',
225
+ at: new Date().toISOString(),
226
+ source: 'play',
227
+ line: `[runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
228
+ };
229
+ }
230
+
152
231
  function readPositiveIntegerField(input: {
153
232
  value: unknown;
154
233
  path: string;
@@ -38,6 +38,19 @@ export type PlayDocflowNode = {
38
38
  id: string;
39
39
  label: string;
40
40
  kind: PlayDocflowNodeKind;
41
+ /**
42
+ * No statement in this play runs this box — the author said so with
43
+ * `class <id> sketch`. See {@link SKETCH_CLASS}.
44
+ *
45
+ * Deliberately NOT a `kind`. What a box IS (an action, a decision, a dataset)
46
+ * and whether code binds it are two different questions, and folding the
47
+ * second into the first was wrong in a way the tests caught immediately: a
48
+ * sketched diamond stopped being a decision, so it lost its shape on the
49
+ * canvas and the branch-label lint stopped checking its arms. Absent rather
50
+ * than `false` when bound, so the JSON a bound diagram hashes to is byte for
51
+ * byte what it was before sketches existed.
52
+ */
53
+ sketch?: true;
41
54
  };
42
55
 
43
56
  /**
@@ -225,6 +238,23 @@ const ATTRIBUTE = /([A-Za-z][\w-]*)\s*:\s*"([^"\n]*)"/y;
225
238
  const MERMAID_NODE_ATTRIBUTES = ['label', 'type', 'in', 'out', 'arm'] as const;
226
239
  const LEGACY_DOCFLOW_ATTRIBUTES = ['id', ...MERMAID_NODE_ATTRIBUTES] as const;
227
240
  const MERMAID_NODE_ID = /^[A-Za-z][\w-]*/;
241
+ /**
242
+ * `class a,b,c sketch` — the author declaring that no statement runs these boxes.
243
+ *
244
+ * Every other box in a diagram must point at a statement in this play's source,
245
+ * because that is what makes the diagram a claim about the code rather than a
246
+ * picture of it. Some boxes honestly cannot: a cascade whose legs live in a
247
+ * sibling module, a loop over a provider list, the outcome boxes hanging off a
248
+ * decision. Those boxes still belong on the canvas — they name the real route —
249
+ * and this is how the author says so out loud, so the reader is told "sketch"
250
+ * instead of being shown a box that looks misconfigured.
251
+ *
252
+ * It is mermaid's own `class` statement rather than a Deepline directive, so the
253
+ * block stays a diagram any mermaid renderer can draw. Class names other than
254
+ * `sketch` remain styling this dashboard does not apply, exactly as before.
255
+ */
256
+ const SKETCH_CLASS =
257
+ /^\s*class\s+([A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*)\s+sketch\s*;?\s*$/;
228
258
  /**
229
259
  * Mermaid's node shapes, longest opener FIRST.
230
260
  *
@@ -601,7 +631,14 @@ function levenshtein(left: string, right: string): number {
601
631
 
602
632
  const MERMAID_BLOCK = /\/\*\*\s*@mermaid(?:\s|\r?\n)([\s\S]*?)\*\//g;
603
633
  const LEGACY_BLOCK = /\/\*\*\s*@docflow(?:\s|\r?\n)([\s\S]*?)\*\//;
604
- const BLOCK_EXPORT_HEADER = /^[A-Za-z_$][\w$]*$/;
634
+ /**
635
+ * What a block header may name: an export name, or the play's own kebab-case
636
+ * name. Hyphens are in the set for the second — `@mermaid name-to-linkedin-url-
637
+ * waterfall` is the header worth writing, and while it was identifier-only the
638
+ * whole line silently became the diagram's first line, which surfaced as
639
+ * "Docflow must start with `flowchart`" and named nothing.
640
+ */
641
+ const BLOCK_EXPORT_HEADER = /^[A-Za-z_$][\w$-]*$/;
605
642
 
606
643
  /** Strips the JSDoc `*` gutter and trims, leaving the authored block text. */
607
644
  function cleanBlockBody(raw: string): string {
@@ -613,7 +650,8 @@ function cleanBlockBody(raw: string): string {
613
650
  }
614
651
 
615
652
  /**
616
- * Splits `/** @mermaid batch` into the export it names and the diagram itself.
653
+ * Splits `/** @mermaid contact-to-phone-waterfall` into the play it names and
654
+ * the diagram itself.
617
655
  *
618
656
  * The name is the first token after the tag, the same place `// @mermaid-node
619
657
  * <id>` puts its target, so one rule covers both halves of the grammar. It is
@@ -649,6 +687,8 @@ type ParsedDocflowBlockGraph = {
649
687
  edges: PlayDocflowEdge[];
650
688
  subgraphs: Map<string, PlayDocflowSubgraph>;
651
689
  ignoredDirectives: string[];
690
+ /** Ids a `class … sketch` line declared to run no statement. */
691
+ sketchIds: Set<string>;
652
692
  };
653
693
 
654
694
  type ParsedDocflowBlock = ParsedDocflowBlockGraph & {
@@ -701,6 +741,9 @@ function parseDocflowBlockGraph(
701
741
  // an edge on an earlier line may reference a subgraph declared later.
702
742
  const subgraphs = new Map<string, PlayDocflowSubgraph>();
703
743
  const ignoredDirectives: string[] = [];
744
+ // Boxes the author declared to be a sketch, via mermaid's own
745
+ // `class a,b sketch`. See {@link SKETCH_CLASS}.
746
+ const sketchIds = new Set<string>();
704
747
  const subgraphStack: PlayDocflowSubgraph[] = [];
705
748
  const subgraphIds = new Set<string>();
706
749
  if (syntax === 'mermaid') {
@@ -796,7 +839,15 @@ function parseDocflowBlockGraph(
796
839
  const isDirective =
797
840
  /^\s*(?:direction|classDef|class|style|linkStyle|click)\b/i.test(line);
798
841
  if (syntax === 'mermaid' && isDirective) {
799
- ignoredDirectives.push(line.trim());
842
+ const sketch = SKETCH_CLASS.exec(line);
843
+ if (sketch) {
844
+ for (const id of sketch[1]!.split(',')) {
845
+ const trimmed = id.trim();
846
+ if (trimmed) sketchIds.add(trimmed);
847
+ }
848
+ } else {
849
+ ignoredDirectives.push(line.trim());
850
+ }
800
851
  }
801
852
  if (syntax === 'mermaid' && !isDirective) {
802
853
  declaredOnThisLine = addNodes(line, line);
@@ -859,7 +910,20 @@ function parseDocflowBlockGraph(
859
910
  );
860
911
  }
861
912
 
862
- return { direction, nodes, edges, subgraphs, ignoredDirectives };
913
+ for (const id of sketchIds) {
914
+ const node = nodes.get(id);
915
+ if (!node) {
916
+ errors.push(
917
+ subgraphs.has(id)
918
+ ? `Docflow \`class ${id} sketch\` names a subgraph. A subgraph is a region, not a box, and never binds code — drop it from the class line.`
919
+ : `Docflow \`class ${id} sketch\` names "${id}", which this diagram does not draw.`,
920
+ );
921
+ continue;
922
+ }
923
+ node.sketch = true;
924
+ }
925
+
926
+ return { direction, nodes, edges, subgraphs, ignoredDirectives, sketchIds };
863
927
  }
864
928
 
865
929
  function materializeDocflow(block: ParsedDocflowBlock): PlayDocflow {
@@ -889,7 +953,11 @@ export function parsePlayDocflowFile(
889
953
  sourceCode: string,
890
954
  ): PlayDocflowFileParseResult {
891
955
  const errors: string[] = [];
892
- const bindings = parseDocflowBindings(sourceCode, errors);
956
+ // Ids whose annotation was written but refused. They are NOT unbound boxes:
957
+ // the author already has one actionable error about them, and telling them to
958
+ // "bind it" on the next line is telling them to do what they just did.
959
+ const rejectedNodeIds = new Set<string>();
960
+ const bindings = parseDocflowBindings(sourceCode, errors, rejectedNodeIds);
893
961
 
894
962
  const rawBlocks: Array<{
895
963
  syntax: NonNullable<PlayDocflow['syntax']>;
@@ -923,7 +991,13 @@ export function parsePlayDocflowFile(
923
991
  }
924
992
 
925
993
  resolveBlockExports(sourceCode, parsedBlocks, errors);
926
- attachBindingsToBlocks(sourceCode, parsedBlocks, bindings, errors);
994
+ attachBindingsToBlocks(
995
+ sourceCode,
996
+ parsedBlocks,
997
+ bindings,
998
+ errors,
999
+ rejectedNodeIds,
1000
+ );
927
1001
 
928
1002
  return {
929
1003
  blocks: parsedBlocks
@@ -1163,7 +1237,7 @@ function projectRecordedArms(
1163
1237
  // One decision, one meaning per token. Two arms both claiming `run` is the
1164
1238
  // exact defect the recorded identity exists to make impossible, so it is
1165
1239
  // refused at the source rather than resolved by precedence downstream.
1166
- const key = `${edge.from}${binding.arm}`;
1240
+ const key = `${edge.from}\u0000${binding.arm}`;
1167
1241
  const already = declaredBy.get(key);
1168
1242
  if (already !== undefined) {
1169
1243
  errors.push(
@@ -1181,6 +1255,7 @@ function attachBindingsToBlocks(
1181
1255
  blocks: ParsedDocflowBlock[],
1182
1256
  bindings: readonly PlayDocflowBinding[],
1183
1257
  errors: string[],
1258
+ rejectedNodeIds: ReadonlySet<string>,
1184
1259
  ): void {
1185
1260
  const lineStarts = sourceLineStartsForDocflow(sourceCode);
1186
1261
  const exportRanges = playExportSourceRanges(sourceCode);
@@ -1227,17 +1302,33 @@ function attachBindingsToBlocks(
1227
1302
  for (const binding of block.bindings) {
1228
1303
  const node = block.nodes.get(binding.nodeId)!;
1229
1304
  if (binding.label) node.label = binding.label;
1305
+ // A box cannot be both a sketch and a bound statement. Silently letting
1306
+ // one win renders a real, traceable step as scenery — or the reverse — and
1307
+ // the author who wrote both has no way to see which they got.
1308
+ if (block.sketchIds.has(binding.nodeId)) {
1309
+ errors.push(
1310
+ `Docflow box "${binding.nodeId}" is declared \`class ${binding.nodeId} sketch\` and also bound by \`// @mermaid-node ${binding.nodeId}\` on line ${binding.line}. It is one or the other: drop it from the class line, or drop the annotation.`,
1311
+ );
1312
+ continue;
1313
+ }
1230
1314
  if (binding.kind) node.kind = binding.kind;
1231
1315
  }
1232
1316
  projectRecordedArms(block, errors);
1233
- if (block.syntax !== 'docflow') continue;
1234
1317
  const bound = new Set(block.bindings.map((binding) => binding.nodeId));
1235
1318
  for (const node of block.nodes.values()) {
1236
- if (node.kind !== 'conceptual' && !bound.has(node.id)) {
1237
- errors.push(
1238
- `Docflow node "${node.id}" has no code binding; mark it type:"conceptual" or bind it.`,
1239
- );
1240
- }
1319
+ if (
1320
+ node.sketch ||
1321
+ node.kind === 'conceptual' ||
1322
+ bound.has(node.id) ||
1323
+ rejectedNodeIds.has(node.id)
1324
+ )
1325
+ continue;
1326
+ errors.push(
1327
+ block.syntax === 'docflow'
1328
+ ? `Docflow node "${node.id}" has no code binding; mark it type:"conceptual" or bind it.`
1329
+ : `Docflow box "${node.id}" in \`${blockLabel(block)}\` points at nothing, so the canvas can only draw it and say nothing about it. ` +
1330
+ `Either put \`// @mermaid-node ${node.id}\` above the statement it runs, or — if this play has no such statement, because the work happens in another module or inside a loop — add it to a \`class ${node.id} sketch\` line in the block to declare it a sketch.`,
1331
+ );
1241
1332
  }
1242
1333
  }
1243
1334
  }
@@ -1292,6 +1383,7 @@ export function parsePlayDocflow(
1292
1383
  function parseDocflowBindings(
1293
1384
  sourceCode: string,
1294
1385
  errors: string[],
1386
+ rejectedNodeIds: Set<string>,
1295
1387
  ): PlayDocflowBinding[] {
1296
1388
  const lines = sourceCode.split(/\r?\n/);
1297
1389
  const bindings: PlayDocflowBinding[] = [];
@@ -1302,6 +1394,11 @@ function parseDocflowBindings(
1302
1394
  const legacyMatch = PUT.exec(lines[index]!);
1303
1395
  const mermaidMatch = MERMAID_NODE.exec(lines[index]!);
1304
1396
  if (!legacyMatch && !mermaidMatch) continue;
1397
+ // Whatever this annotation names, the author has now written it down. Every
1398
+ // `continue` below is a rejection, and a rejected id must not come back as
1399
+ // an unbound box — see `rejectedNodeIds` in `parsePlayDocflowFile`.
1400
+ const annotatedId = mermaidMatch?.[1]?.trim();
1401
+ if (annotatedId) rejectedNodeIds.add(annotatedId);
1305
1402
  const attributes = parseAttributes(
1306
1403
  mermaidMatch ? (mermaidMatch[2] ?? '') : legacyMatch![1]!,
1307
1404
  {
@@ -1311,11 +1408,12 @@ function parseDocflowBindings(
1311
1408
  },
1312
1409
  );
1313
1410
  if (!attributes) continue;
1314
- const id = (mermaidMatch ? mermaidMatch[1] : attributes.id)?.trim();
1411
+ const id = (annotatedId ?? attributes.id)?.trim();
1315
1412
  if (!id) {
1316
1413
  errors.push(`Docflow annotation on line ${index + 1} requires id:"…".`);
1317
1414
  continue;
1318
1415
  }
1416
+ rejectedNodeIds.add(id);
1319
1417
  const kind = nodeKind(attributes.type);
1320
1418
  if (attributes.type && !kind) {
1321
1419
  errors.push(
@@ -1370,6 +1468,7 @@ function parseDocflowBindings(
1370
1468
  ioConfidence: 'explicit' as const,
1371
1469
  }
1372
1470
  : inferBindingIo(lines[nextLine]!);
1471
+ rejectedNodeIds.delete(id);
1373
1472
  bindings.push({
1374
1473
  nodeId: id,
1375
1474
  line: nextLine + 1,