@blokjs/runner 0.4.0 → 0.6.0

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 (163) hide show
  1. package/dist/Blok.js +32 -3
  2. package/dist/Blok.js.map +1 -1
  3. package/dist/Configuration.d.ts +41 -5
  4. package/dist/Configuration.js +215 -92
  5. package/dist/Configuration.js.map +1 -1
  6. package/dist/ForEachNode.d.ts +59 -0
  7. package/dist/ForEachNode.js +522 -0
  8. package/dist/ForEachNode.js.map +1 -0
  9. package/dist/LoopMaxIterationsError.d.ts +11 -0
  10. package/dist/LoopMaxIterationsError.js +18 -0
  11. package/dist/LoopMaxIterationsError.js.map +1 -0
  12. package/dist/LoopNode.d.ts +36 -0
  13. package/dist/LoopNode.js +182 -0
  14. package/dist/LoopNode.js.map +1 -0
  15. package/dist/Runner.d.ts +11 -1
  16. package/dist/Runner.js +9 -2
  17. package/dist/Runner.js.map +1 -1
  18. package/dist/RunnerSteps.js +419 -112
  19. package/dist/RunnerSteps.js.map +1 -1
  20. package/dist/RuntimeAdapterNode.d.ts +2 -1
  21. package/dist/RuntimeAdapterNode.js +2 -2
  22. package/dist/RuntimeAdapterNode.js.map +1 -1
  23. package/dist/RuntimeRegistry.d.ts +23 -2
  24. package/dist/RuntimeRegistry.js +31 -2
  25. package/dist/RuntimeRegistry.js.map +1 -1
  26. package/dist/SubworkflowNode.d.ts +106 -0
  27. package/dist/SubworkflowNode.js +261 -3
  28. package/dist/SubworkflowNode.js.map +1 -1
  29. package/dist/SwitchNode.d.ts +37 -0
  30. package/dist/SwitchNode.js +153 -0
  31. package/dist/SwitchNode.js.map +1 -0
  32. package/dist/TriggerBase.d.ts +50 -0
  33. package/dist/TriggerBase.js +262 -4
  34. package/dist/TriggerBase.js.map +1 -1
  35. package/dist/TryCatchNode.d.ts +32 -0
  36. package/dist/TryCatchNode.js +207 -0
  37. package/dist/TryCatchNode.js.map +1 -0
  38. package/dist/adapters/grpc/GrpcCodec.js +2 -2
  39. package/dist/adapters/grpc/GrpcRuntimeAdapter.d.ts +6 -4
  40. package/dist/adapters/grpc/GrpcRuntimeAdapter.js +6 -4
  41. package/dist/adapters/grpc/GrpcRuntimeAdapter.js.map +1 -1
  42. package/dist/adapters/grpc/types.d.ts +7 -5
  43. package/dist/adapters/grpc/types.js.map +1 -1
  44. package/dist/adapters/transport.d.ts +12 -41
  45. package/dist/adapters/transport.js +21 -70
  46. package/dist/adapters/transport.js.map +1 -1
  47. package/dist/cache/NodeResultCache.js +7 -0
  48. package/dist/cache/NodeResultCache.js.map +1 -1
  49. package/dist/concurrency/NatsKvConcurrencyBackend.js +18 -5
  50. package/dist/concurrency/NatsKvConcurrencyBackend.js.map +1 -1
  51. package/dist/concurrency/RedisConcurrencyBackend.d.ts +64 -0
  52. package/dist/concurrency/RedisConcurrencyBackend.js +374 -0
  53. package/dist/concurrency/RedisConcurrencyBackend.js.map +1 -0
  54. package/dist/concurrency/createConcurrencyBackend.d.ts +1 -0
  55. package/dist/concurrency/createConcurrencyBackend.js +5 -1
  56. package/dist/concurrency/createConcurrencyBackend.js.map +1 -1
  57. package/dist/defineNode.d.ts +8 -0
  58. package/dist/defineNode.js +25 -5
  59. package/dist/defineNode.js.map +1 -1
  60. package/dist/graphql/GraphQLSchemaGenerator.js +1 -1
  61. package/dist/graphql/GraphQLSchemaGenerator.js.map +1 -1
  62. package/dist/index.d.ts +10 -6
  63. package/dist/index.js +13 -9
  64. package/dist/index.js.map +1 -1
  65. package/dist/marketplace/RuntimeCatalog.d.ts +6 -0
  66. package/dist/marketplace/RuntimeCatalog.js.map +1 -1
  67. package/dist/marketplace/RuntimeDiscovery.d.ts +2 -2
  68. package/dist/marketplace/RuntimeDiscovery.js +18 -6
  69. package/dist/marketplace/RuntimeDiscovery.js.map +1 -1
  70. package/dist/monitoring/ConcurrencyMetrics.d.ts +26 -0
  71. package/dist/monitoring/ConcurrencyMetrics.js +36 -4
  72. package/dist/monitoring/ConcurrencyMetrics.js.map +1 -1
  73. package/dist/monitoring/ForEachWaitMetrics.d.ts +22 -0
  74. package/dist/monitoring/ForEachWaitMetrics.js +36 -0
  75. package/dist/monitoring/ForEachWaitMetrics.js.map +1 -0
  76. package/dist/openapi/OpenAPIGenerator.js +7 -2
  77. package/dist/openapi/OpenAPIGenerator.js.map +1 -1
  78. package/dist/runtime/PrimitiveStack.d.ts +64 -0
  79. package/dist/runtime/PrimitiveStack.js +92 -0
  80. package/dist/runtime/PrimitiveStack.js.map +1 -0
  81. package/dist/scheduling/DebounceBackend.d.ts +108 -0
  82. package/dist/scheduling/DebounceBackend.js +23 -0
  83. package/dist/scheduling/DebounceBackend.js.map +1 -0
  84. package/dist/scheduling/DebounceCoordinator.d.ts +65 -12
  85. package/dist/scheduling/DebounceCoordinator.js +234 -13
  86. package/dist/scheduling/DebounceCoordinator.js.map +1 -1
  87. package/dist/scheduling/DeferredRunScheduler.d.ts +28 -0
  88. package/dist/scheduling/DeferredRunScheduler.js +105 -3
  89. package/dist/scheduling/DeferredRunScheduler.js.map +1 -1
  90. package/dist/scheduling/NatsKvDebounceBackend.d.ts +53 -0
  91. package/dist/scheduling/NatsKvDebounceBackend.js +334 -0
  92. package/dist/scheduling/NatsKvDebounceBackend.js.map +1 -0
  93. package/dist/scheduling/RedisDebounceBackend.d.ts +49 -0
  94. package/dist/scheduling/RedisDebounceBackend.js +356 -0
  95. package/dist/scheduling/RedisDebounceBackend.js.map +1 -0
  96. package/dist/scheduling/createDebounceBackend.d.ts +25 -0
  97. package/dist/scheduling/createDebounceBackend.js +39 -0
  98. package/dist/scheduling/createDebounceBackend.js.map +1 -0
  99. package/dist/security/AuditLogger.js +1 -1
  100. package/dist/security/AuditLogger.js.map +1 -1
  101. package/dist/security/AuthMiddleware.d.ts +19 -20
  102. package/dist/security/AuthMiddleware.js +35 -20
  103. package/dist/security/AuthMiddleware.js.map +1 -1
  104. package/dist/security/OAuthProvider.js +2 -2
  105. package/dist/security/OAuthProvider.js.map +1 -1
  106. package/dist/security/SecretManager.js +14 -13
  107. package/dist/security/SecretManager.js.map +1 -1
  108. package/dist/security/index.d.ts +3 -1
  109. package/dist/security/index.js +3 -1
  110. package/dist/security/index.js.map +1 -1
  111. package/dist/testing/TestHarness.d.ts +27 -12
  112. package/dist/testing/TestHarness.js +19 -3
  113. package/dist/testing/TestHarness.js.map +1 -1
  114. package/dist/testing/WorkflowTestRunner.js +0 -7
  115. package/dist/testing/WorkflowTestRunner.js.map +1 -1
  116. package/dist/tracing/InMemoryRunStore.d.ts +14 -1
  117. package/dist/tracing/InMemoryRunStore.js +95 -6
  118. package/dist/tracing/InMemoryRunStore.js.map +1 -1
  119. package/dist/tracing/PostgresRunStore.d.ts +28 -2
  120. package/dist/tracing/PostgresRunStore.js +276 -3
  121. package/dist/tracing/PostgresRunStore.js.map +1 -1
  122. package/dist/tracing/RoutingDiagnostics.d.ts +55 -0
  123. package/dist/tracing/RoutingDiagnostics.js +50 -0
  124. package/dist/tracing/RoutingDiagnostics.js.map +1 -0
  125. package/dist/tracing/RunStore.d.ts +82 -1
  126. package/dist/tracing/RunTracker.d.ts +7 -1
  127. package/dist/tracing/RunTracker.js +23 -0
  128. package/dist/tracing/RunTracker.js.map +1 -1
  129. package/dist/tracing/SqliteRunStore.d.ts +57 -2
  130. package/dist/tracing/SqliteRunStore.js +408 -48
  131. package/dist/tracing/SqliteRunStore.js.map +1 -1
  132. package/dist/tracing/TraceRouter.js +380 -18
  133. package/dist/tracing/TraceRouter.js.map +1 -1
  134. package/dist/tracing/createStore.js +14 -3
  135. package/dist/tracing/createStore.js.map +1 -1
  136. package/dist/tracing/metadataFilter.d.ts +63 -0
  137. package/dist/tracing/metadataFilter.js +224 -0
  138. package/dist/tracing/metadataFilter.js.map +1 -0
  139. package/dist/tracing/types.d.ts +331 -7
  140. package/dist/utils/envAllowlist.d.ts +35 -0
  141. package/dist/utils/envAllowlist.js +113 -0
  142. package/dist/utils/envAllowlist.js.map +1 -0
  143. package/dist/version/RuntimeVersionValidator.d.ts +38 -0
  144. package/dist/version/RuntimeVersionValidator.js +121 -0
  145. package/dist/version/RuntimeVersionValidator.js.map +1 -0
  146. package/dist/visualization/WorkflowVisualizer.js +4 -4
  147. package/dist/visualization/WorkflowVisualizer.js.map +1 -1
  148. package/dist/workflow/PersistenceHelper.d.ts +18 -10
  149. package/dist/workflow/PersistenceHelper.js +35 -9
  150. package/dist/workflow/PersistenceHelper.js.map +1 -1
  151. package/dist/workflow/WorkflowNormalizer.d.ts +19 -1
  152. package/dist/workflow/WorkflowNormalizer.js +469 -19
  153. package/dist/workflow/WorkflowNormalizer.js.map +1 -1
  154. package/dist/workflow/WorkflowRegistry.d.ts +122 -0
  155. package/dist/workflow/WorkflowRegistry.js +121 -0
  156. package/dist/workflow/WorkflowRegistry.js.map +1 -1
  157. package/dist/workflow/sampleBody.d.ts +54 -0
  158. package/dist/workflow/sampleBody.js +320 -0
  159. package/dist/workflow/sampleBody.js.map +1 -0
  160. package/package.json +3 -8
  161. package/dist/adapters/HttpRuntimeAdapter.d.ts +0 -79
  162. package/dist/adapters/HttpRuntimeAdapter.js +0 -233
  163. package/dist/adapters/HttpRuntimeAdapter.js.map +0 -1
@@ -1,22 +1,63 @@
1
1
  import { createRequire } from "node:module";
2
+ import { isValidMetadataKey, normaliseMetadataFilters, translateFilterToSql } from "./metadataFilter";
2
3
  const esmRequire = createRequire(import.meta.url);
4
+ function encodeNodeRunFlags(nodeRun) {
5
+ const flags = {};
6
+ if (nodeRun.wait !== undefined)
7
+ flags.wait = nodeRun.wait;
8
+ if (nodeRun.dispatch !== undefined)
9
+ flags.dispatch = nodeRun.dispatch;
10
+ if (nodeRun.subworkflowDepth !== undefined)
11
+ flags.subworkflowDepth = nodeRun.subworkflowDepth;
12
+ if (nodeRun.middleware !== undefined)
13
+ flags.middleware = nodeRun.middleware;
14
+ if (nodeRun.iterationIndex !== undefined)
15
+ flags.iterationIndex = nodeRun.iterationIndex;
16
+ const empty = Object.keys(flags).length === 0;
17
+ return empty ? null : JSON.stringify(flags);
18
+ }
19
+ function decodeNodeRunFlags(raw) {
20
+ if (!raw)
21
+ return {};
22
+ try {
23
+ const parsed = JSON.parse(raw);
24
+ if (parsed && typeof parsed === "object")
25
+ return parsed;
26
+ }
27
+ catch {
28
+ // Corrupt JSON (concurrent write race, manual edit, etc.) — fall
29
+ // through to empty flags rather than failing the whole rowToNodeRun.
30
+ }
31
+ return {};
32
+ }
3
33
  const isBun = "Bun" in globalThis;
4
34
  /**
5
- * SQLite-backed RunStore supporting both bun:sqlite and better-sqlite3.
6
- *
7
- * When running under Bun, uses the built-in bun:sqlite module for
8
- * optimal performance. Falls back to better-sqlite3 under Node.js.
9
- *
10
- * Provides persistent trace storage that survives process restarts.
11
- * All operations are synchronous.
12
- *
13
- * Schema is auto-migrated on construction via a versioned migration system.
35
+ * F1 read `BLOK_INDEXED_METADATA_KEYS=tier,region` from the env.
36
+ * Empty / unset = no F1 indexing. Exposed for test seams; production
37
+ * code reads through the `SqliteRunStore` constructor.
14
38
  */
39
+ export function readIndexedMetadataKeysFromEnv(env = process.env) {
40
+ const raw = env.BLOK_INDEXED_METADATA_KEYS;
41
+ if (typeof raw !== "string" || raw.length === 0)
42
+ return [];
43
+ return raw
44
+ .split(",")
45
+ .map((k) => k.trim())
46
+ .filter((k) => k.length > 0);
47
+ }
15
48
  export class SqliteRunStore {
16
49
  db;
17
50
  // Prepared statements (lazy-initialized)
18
51
  stmts = {};
19
- constructor(dbPath = ".blok/trace.db") {
52
+ /**
53
+ * F1 — the validated, frozen set of metadata keys that have an
54
+ * indexed generated column on `workflow_runs`. Read at query time
55
+ * to decide whether a filter's LHS is `metadata_<key>_idx`
56
+ * (indexed path) or `json_extract(metadata_json, '$.<key>')`
57
+ * (sequential path).
58
+ */
59
+ indexedMetadataKeys;
60
+ constructor(dbPath = ".blok/trace.db", opts) {
20
61
  if (isBun) {
21
62
  // Use Bun's built-in SQLite (3-6x faster than better-sqlite3)
22
63
  const bunMod = "bun:sqlite";
@@ -43,6 +84,50 @@ export class SqliteRunStore {
43
84
  this.db.exec("PRAGMA synchronous = NORMAL");
44
85
  this.db.exec("PRAGMA foreign_keys = ON");
45
86
  this.migrate();
87
+ // F1 — resolve declared indexed keys (constructor option wins over
88
+ // env var) and ensure each one has a generated column + index.
89
+ // Runs AFTER `migrate()` so the base `workflow_runs` table exists.
90
+ const declared = opts?.indexedMetadataKeys ?? readIndexedMetadataKeysFromEnv();
91
+ this.indexedMetadataKeys = new Set(declared.filter((k) => isValidMetadataKey(k)));
92
+ this.ensureIndexedMetadataColumns(this.indexedMetadataKeys);
93
+ }
94
+ /**
95
+ * F1 — for each declared key, ensure (a) the `metadata_<key>_idx`
96
+ * virtual generated column exists on `workflow_runs`, and (b) an
97
+ * index on that column exists. Both are idempotent — pre-existing
98
+ * columns are detected via `PRAGMA table_info` since SQLite doesn't
99
+ * support `ALTER TABLE ADD COLUMN IF NOT EXISTS`.
100
+ *
101
+ * Generated columns are `VIRTUAL` rather than `STORED` so they can
102
+ * be added to a non-empty table (STORED columns can't, per SQLite's
103
+ * `ALTER TABLE` rules). The query planner can still use a btree
104
+ * index built on a VIRTUAL column — that's the F1 win.
105
+ */
106
+ ensureIndexedMetadataColumns(keys) {
107
+ if (keys.size === 0)
108
+ return;
109
+ // Use `table_xinfo` rather than `table_info` — virtual generated
110
+ // columns are HIDDEN columns and `PRAGMA table_info` deliberately
111
+ // omits them. `table_xinfo` returns hidden columns too (with
112
+ // `hidden=2` for virtual generated columns specifically), so the
113
+ // idempotency check actually works against a previously-added
114
+ // column on a re-opened db.
115
+ const existingColumns = new Set(this.db
116
+ .prepare("PRAGMA table_xinfo(workflow_runs)")
117
+ .all()
118
+ .map((r) => r.name));
119
+ for (const key of keys) {
120
+ const columnName = `metadata_${key}_idx`;
121
+ const indexName = `idx_workflow_runs_metadata_${key}`;
122
+ if (!existingColumns.has(columnName)) {
123
+ // TEXT type-hint is explicit so the planner has a stable
124
+ // expectation; the actual stored type still matches
125
+ // whatever `json_extract` returns at evaluation time
126
+ // (SQLite uses dynamic type affinity).
127
+ this.db.exec(`ALTER TABLE workflow_runs ADD COLUMN ${columnName} TEXT GENERATED ALWAYS AS (json_extract(metadata_json, '$.${key}')) VIRTUAL`);
128
+ }
129
+ this.db.exec(`CREATE INDEX IF NOT EXISTS ${indexName} ON workflow_runs(${columnName})`);
130
+ }
46
131
  }
47
132
  // === Schema Migration ===
48
133
  migrate() {
@@ -332,6 +417,129 @@ export class SqliteRunStore {
332
417
  ALTER TABLE workflow_runs ADD COLUMN last_completed_step_index INTEGER;
333
418
  `,
334
419
  },
420
+ {
421
+ // v0.6 prerequisite for wait-inside-primitives Phase 2.
422
+ //
423
+ // state_snapshot is a JSON blob of `ctx.state` taken
424
+ // immediately before RunnerSteps throws WaitDispatchRequest.
425
+ // On dispatchDeferred re-entry — especially the cross-process
426
+ // recovery path where a fresh ctx is rebuilt from the
427
+ // persisted scheduled_dispatches row — TriggerBase.run reads
428
+ // this column and rehydrates ctx.state so subsequent steps
429
+ // see the same pre-wait state regardless of restart.
430
+ //
431
+ // Default NULL (= no snapshot; runner doesn't rehydrate,
432
+ // preserving exact pre-v0.6 behaviour for runs that never
433
+ // hit a wait).
434
+ version: 11,
435
+ sql: `
436
+ ALTER TABLE workflow_runs ADD COLUMN state_snapshot TEXT;
437
+ `,
438
+ },
439
+ {
440
+ // v0.6 wait-inside-primitives Phase 2 — sequential forEach
441
+ // iteration cursor.
442
+ //
443
+ // iteration_context is a JSON blob written to the FOREACH'S
444
+ // NodeRun (not the inner-step NodeRun) when an inner step
445
+ // throws WaitDispatchRequest mid-iteration. Records:
446
+ // - `iteration`: which iteration of the forEach was in flight
447
+ // - `innerStepIndex`: which inner step within that iteration
448
+ // fired the wait
449
+ // - `completedResults`: the partial accumulator for
450
+ // iterations [0..iteration-1], so the post-resume final
451
+ // result array covers EVERY iteration
452
+ //
453
+ // Read on dispatchDeferred re-entry by TriggerBase.run, which
454
+ // rehydrates the column onto ctx as `_blokIterationResume`;
455
+ // ForEachNode picks it up to resume at the right iteration +
456
+ // inner step. Default NULL preserves pre-v0.6 behaviour for
457
+ // NodeRuns that never threw a wait (the vast majority).
458
+ //
459
+ // Phase 2 wires this for SEQUENTIAL forEach only. Future
460
+ // phases reuse the column with primitive-specific shapes
461
+ // (parallel forEach, loop, switch).
462
+ version: 12,
463
+ sql: `
464
+ ALTER TABLE node_runs ADD COLUMN iteration_context TEXT;
465
+ `,
466
+ },
467
+ {
468
+ // Tier C #2 — cross-process scheduler coordination.
469
+ //
470
+ // Multi-process deployments sharing one PG / sqlite file risk
471
+ // double-firing the same dispatch when each process's
472
+ // `recoverDispatches()` independently registers timers. The
473
+ // claim columns let processes atomically take ownership of
474
+ // rows during recovery; the heartbeat keeps the claim fresh
475
+ // while the timer is registered; a dead holder's claim
476
+ // expires after `BLOK_SCHEDULER_CLAIM_LEASE_MS` and a
477
+ // surviving process can take over on its next recovery.
478
+ //
479
+ // Single-process sqlite deployments see no observable
480
+ // behavior change (the same process always wins the claim).
481
+ // Additive — pre-existing rows get NULL claim columns and
482
+ // become claimable on the next recovery.
483
+ version: 13,
484
+ sql: `
485
+ ALTER TABLE scheduled_dispatches ADD COLUMN claimed_by TEXT;
486
+ ALTER TABLE scheduled_dispatches ADD COLUMN claimed_at INTEGER;
487
+ CREATE INDEX IF NOT EXISTS idx_scheduled_dispatches_claim ON scheduled_dispatches(claimed_by, claimed_at);
488
+ `,
489
+ },
490
+ {
491
+ // E2 · server-side saved filters for the runs list. Replaces
492
+ // the prior `localStorage` impl in Studio so filter presets
493
+ // survive a fresh browser. `name` is UNIQUE — saving with an
494
+ // existing name overwrites the row (matches the localStorage
495
+ // "replace by name" semantics callers already rely on).
496
+ version: 14,
497
+ sql: `
498
+ CREATE TABLE IF NOT EXISTS trace_saved_filters (
499
+ id TEXT PRIMARY KEY,
500
+ name TEXT NOT NULL UNIQUE,
501
+ status TEXT NOT NULL DEFAULT '',
502
+ tags_input TEXT NOT NULL DEFAULT '',
503
+ metadata_input TEXT NOT NULL DEFAULT '',
504
+ created_at INTEGER NOT NULL,
505
+ updated_at INTEGER NOT NULL
506
+ );
507
+ CREATE INDEX IF NOT EXISTS idx_saved_filters_updated_at ON trace_saved_filters(updated_at);
508
+ `,
509
+ },
510
+ {
511
+ // v0.6 follow-up to #100 — recorded sample bodies for the
512
+ // Studio empty-state curl. Opt-in per HTTP trigger via
513
+ // `recordSample: true`. ONE row per workflow (PK on
514
+ // workflow_name); the trigger only writes the FIRST
515
+ // successful run's body so the operator-visible curl stays
516
+ // stable. Author overrides + static inference still apply
517
+ // when no row exists.
518
+ version: 15,
519
+ sql: `
520
+ CREATE TABLE IF NOT EXISTS trace_workflow_samples (
521
+ workflow_name TEXT PRIMARY KEY,
522
+ body_json TEXT NOT NULL,
523
+ source_run_id TEXT NOT NULL,
524
+ recorded_at INTEGER NOT NULL
525
+ );
526
+ `,
527
+ },
528
+ {
529
+ // v0.6 follow-up — bag column for Studio-visible step flags.
530
+ // Until now `wait`, `subworkflowDepth`, `middleware`,
531
+ // `iterationIndex` lived only on the in-memory NodeRun and
532
+ // silently dropped on sqlite/PG round-trip — the rail badges
533
+ // vanished on browser refresh or process restart. The new
534
+ // G2 `dispatch` field would have inherited the same gap.
535
+ // One opaque JSON column holds whichever of the five fields
536
+ // are set; pre-v16 rows get NULL (which decodes back to all
537
+ // fields undefined — matches the previous behaviour exactly).
538
+ version: 16,
539
+ sql: `
540
+ ALTER TABLE node_runs ADD COLUMN flags_json TEXT;
541
+ `,
542
+ },
335
543
  ];
336
544
  const applyMigration = this.db.transaction((m) => {
337
545
  this.db.exec(m.sql);
@@ -359,9 +567,9 @@ export class SqliteRunStore {
359
567
  tags_json, metadata_json, node_count, completed_nodes, environment, replay_of,
360
568
  parent_run_id, parent_node_run_id,
361
569
  scheduled_at, expires_at, debounce_key, debounce_mode, ping_count,
362
- last_completed_step_index)
363
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
364
- `).run(run.id, run.workflowName, run.workflowPath, run.triggerType, run.triggerSummary, run.status, run.startedAt, run.finishedAt ?? null, run.durationMs ?? null, run.error ? JSON.stringify(run.error) : null, JSON.stringify(run.tags || []), run.metadata ? JSON.stringify(run.metadata) : null, run.nodeCount, run.completedNodes, run.environment ?? null, run.replayOf ?? null, run.parentRunId ?? null, run.parentNodeRunId ?? null, run.scheduledAt ?? null, run.expiresAt ?? null, run.debounceKey ?? null, run.debounceMode ?? null, run.pingCount ?? null, run.lastCompletedStepIndex ?? null);
570
+ last_completed_step_index, state_snapshot)
571
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
572
+ `).run(run.id, run.workflowName, run.workflowPath, run.triggerType, run.triggerSummary, run.status, run.startedAt, run.finishedAt ?? null, run.durationMs ?? null, run.error ? JSON.stringify(run.error) : null, JSON.stringify(run.tags || []), run.metadata ? JSON.stringify(run.metadata) : null, run.nodeCount, run.completedNodes, run.environment ?? null, run.replayOf ?? null, run.parentRunId ?? null, run.parentNodeRunId ?? null, run.scheduledAt ?? null, run.expiresAt ?? null, run.debounceKey ?? null, run.debounceMode ?? null, run.pingCount ?? null, run.lastCompletedStepIndex ?? null, run.stateSnapshot ?? null);
365
573
  }
366
574
  updateRun(runId, updates) {
367
575
  const setClauses = [];
@@ -430,6 +638,10 @@ export class SqliteRunStore {
430
638
  setClauses.push("last_completed_step_index = ?");
431
639
  values.push(updates.lastCompletedStepIndex);
432
640
  }
641
+ if (updates.stateSnapshot !== undefined) {
642
+ setClauses.push("state_snapshot = ?");
643
+ values.push(updates.stateSnapshot);
644
+ }
433
645
  if (updates.startedAt !== undefined) {
434
646
  setClauses.push("started_at = ?");
435
647
  values.push(updates.startedAt);
@@ -445,9 +657,10 @@ export class SqliteRunStore {
445
657
  (id, run_id, node_name, node_type, runtime_kind,
446
658
  status, started_at, finished_at, duration_ms,
447
659
  inputs_json, outputs_json, error_json,
448
- parent_node_id, depth, step_index, metrics_json, cached_json, attempts_json)
449
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
450
- `).run(nodeRun.id, nodeRun.runId, nodeRun.nodeName, nodeRun.nodeType, nodeRun.runtimeKind ?? null, nodeRun.status, nodeRun.startedAt, nodeRun.finishedAt ?? null, nodeRun.durationMs ?? null, nodeRun.inputs !== undefined ? JSON.stringify(nodeRun.inputs) : null, nodeRun.outputs !== undefined ? JSON.stringify(nodeRun.outputs) : null, nodeRun.error ? JSON.stringify(nodeRun.error) : null, nodeRun.parentNodeId ?? null, nodeRun.depth, nodeRun.stepIndex, nodeRun.metrics ? JSON.stringify(nodeRun.metrics) : null, nodeRun.cached ? JSON.stringify(nodeRun.cached) : null, nodeRun.attempts ? JSON.stringify(nodeRun.attempts) : null);
660
+ parent_node_id, depth, step_index, metrics_json, cached_json, attempts_json,
661
+ iteration_context, flags_json)
662
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
663
+ `).run(nodeRun.id, nodeRun.runId, nodeRun.nodeName, nodeRun.nodeType, nodeRun.runtimeKind ?? null, nodeRun.status, nodeRun.startedAt, nodeRun.finishedAt ?? null, nodeRun.durationMs ?? null, nodeRun.inputs !== undefined ? JSON.stringify(nodeRun.inputs) : null, nodeRun.outputs !== undefined ? JSON.stringify(nodeRun.outputs) : null, nodeRun.error ? JSON.stringify(nodeRun.error) : null, nodeRun.parentNodeId ?? null, nodeRun.depth, nodeRun.stepIndex, nodeRun.metrics ? JSON.stringify(nodeRun.metrics) : null, nodeRun.cached ? JSON.stringify(nodeRun.cached) : null, nodeRun.attempts ? JSON.stringify(nodeRun.attempts) : null, nodeRun.iterationContext ? JSON.stringify(nodeRun.iterationContext) : null, encodeNodeRunFlags(nodeRun));
451
664
  }
452
665
  updateNodeRun(nodeRunId, updates) {
453
666
  const setClauses = [];
@@ -484,6 +697,10 @@ export class SqliteRunStore {
484
697
  setClauses.push("attempts_json = ?");
485
698
  values.push(JSON.stringify(updates.attempts));
486
699
  }
700
+ if (updates.iterationContext !== undefined) {
701
+ setClauses.push("iteration_context = ?");
702
+ values.push(JSON.stringify(updates.iterationContext));
703
+ }
487
704
  if (setClauses.length === 0)
488
705
  return;
489
706
  values.push(nodeRunId);
@@ -517,21 +734,30 @@ export class SqliteRunStore {
517
734
  conditions.push("status = ?");
518
735
  params.push(opts.status);
519
736
  }
520
- // Tier 2 quick-wins — metadata key=value filter via json_extract.
521
- // Multiple key=value pairs combine with AND semantics. Indexed scans
522
- // aren't possible against arbitrary JSON keys; sequential scan is
523
- // acceptable given the runs table has size cap via evictOldRuns.
737
+ // F2 (v0.5) — metadata filter with operator support
738
+ // (eq / ne / gt / gte / lt / lte / like / in / nin) via
739
+ // `translateFilterToSql`. F1 (v0.5) declared indexed keys
740
+ // (`BLOK_INDEXED_METADATA_KEYS=tier,region`) reference
741
+ // `metadata_<key>_idx` (the generated column ensured at
742
+ // construction time) so the query planner uses the btree
743
+ // index instead of a sequential `json_extract` scan.
744
+ //
745
+ // `normaliseMetadataFilters` accepts both the legacy
746
+ // `Record<string, string>` shape (v0.4) AND the new
747
+ // `MetadataFilter[]` shape (v0.5) — keys outside
748
+ // `/^[a-zA-Z0-9_-]+$/` are silently dropped for JSON-path
749
+ // injection safety.
524
750
  if (opts?.metadata) {
525
- const entries = Object.entries(opts.metadata);
526
- for (const [k, v] of entries) {
527
- // Use prefixed paths for safety: only allow keys matching
528
- // /^[a-zA-Z0-9_-]+$/ to prevent JSON path injection. Keys with
529
- // special characters silently skip filtering — caller can
530
- // always fall back to client-side filter for those.
531
- if (!/^[a-zA-Z0-9_-]+$/.test(k))
751
+ const filters = normaliseMetadataFilters(opts.metadata);
752
+ for (const f of filters) {
753
+ const lhs = this.indexedMetadataKeys.has(f.key)
754
+ ? `metadata_${f.key}_idx`
755
+ : `json_extract(metadata_json, '$.${f.key}')`;
756
+ const translated = translateFilterToSql(f, lhs);
757
+ if (!translated)
532
758
  continue;
533
- conditions.push(`json_extract(metadata_json, '$.${k}') = ?`);
534
- params.push(v);
759
+ conditions.push(translated.fragment);
760
+ params.push(...translated.params);
535
761
  }
536
762
  }
537
763
  const tags = opts?.tags;
@@ -845,6 +1071,84 @@ export class SqliteRunStore {
845
1071
  values.push(dashboardId);
846
1072
  this.db.prepare(`UPDATE dashboards SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
847
1073
  }
1074
+ // === Saved filters (E2) ===
1075
+ upsertSavedFilter(filter) {
1076
+ // UNIQUE(name) lets us collapse "create or overwrite" into one
1077
+ // UPSERT — preserves the existing `id` + `created_at` when the
1078
+ // name matches, bumps `updated_at` to now. Matches the legacy
1079
+ // localStorage replace-by-name semantics callers depend on.
1080
+ const persisted = this.db.transaction((f) => {
1081
+ const existing = this.db.prepare("SELECT id, created_at FROM trace_saved_filters WHERE name = ?").get(f.name);
1082
+ const id = existing?.id ?? f.id;
1083
+ const createdAt = existing?.created_at ?? f.createdAt;
1084
+ const updatedAt = f.updatedAt;
1085
+ this.db
1086
+ .prepare(`
1087
+ INSERT INTO trace_saved_filters
1088
+ (id, name, status, tags_input, metadata_input, created_at, updated_at)
1089
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1090
+ ON CONFLICT(name) DO UPDATE SET
1091
+ status = excluded.status,
1092
+ tags_input = excluded.tags_input,
1093
+ metadata_input = excluded.metadata_input,
1094
+ updated_at = excluded.updated_at
1095
+ `)
1096
+ .run(id, f.name, f.status, f.tagsInput, f.metadataInput, createdAt, updatedAt);
1097
+ return {
1098
+ id,
1099
+ name: f.name,
1100
+ status: f.status,
1101
+ tagsInput: f.tagsInput,
1102
+ metadataInput: f.metadataInput,
1103
+ createdAt,
1104
+ updatedAt,
1105
+ };
1106
+ })(filter);
1107
+ return persisted;
1108
+ }
1109
+ listSavedFilters() {
1110
+ const rows = this.db
1111
+ .prepare("SELECT * FROM trace_saved_filters ORDER BY updated_at DESC")
1112
+ .all();
1113
+ return rows.map((r) => rowToSavedFilter(r));
1114
+ }
1115
+ deleteSavedFilter(name) {
1116
+ const result = this.db.prepare("DELETE FROM trace_saved_filters WHERE name = ?").run(name);
1117
+ return result.changes > 0;
1118
+ }
1119
+ // === Sample-body recording (option C follow-up to #100) ===
1120
+ recordWorkflowSample(sample) {
1121
+ // First-record-wins. INSERT OR IGNORE skips the row when the
1122
+ // PRIMARY KEY (workflow_name) already exists; we then SELECT to
1123
+ // return whatever's actually persisted. Keeps the operator-
1124
+ // visible sample stable across re-runs.
1125
+ const insert = this.db.prepare("INSERT OR IGNORE INTO trace_workflow_samples (workflow_name, body_json, source_run_id, recorded_at) VALUES (?, ?, ?, ?)");
1126
+ insert.run(sample.workflowName, JSON.stringify(sample.body), sample.sourceRunId, sample.recordedAt);
1127
+ const existing = this.getWorkflowSample(sample.workflowName);
1128
+ return existing ?? sample;
1129
+ }
1130
+ getWorkflowSample(workflowName) {
1131
+ const row = this.db.prepare("SELECT * FROM trace_workflow_samples WHERE workflow_name = ?").get(workflowName);
1132
+ if (!row)
1133
+ return undefined;
1134
+ let body;
1135
+ try {
1136
+ body = JSON.parse(row.body_json);
1137
+ }
1138
+ catch {
1139
+ body = null;
1140
+ }
1141
+ return {
1142
+ workflowName: row.workflow_name,
1143
+ body,
1144
+ sourceRunId: row.source_run_id,
1145
+ recordedAt: row.recorded_at,
1146
+ };
1147
+ }
1148
+ deleteWorkflowSample(workflowName) {
1149
+ const result = this.db.prepare("DELETE FROM trace_workflow_samples WHERE workflow_name = ?").run(workflowName);
1150
+ return result.changes > 0;
1151
+ }
848
1152
  // === Cleanup ===
849
1153
  clearAll() {
850
1154
  const count = this.db.prepare("SELECT COUNT(*) as c FROM workflow_runs").get()?.c ?? 0;
@@ -945,6 +1249,9 @@ export class SqliteRunStore {
945
1249
  }
946
1250
  // === Durable scheduling (Tier 2 #5+#7 follow-up) ===
947
1251
  upsertScheduledDispatch(row) {
1252
+ // On CONFLICT we deliberately PRESERVE claimed_by + claimed_at —
1253
+ // debounce reset / queue re-defer updates the scheduling fields
1254
+ // but ownership stays with whoever currently holds the claim.
948
1255
  this.stmt("upsertScheduledDispatch", `INSERT INTO scheduled_dispatches
949
1256
  (run_id, workflow_name, trigger_type, scheduled_at, expires_at, dispatch_status, payload_json, created_at)
950
1257
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -974,25 +1281,57 @@ export class SqliteRunStore {
974
1281
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
975
1282
  const sql = `SELECT * FROM scheduled_dispatches ${where} ORDER BY scheduled_at ASC`;
976
1283
  const rows = this.db.prepare(sql).all(...args);
977
- return rows.map((r) => {
978
- let parsedPayload = null;
979
- try {
980
- parsedPayload = JSON.parse(r.payload_json);
981
- }
982
- catch {
983
- parsedPayload = null;
984
- }
985
- return {
986
- runId: r.run_id,
987
- workflowName: r.workflow_name,
988
- triggerType: r.trigger_type,
989
- scheduledAt: r.scheduled_at,
990
- expiresAt: r.expires_at ?? undefined,
991
- dispatchStatus: r.dispatch_status,
992
- payload: parsedPayload,
993
- createdAt: r.created_at,
994
- };
995
- });
1284
+ return rows.map((r) => this.rowToScheduledDispatch(r));
1285
+ }
1286
+ // === Tier C #2 — cross-process scheduler coordination ===
1287
+ claimDispatches(processId, leaseMs, now, opts) {
1288
+ // Atomic claim: UPDATE rows that are unclaimed OR whose lease has
1289
+ // expired, set the claim, return the affected rows.
1290
+ // SQLite supports `UPDATE ... RETURNING *` since v3.35.0 (2021);
1291
+ // better-sqlite3 ships a modern sqlite so this is safe.
1292
+ const triggerClause = opts?.triggerType ? "AND trigger_type = ?" : "";
1293
+ const sql = `
1294
+ UPDATE scheduled_dispatches
1295
+ SET claimed_by = ?, claimed_at = ?
1296
+ WHERE (claimed_by IS NULL OR claimed_at + ? < ?) ${triggerClause}
1297
+ RETURNING *
1298
+ `;
1299
+ const args = [processId, now, leaseMs, now];
1300
+ if (opts?.triggerType)
1301
+ args.push(opts.triggerType);
1302
+ const claimed = this.db.prepare(sql).all(...args);
1303
+ const out = claimed.map((r) => this.rowToScheduledDispatch(r));
1304
+ out.sort((a, b) => a.scheduledAt - b.scheduledAt);
1305
+ return out;
1306
+ }
1307
+ heartbeatClaims(processId, now) {
1308
+ const result = this.stmt("heartbeatClaims", "UPDATE scheduled_dispatches SET claimed_at = ? WHERE claimed_by = ?").run(now, processId);
1309
+ return result.changes;
1310
+ }
1311
+ releaseClaim(runId) {
1312
+ const result = this.stmt("releaseClaim", "UPDATE scheduled_dispatches SET claimed_by = NULL, claimed_at = NULL WHERE run_id = ? AND claimed_by IS NOT NULL").run(runId);
1313
+ return result.changes > 0;
1314
+ }
1315
+ rowToScheduledDispatch(r) {
1316
+ let parsedPayload = null;
1317
+ try {
1318
+ parsedPayload = JSON.parse(r.payload_json);
1319
+ }
1320
+ catch {
1321
+ parsedPayload = null;
1322
+ }
1323
+ return {
1324
+ runId: r.run_id,
1325
+ workflowName: r.workflow_name,
1326
+ triggerType: r.trigger_type,
1327
+ scheduledAt: r.scheduled_at,
1328
+ expiresAt: r.expires_at ?? undefined,
1329
+ dispatchStatus: r.dispatch_status,
1330
+ payload: parsedPayload,
1331
+ createdAt: r.created_at,
1332
+ claimedBy: r.claimed_by ?? undefined,
1333
+ claimedAt: r.claimed_at ?? undefined,
1334
+ };
996
1335
  }
997
1336
  purgeExpiredScheduledDispatches(now) {
998
1337
  const result = this.stmt("purgeExpiredScheduledDispatches", "DELETE FROM scheduled_dispatches WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
@@ -1055,9 +1394,11 @@ export class SqliteRunStore {
1055
1394
  debounceMode: (row.debounce_mode ?? undefined),
1056
1395
  pingCount: row.ping_count ?? undefined,
1057
1396
  lastCompletedStepIndex: row.last_completed_step_index ?? undefined,
1397
+ stateSnapshot: row.state_snapshot ?? undefined,
1058
1398
  };
1059
1399
  }
1060
1400
  rowToNodeRun(row) {
1401
+ const flags = decodeNodeRunFlags(row.flags_json);
1061
1402
  return {
1062
1403
  id: row.id,
1063
1404
  runId: row.run_id,
@@ -1077,6 +1418,14 @@ export class SqliteRunStore {
1077
1418
  metrics: row.metrics_json ? JSON.parse(row.metrics_json) : undefined,
1078
1419
  cached: row.cached_json ? JSON.parse(row.cached_json) : undefined,
1079
1420
  attempts: row.attempts_json ? JSON.parse(row.attempts_json) : undefined,
1421
+ iterationContext: row.iteration_context
1422
+ ? JSON.parse(row.iteration_context)
1423
+ : undefined,
1424
+ wait: flags.wait,
1425
+ dispatch: flags.dispatch,
1426
+ subworkflowDepth: flags.subworkflowDepth,
1427
+ middleware: flags.middleware,
1428
+ iterationIndex: flags.iterationIndex,
1080
1429
  };
1081
1430
  }
1082
1431
  rowToEvent(row) {
@@ -1115,4 +1464,15 @@ export class SqliteRunStore {
1115
1464
  };
1116
1465
  }
1117
1466
  }
1467
+ function rowToSavedFilter(row) {
1468
+ return {
1469
+ id: row.id,
1470
+ name: row.name,
1471
+ status: row.status,
1472
+ tagsInput: row.tags_input,
1473
+ metadataInput: row.metadata_input,
1474
+ createdAt: row.created_at,
1475
+ updatedAt: row.updated_at,
1476
+ };
1477
+ }
1118
1478
  //# sourceMappingURL=SqliteRunStore.js.map