@jungjaehoon/mama-os 0.24.1 → 0.25.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 (113) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -6
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +8 -0
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/code-act/constants.js +2 -2
  7. package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
  8. package/dist/agent/code-act/host-bridge.js +37 -4
  9. package/dist/agent/code-act/host-bridge.js.map +1 -1
  10. package/dist/agent/code-act/type-definition-generator.d.ts.map +1 -1
  11. package/dist/agent/code-act/type-definition-generator.js +2 -4
  12. package/dist/agent/code-act/type-definition-generator.js.map +1 -1
  13. package/dist/agent/context-compile-service.d.ts +2 -0
  14. package/dist/agent/context-compile-service.d.ts.map +1 -1
  15. package/dist/agent/context-compile-service.js +26 -7
  16. package/dist/agent/context-compile-service.js.map +1 -1
  17. package/dist/agent/gateway-tool-executor.d.ts +2 -0
  18. package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
  19. package/dist/agent/gateway-tool-executor.js +312 -18
  20. package/dist/agent/gateway-tool-executor.js.map +1 -1
  21. package/dist/agent/gateway-tools.md +4 -3
  22. package/dist/agent/tool-registry.d.ts.map +1 -1
  23. package/dist/agent/tool-registry.js +9 -3
  24. package/dist/agent/tool-registry.js.map +1 -1
  25. package/dist/agent/types.d.ts +27 -6
  26. package/dist/agent/types.d.ts.map +1 -1
  27. package/dist/agent/types.js.map +1 -1
  28. package/dist/api/operator-tasks-handler.d.ts.map +1 -1
  29. package/dist/api/operator-tasks-handler.js +23 -1
  30. package/dist/api/operator-tasks-handler.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts +7 -3
  32. package/dist/cli/commands/start.d.ts.map +1 -1
  33. package/dist/cli/commands/start.js +113 -65
  34. package/dist/cli/commands/start.js.map +1 -1
  35. package/dist/cli/runtime/agent-loop-init.d.ts +2 -0
  36. package/dist/cli/runtime/agent-loop-init.d.ts.map +1 -1
  37. package/dist/cli/runtime/agent-loop-init.js +37 -12
  38. package/dist/cli/runtime/agent-loop-init.js.map +1 -1
  39. package/dist/cli/runtime/api-routes-init.js +3 -3
  40. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  41. package/dist/cli/runtime/temporal-init.d.ts +28 -0
  42. package/dist/cli/runtime/temporal-init.d.ts.map +1 -0
  43. package/dist/cli/runtime/temporal-init.js +74 -0
  44. package/dist/cli/runtime/temporal-init.js.map +1 -0
  45. package/dist/db/migrations/operator-task-temporal.d.ts +8 -0
  46. package/dist/db/migrations/operator-task-temporal.d.ts.map +1 -0
  47. package/dist/db/migrations/operator-task-temporal.js +209 -0
  48. package/dist/db/migrations/operator-task-temporal.js.map +1 -0
  49. package/dist/envelope/enforcer.d.ts.map +1 -1
  50. package/dist/envelope/enforcer.js +6 -6
  51. package/dist/envelope/enforcer.js.map +1 -1
  52. package/dist/multi-agent/dashboard-agent-persona.d.ts.map +1 -1
  53. package/dist/multi-agent/dashboard-agent-persona.js +9 -4
  54. package/dist/multi-agent/dashboard-agent-persona.js.map +1 -1
  55. package/dist/operator/action-verifier.d.ts +23 -0
  56. package/dist/operator/action-verifier.d.ts.map +1 -1
  57. package/dist/operator/action-verifier.js +76 -0
  58. package/dist/operator/action-verifier.js.map +1 -1
  59. package/dist/operator/board-reconcile.d.ts.map +1 -1
  60. package/dist/operator/board-reconcile.js +3 -0
  61. package/dist/operator/board-reconcile.js.map +1 -1
  62. package/dist/operator/board-slot-instructions.d.ts +3 -3
  63. package/dist/operator/board-slot-instructions.d.ts.map +1 -1
  64. package/dist/operator/board-slot-instructions.js +17 -8
  65. package/dist/operator/board-slot-instructions.js.map +1 -1
  66. package/dist/operator/briefs.d.ts.map +1 -1
  67. package/dist/operator/briefs.js +3 -0
  68. package/dist/operator/briefs.js.map +1 -1
  69. package/dist/operator/task-ledger.d.ts +134 -8
  70. package/dist/operator/task-ledger.d.ts.map +1 -1
  71. package/dist/operator/task-ledger.js +1181 -74
  72. package/dist/operator/task-ledger.js.map +1 -1
  73. package/dist/operator/task-temporal.d.ts +21 -0
  74. package/dist/operator/task-temporal.d.ts.map +1 -0
  75. package/dist/operator/task-temporal.js +111 -0
  76. package/dist/operator/task-temporal.js.map +1 -0
  77. package/dist/operator/temporal-effect.d.ts +62 -0
  78. package/dist/operator/temporal-effect.d.ts.map +1 -0
  79. package/dist/operator/temporal-effect.js +71 -0
  80. package/dist/operator/temporal-effect.js.map +1 -0
  81. package/dist/operator/temporal-reconcile.d.ts +48 -0
  82. package/dist/operator/temporal-reconcile.d.ts.map +1 -0
  83. package/dist/operator/temporal-reconcile.js +215 -0
  84. package/dist/operator/temporal-reconcile.js.map +1 -0
  85. package/dist/operator/temporal-runtime.d.ts +54 -0
  86. package/dist/operator/temporal-runtime.d.ts.map +1 -0
  87. package/dist/operator/temporal-runtime.js +110 -0
  88. package/dist/operator/temporal-runtime.js.map +1 -0
  89. package/dist/operator/temporal-worker.d.ts +19 -0
  90. package/dist/operator/temporal-worker.d.ts.map +1 -0
  91. package/dist/operator/temporal-worker.js +105 -0
  92. package/dist/operator/temporal-worker.js.map +1 -0
  93. package/dist/operator/worker-run.d.ts +6 -2
  94. package/dist/operator/worker-run.d.ts.map +1 -1
  95. package/dist/operator/worker-run.js +14 -0
  96. package/dist/operator/worker-run.js.map +1 -1
  97. package/dist/operator/workorder-consumer.d.ts +27 -10
  98. package/dist/operator/workorder-consumer.d.ts.map +1 -1
  99. package/dist/operator/workorder-consumer.js +195 -14
  100. package/dist/operator/workorder-consumer.js.map +1 -1
  101. package/dist/operator/workorder-hooks.d.ts +3 -0
  102. package/dist/operator/workorder-hooks.d.ts.map +1 -1
  103. package/dist/operator/workorder-hooks.js +23 -0
  104. package/dist/operator/workorder-hooks.js.map +1 -1
  105. package/dist/operator/workorder-publishers.d.ts +10 -1
  106. package/dist/operator/workorder-publishers.d.ts.map +1 -1
  107. package/dist/operator/workorder-publishers.js +40 -3
  108. package/dist/operator/workorder-publishers.js.map +1 -1
  109. package/package.json +1 -1
  110. package/public/ui/assets/{index-OSgiwL24.js → index-BDXDGEV6.js} +15 -15
  111. package/public/ui/assets/index-po2tN0xq.css +1 -0
  112. package/public/ui/index.html +2 -2
  113. package/public/ui/assets/index-CKOJQYXA.css +0 -1
@@ -2,15 +2,15 @@
2
2
  /**
3
3
  * TaskLedger - the operator-owned native work-item ledger (M8 Task 0.1).
4
4
  *
5
- * Ports the SHAPE of Kagemusha's proven task store (11 columns: id/title/status/
6
- * priority/deadline/source/auto_created/confirmed/timestamps) plus `assignee`
7
- * and an idempotency key. Implements the pre-existing `TaskSource` interface
8
- * (operator-interfaces.ts) so the board projects ONE task model, not two.
5
+ * Extends the shape of Kagemusha's proven task store with assignment,
6
+ * idempotency, temporal scheduling, durable generation, effect receipt, and
7
+ * workorder state. Implements the pre-existing `TaskSource` interface
8
+ * (operator-interfaces.ts) so the board projects one task model, not two.
9
9
  *
10
10
  * Reconcile runs create/update rows through the task_create/task_update gateway
11
11
  * tools; the pipeline board slot is a projection of `list({order:
12
- * 'deadline_priority'})`. The AGENT makes every judgment about what becomes a
13
- * task; this store only persists.
12
+ * 'deadline_priority'})`. The agent proposes task changes, while this ledger
13
+ * enforces revisions, temporal ownership, idempotency, and atomic receipts.
14
14
  *
15
15
  * Schema-extension note: CREATE TABLE IF NOT EXISTS is a no-op on existing
16
16
  * tables. Any post-ship column addition needs an explicit ALTER TABLE guarded
@@ -20,8 +20,11 @@
20
20
  * (start.ts opens and closes it once) - deliberately no close() here.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.TaskLedger = exports.WORKORDER_CHANNEL_PREFIX = exports.WORKORDER_KINDS = exports.TASK_KINDS = exports.TASK_PRIORITIES = exports.TASK_STATUSES = void 0;
23
+ exports.TaskLedger = exports.WORKORDER_CHANNEL_PREFIX = exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS = exports.WORKORDER_KINDS = exports.TASK_KINDS = exports.TASK_PRIORITIES = exports.TASK_STATUSES = void 0;
24
24
  const node_crypto_1 = require("node:crypto");
25
+ const operator_task_temporal_js_1 = require("../db/migrations/operator-task-temporal.js");
26
+ const temporal_effect_js_1 = require("./temporal-effect.js");
27
+ const task_temporal_js_1 = require("./task-temporal.js");
25
28
  exports.TASK_STATUSES = [
26
29
  'pending',
27
30
  'in_progress',
@@ -35,9 +38,12 @@ exports.TASK_STATUSES = [
35
38
  ];
36
39
  exports.TASK_PRIORITIES = ['high', 'normal', 'low'];
37
40
  exports.TASK_KINDS = ['owner', 'system'];
38
- exports.WORKORDER_KINDS = ['board', 'wiki', 'memory-curation'];
41
+ exports.WORKORDER_KINDS = ['board', 'wiki', 'memory-curation', 'temporal'];
42
+ exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS = 3;
39
43
  /** source_channel namespace for workorder rows: 'workorder:<workKind>'. */
40
44
  exports.WORKORDER_CHANNEL_PREFIX = 'workorder:';
45
+ const KNOWN_WORKORDER_CHANNELS = exports.WORKORDER_KINDS.map((workKind) => `${exports.WORKORDER_CHANNEL_PREFIX}${workKind}`);
46
+ const KNOWN_WORKORDER_CHANNELS_SQL = KNOWN_WORKORDER_CHANNELS.map(() => '?').join(',');
41
47
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
42
48
  function isoToEpochMs(iso) {
43
49
  if (!iso)
@@ -62,7 +68,7 @@ function assertIsoDate(value, field) {
62
68
  throw new Error(`${field} must be an ISO date (YYYY-MM-DD), got: ${value}`);
63
69
  }
64
70
  }
65
- function rowToRecord(row) {
71
+ function rowToRecord(row, now, timeZone) {
66
72
  return {
67
73
  id: row.id,
68
74
  title: row.title,
@@ -77,20 +83,44 @@ function rowToRecord(row) {
77
83
  latestEvent: row.latest_event,
78
84
  autoCreated: row.auto_created === 1,
79
85
  confirmed: row.confirmed === 1,
86
+ dueAt: row.due_at,
87
+ deadlineOffsetMinutes: row.deadline_offset_minutes,
88
+ revision: row.revision,
89
+ temporalEpoch: row.temporal_epoch,
90
+ temporalReconciledOccurrenceKey: row.temporal_reconciled_occurrence_key,
91
+ lastTemporalCheckedAt: row.last_temporal_checked_at,
92
+ nextTemporalCheckAt: row.next_temporal_check_at,
93
+ lastTemporalAttemptId: row.last_temporal_attempt_id,
94
+ temporalState: (0, task_temporal_js_1.deriveTemporalState)({
95
+ status: row.status,
96
+ dueAt: row.due_at,
97
+ deadlineIso: row.deadline,
98
+ deadlineOffsetMinutes: row.deadline_offset_minutes,
99
+ }, now, timeZone),
80
100
  createdAt: row.created_at,
81
101
  updatedAt: row.updated_at,
82
102
  };
83
103
  }
84
104
  class TaskLedger {
85
105
  db;
86
- constructor(db) {
106
+ now;
107
+ timeZone;
108
+ constructor(db, options = {}) {
87
109
  this.db = db;
110
+ this.now = options.now ?? Date.now;
111
+ this.timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
112
+ // Validate the injected or boot-resolved zone once, before reads depend on it.
113
+ new Intl.DateTimeFormat('en-US', { timeZone: this.timeZone }).format(0);
88
114
  this.runMigration();
89
115
  }
116
+ toRecord(row) {
117
+ return rowToRecord(row, this.now(), this.timeZone);
118
+ }
90
119
  runMigration() {
91
120
  // Both construction sites (start.ts boot + operator-handler lazy) run this;
92
121
  // busy_timeout here covers BOTH connections against the rebuild race.
93
122
  this.db.pragma('busy_timeout = 5000');
123
+ this.db.pragma('foreign_keys = ON');
94
124
  this.db.exec(`
95
125
  CREATE TABLE IF NOT EXISTS operator_tasks (${TaskLedger.TABLE_COLUMNS_SQL});
96
126
  ${TaskLedger.INDEXES_SQL}
@@ -105,6 +135,10 @@ class TaskLedger {
105
135
  ON operator_no_update_notes(scope, id);
106
136
  `);
107
137
  this.upgradeSchema();
138
+ const foreignKeyViolations = this.db.pragma('foreign_key_check');
139
+ if (foreignKeyViolations.length > 0) {
140
+ throw new Error(`operator task migration found ${foreignKeyViolations.length} foreign key violation(s)`);
141
+ }
108
142
  }
109
143
  /** Full current column set - single source for CREATE and the rebuild copy. */
110
144
  static TABLE_COLUMNS_SQL = `
@@ -122,6 +156,15 @@ class TaskLedger {
122
156
  latest_event TEXT,
123
157
  auto_created INTEGER NOT NULL DEFAULT 1,
124
158
  confirmed INTEGER NOT NULL DEFAULT 0,
159
+ due_at INTEGER,
160
+ deadline_offset_minutes INTEGER
161
+ CHECK (deadline_offset_minutes BETWEEN -840 AND 840),
162
+ revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
163
+ temporal_epoch INTEGER NOT NULL DEFAULT 0 CHECK (temporal_epoch >= 0),
164
+ temporal_reconciled_occurrence_key TEXT,
165
+ last_temporal_checked_at INTEGER,
166
+ next_temporal_check_at INTEGER,
167
+ last_temporal_attempt_id INTEGER,
125
168
  created_at INTEGER NOT NULL,
126
169
  updated_at INTEGER NOT NULL`;
127
170
  // Unique-index predicate excludes terminal rows so a terminal keyed workorder
@@ -185,6 +228,7 @@ class TaskLedger {
185
228
  }
186
229
  }
187
230
  }
231
+ (0, operator_task_temporal_js_1.applyOperatorTaskTemporalMigration)(this.db);
188
232
  // Old-predicate unique index (no terminal exclusion) -> swap in place.
189
233
  const idxRow = this.db
190
234
  .prepare(`SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_operator_tasks_source'`)
@@ -247,7 +291,26 @@ class TaskLedger {
247
291
  ORDER BY ${order}
248
292
  LIMIT ?`)
249
293
  .all(...params, limit);
250
- return rows.map(rowToRecord);
294
+ return rows.map((row) => this.toRecord(row));
295
+ }
296
+ /** Internal bounded page for temporal reconciliation; excludes rows that can never be candidates. */
297
+ listTemporalScanPage(input) {
298
+ const rawLimit = Number(input.limit);
299
+ const rawAfterId = Number(input.afterId);
300
+ const limit = Number.isFinite(rawLimit)
301
+ ? Math.max(1, Math.min(200, Math.floor(rawLimit)))
302
+ : 200;
303
+ const afterId = Number.isFinite(rawAfterId) ? Math.max(0, Math.floor(rawAfterId)) : 0;
304
+ const rows = this.db
305
+ .prepare(`SELECT * FROM operator_tasks INDEXED BY idx_operator_tasks_temporal_scan_id
306
+ WHERE kind = 'owner'
307
+ AND status IN ('pending','in_progress','review','blocked')
308
+ AND id > ?
309
+ AND (due_at IS NOT NULL OR deadline IS NOT NULL OR next_temporal_check_at IS NOT NULL)
310
+ ORDER BY id ASC
311
+ LIMIT ?`)
312
+ .all(afterId, limit);
313
+ return rows.map((row) => this.toRecord(row));
251
314
  }
252
315
  /** Owner rows only - system workorder rows are invisible to external reads. */
253
316
  getById(id) {
@@ -257,7 +320,7 @@ class TaskLedger {
257
320
  /** Internal fetch without the kind filter (guards and workorder paths). */
258
321
  getRowById(id) {
259
322
  const row = this.db.prepare(`SELECT * FROM operator_tasks WHERE id = ?`).get(id);
260
- return row ? rowToRecord(row) : null;
323
+ return row ? this.toRecord(row) : null;
261
324
  }
262
325
  /**
263
326
  * Create a task. Idempotent under at-least-once delivery: a duplicate
@@ -284,7 +347,13 @@ class TaskLedger {
284
347
  assertEnum(input.priority, exports.TASK_PRIORITIES, 'priority');
285
348
  if (input.deadline !== undefined)
286
349
  assertIsoDate(input.deadline, 'deadline');
287
- const now = Date.now();
350
+ const exactDue = input.due_at !== undefined ? (0, task_temporal_js_1.parseExactDueAt)(input.due_at) : null;
351
+ if (exactDue && input.deadline !== undefined && input.deadline !== exactDue.deadline) {
352
+ throw new Error('task_create: due_at and deadline conflict');
353
+ }
354
+ const normalizedDeadline = exactDue?.deadline ?? input.deadline ?? null;
355
+ const initialTemporalEpoch = normalizedDeadline !== null ? 1 : 0;
356
+ const now = this.now();
288
357
  if (input.source_channel && input.source_event_id) {
289
358
  // kind='owner' probe: an agent-supplied (channel, event) pair can never
290
359
  // reach a system workorder row (Stage-2 tamper guard).
@@ -293,13 +362,15 @@ class TaskLedger {
293
362
  WHERE source_channel = ? AND source_event_id = ? AND kind = 'owner'`)
294
363
  .get(input.source_channel, input.source_event_id);
295
364
  if (existing) {
296
- // Upsert carries every provided field EXCEPT title (the original naming
297
- // stays stable across retries; movement and state updates flow through).
365
+ // Duplicate source delivery uses the same mutation boundary, including
366
+ // exact-time normalization and no-op detection. The original title stays
367
+ // stable across retries for backward compatibility.
298
368
  return this.update(existing.id, {
299
369
  ...(input.status !== undefined ? { status: input.status } : {}),
300
370
  ...(input.priority !== undefined ? { priority: input.priority } : {}),
301
371
  ...(input.assignee !== undefined ? { assignee: input.assignee } : {}),
302
372
  ...(input.deadline !== undefined ? { deadline: input.deadline } : {}),
373
+ ...(input.due_at !== undefined ? { due_at: input.due_at } : {}),
303
374
  ...(input.confirmed !== undefined ? { confirmed: input.confirmed } : {}),
304
375
  ...(input.latest_event !== undefined ? { latest_event: input.latest_event } : {}),
305
376
  });
@@ -307,26 +378,17 @@ class TaskLedger {
307
378
  }
308
379
  const result = this.db
309
380
  .prepare(`INSERT INTO operator_tasks
310
- (title, status, priority, assignee, deadline, source_channel, source_event_id,
311
- latest_event, auto_created, confirmed, created_at, updated_at)
312
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
313
- .run(input.title.trim(), input.status ?? 'pending', input.priority ?? 'normal', input.assignee ?? null, input.deadline ?? null, input.source_channel ?? null, input.source_event_id ?? null, input.latest_event ?? null, 1, input.confirmed ? 1 : 0, now, now);
381
+ (title, status, priority, assignee, deadline, due_at, deadline_offset_minutes,
382
+ revision, temporal_epoch, source_channel, source_event_id, latest_event,
383
+ auto_created, confirmed, created_at, updated_at)
384
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)`)
385
+ .run(input.title.trim(), input.status ?? 'pending', input.priority ?? 'normal', input.assignee ?? null, normalizedDeadline, exactDue?.dueAt ?? null, exactDue?.offsetMinutes ?? null, initialTemporalEpoch, input.source_channel ?? null, input.source_event_id ?? null, input.latest_event ?? null, 1, input.confirmed ? 1 : 0, now, now);
314
386
  const created = this.getById(Number(result.lastInsertRowid));
315
387
  if (!created)
316
388
  throw new Error('task_create: inserted row could not be read back');
317
389
  return created;
318
390
  }
319
391
  update(id, patch) {
320
- const existing = this.getRowById(id);
321
- if (!existing)
322
- throw new Error(`task_update: no task with id ${id}`);
323
- // Stage-2 tamper guards: system workorder rows are host-managed (their
324
- // transitions go through the workorder API only), and 'failed' can never
325
- // be set on an owner task from any external surface (REST PATCH + gateway
326
- // task_update both land here).
327
- if (existing.kind === 'system') {
328
- throw new Error(`task_update: task ${id} is a system workorder row (host-managed)`);
329
- }
330
392
  if (patch.status === 'failed') {
331
393
  throw new Error(`task_update: 'failed' is a system-only status`);
332
394
  }
@@ -340,29 +402,154 @@ class TaskLedger {
340
402
  if (patch.title !== undefined && patch.title.trim() === '') {
341
403
  throw new Error('task title must be a non-empty string');
342
404
  }
343
- const sets = ['updated_at = ?'];
344
- const params = [Date.now()];
345
- const assign = (column, value) => {
346
- sets.push(`${column} = ?`);
347
- params.push(value);
348
- };
349
- if (patch.title !== undefined)
350
- assign('title', patch.title.trim());
351
- if (patch.status !== undefined)
352
- assign('status', patch.status);
353
- if (patch.priority !== undefined)
354
- assign('priority', patch.priority);
355
- if (patch.assignee !== undefined)
356
- assign('assignee', patch.assignee);
357
- if (patch.deadline !== undefined)
358
- assign('deadline', patch.deadline);
359
- if (patch.latest_event !== undefined)
360
- assign('latest_event', patch.latest_event);
361
- if (patch.confirmed !== undefined)
362
- assign('confirmed', patch.confirmed ? 1 : 0);
363
- this.db.prepare(`UPDATE operator_tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
405
+ this.db.exec('BEGIN IMMEDIATE');
406
+ try {
407
+ const existing = this.db.prepare('SELECT * FROM operator_tasks WHERE id = ?').get(id);
408
+ if (!existing)
409
+ throw new Error(`task_update: no task with id ${id}`);
410
+ if (existing.kind === 'system') {
411
+ throw new Error(`task_update: task ${id} is a system workorder row (host-managed)`);
412
+ }
413
+ const next = {
414
+ title: existing.title,
415
+ status: existing.status,
416
+ priority: existing.priority,
417
+ assignee: existing.assignee,
418
+ deadline: existing.deadline,
419
+ due_at: existing.due_at,
420
+ deadline_offset_minutes: existing.deadline_offset_minutes,
421
+ latest_event: existing.latest_event,
422
+ confirmed: existing.confirmed,
423
+ temporal_epoch: existing.temporal_epoch,
424
+ temporal_reconciled_occurrence_key: existing.temporal_reconciled_occurrence_key,
425
+ last_temporal_checked_at: existing.last_temporal_checked_at,
426
+ next_temporal_check_at: existing.next_temporal_check_at,
427
+ last_temporal_attempt_id: existing.last_temporal_attempt_id,
428
+ };
429
+ if (patch.title !== undefined)
430
+ next.title = patch.title.trim();
431
+ if (patch.status !== undefined)
432
+ next.status = patch.status;
433
+ if (patch.priority !== undefined)
434
+ next.priority = patch.priority;
435
+ if (patch.assignee !== undefined)
436
+ next.assignee = patch.assignee;
437
+ if (patch.latest_event !== undefined)
438
+ next.latest_event = patch.latest_event;
439
+ if (patch.confirmed !== undefined)
440
+ next.confirmed = patch.confirmed ? 1 : 0;
441
+ const hasDueAt = Object.prototype.hasOwnProperty.call(patch, 'due_at');
442
+ const hasDeadline = Object.prototype.hasOwnProperty.call(patch, 'deadline');
443
+ if (hasDueAt && patch.due_at !== null && patch.due_at !== undefined) {
444
+ const exactDue = (0, task_temporal_js_1.parseExactDueAt)(patch.due_at);
445
+ if (hasDeadline && patch.deadline !== exactDue.deadline) {
446
+ throw new Error('task_update: due_at and deadline conflict');
447
+ }
448
+ next.due_at = exactDue.dueAt;
449
+ next.deadline = exactDue.deadline;
450
+ next.deadline_offset_minutes = exactDue.offsetMinutes;
451
+ }
452
+ else if (hasDeadline) {
453
+ next.deadline = patch.deadline ?? null;
454
+ next.due_at = null;
455
+ if (patch.deadline === null) {
456
+ next.deadline_offset_minutes = null;
457
+ }
458
+ }
459
+ else if (hasDueAt) {
460
+ next.due_at = null;
461
+ }
462
+ const temporalChanged = next.due_at !== existing.due_at || next.deadline !== existing.deadline;
463
+ const terminalToOpen = (existing.status === 'done' || existing.status === 'cancelled') &&
464
+ next.status !== 'done' &&
465
+ next.status !== 'cancelled';
466
+ const openToTerminal = existing.status !== 'done' &&
467
+ existing.status !== 'cancelled' &&
468
+ (next.status === 'done' || next.status === 'cancelled');
469
+ if (temporalChanged || terminalToOpen) {
470
+ next.temporal_epoch = existing.temporal_epoch + 1;
471
+ next.temporal_reconciled_occurrence_key = null;
472
+ next.last_temporal_checked_at = null;
473
+ next.next_temporal_check_at = null;
474
+ next.last_temporal_attempt_id = null;
475
+ }
476
+ const persistedColumns = [
477
+ 'title',
478
+ 'status',
479
+ 'priority',
480
+ 'assignee',
481
+ 'deadline',
482
+ 'due_at',
483
+ 'deadline_offset_minutes',
484
+ 'latest_event',
485
+ 'confirmed',
486
+ 'temporal_epoch',
487
+ 'temporal_reconciled_occurrence_key',
488
+ 'last_temporal_checked_at',
489
+ 'next_temporal_check_at',
490
+ 'last_temporal_attempt_id',
491
+ ];
492
+ const changedColumns = persistedColumns.filter((column) => next[column] !== existing[column]);
493
+ if (changedColumns.length === 0) {
494
+ this.db.exec('COMMIT');
495
+ return this.toRecord(existing);
496
+ }
497
+ const nextRevision = existing.revision + 1;
498
+ const sets = changedColumns.map((column) => `${column} = ?`);
499
+ const values = changedColumns.map((column) => next[column]);
500
+ sets.push('revision = ?', 'updated_at = ?');
501
+ values.push(nextRevision, this.now());
502
+ this.db
503
+ .prepare(`UPDATE operator_tasks SET ${sets.join(', ')} WHERE id = ?`)
504
+ .run(...values, id);
505
+ if (temporalChanged || terminalToOpen) {
506
+ this.supersedeTemporalGenerationsInTransaction(id, Number(next.temporal_epoch));
507
+ }
508
+ else if (openToTerminal) {
509
+ this.supersedeAllActiveTemporalGenerationsInTransaction(id);
510
+ }
511
+ this.db.exec('COMMIT');
512
+ }
513
+ catch (error) {
514
+ this.db.exec('ROLLBACK');
515
+ throw error;
516
+ }
364
517
  return this.getById(id);
365
518
  }
519
+ supersedeTemporalGenerationsInTransaction(taskId, currentEpoch, excludeGenerationKey, updatedAt = this.now()) {
520
+ const reason = 'task temporal occurrence superseded';
521
+ const exclusion = excludeGenerationKey ? ' AND generation_key != ?' : '';
522
+ const generationParams = excludeGenerationKey
523
+ ? [taskId, currentEpoch, excludeGenerationKey]
524
+ : [taskId, currentEpoch];
525
+ this.db
526
+ .prepare(`UPDATE operator_tasks SET status = 'cancelled', latest_event = ?, updated_at = ?
527
+ WHERE kind = 'system' AND status IN ('pending','in_progress') AND id IN (
528
+ SELECT last_workorder_id FROM operator_temporal_generations
529
+ WHERE task_id = ? AND disposition = 'active' AND temporal_epoch < ?${exclusion}
530
+ )`)
531
+ .run(reason, updatedAt, ...generationParams);
532
+ this.db
533
+ .prepare(`UPDATE operator_temporal_generations
534
+ SET disposition = 'superseded', reason = ?, updated_at = ?
535
+ WHERE task_id = ? AND disposition = 'active' AND temporal_epoch < ?${exclusion}`)
536
+ .run(reason, updatedAt, ...generationParams);
537
+ }
538
+ supersedeAllActiveTemporalGenerationsInTransaction(taskId, updatedAt = this.now()) {
539
+ const reason = 'owner task closed';
540
+ this.db
541
+ .prepare(`UPDATE operator_tasks SET status = 'cancelled', latest_event = ?, updated_at = ?
542
+ WHERE kind = 'system' AND status IN ('pending','in_progress') AND id IN (
543
+ SELECT last_workorder_id FROM operator_temporal_generations
544
+ WHERE task_id = ? AND disposition = 'active'
545
+ )`)
546
+ .run(reason, updatedAt, taskId);
547
+ this.db
548
+ .prepare(`UPDATE operator_temporal_generations
549
+ SET disposition = 'superseded', reason = ?, updated_at = ?
550
+ WHERE task_id = ? AND disposition = 'active'`)
551
+ .run(reason, updatedAt, taskId);
552
+ }
366
553
  /**
367
554
  * Stable hash over ordered OWNER rows - the Phase-2 verifier's ledger
368
555
  * snapshot. kind filter keeps concurrent system enqueues from shaking the
@@ -385,6 +572,15 @@ class TaskLedger {
385
572
  * unique index excludes terminal statuses) and a fresh row is inserted.
386
573
  */
387
574
  enqueueWorkOrder(order) {
575
+ if (order.workKind === 'temporal') {
576
+ throw new Error('enqueueWorkOrder: temporal work requires enqueueTemporalGeneration');
577
+ }
578
+ if (Object.prototype.hasOwnProperty.call(order.input, 'attempts')) {
579
+ throw new Error('enqueueWorkOrder: attempts is ledger-managed and cannot be supplied');
580
+ }
581
+ return this.insertWorkOrder(order, 1);
582
+ }
583
+ insertWorkOrder(order, attempts) {
388
584
  assertEnum(order.workKind, exports.WORKORDER_KINDS, 'workKind');
389
585
  if (order.priority !== undefined) {
390
586
  assertEnum(order.priority, exports.TASK_PRIORITIES, 'priority');
@@ -401,11 +597,11 @@ class TaskLedger {
401
597
  if (open) {
402
598
  return this.rowToWorkOrder(open);
403
599
  }
404
- const attempts = typeof order.input.attempts === 'number' && order.input.attempts >= 1
405
- ? order.input.attempts
406
- : 1;
600
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
601
+ throw new Error(`insertWorkOrder: attempts must be a positive integer, got: ${attempts}`);
602
+ }
407
603
  const payload = JSON.stringify({ ...order.input, attempts });
408
- const now = Date.now();
604
+ const now = this.now();
409
605
  const result = this.db
410
606
  .prepare(`INSERT INTO operator_tasks
411
607
  (title, status, priority, kind, payload, source_channel, source_event_id,
@@ -414,6 +610,877 @@ class TaskLedger {
414
610
  .run(`workorder:${order.workKind}`, order.priority ?? 'normal', payload, channel, order.idempotencyKey, now, now);
415
611
  return this.getWorkOrderById(Number(result.lastInsertRowid));
416
612
  }
613
+ enqueueTemporalGeneration(input) {
614
+ this.validateTemporalGenerationInput(input);
615
+ const sourceChannelRef = this.temporalSourceIdentifierRef(input.sourceChannel);
616
+ const sourceEventIdRef = this.temporalSourceIdentifierRef(input.sourceEventId);
617
+ let result = null;
618
+ this.db.exec('BEGIN IMMEDIATE');
619
+ try {
620
+ const existing = this.getTemporalGeneration(input.generationKey);
621
+ if (existing) {
622
+ if (existing.taskId !== input.taskId ||
623
+ existing.temporalEpoch !== input.temporalEpoch ||
624
+ existing.occurrenceKey !== input.occurrenceKey ||
625
+ existing.checkAt !== input.checkAt) {
626
+ throw new Error(`temporal generation '${input.generationKey}' conflicts with its stored identity`);
627
+ }
628
+ if (existing.lastWorkOrderId === null) {
629
+ throw new Error(`temporal generation '${input.generationKey}' has no owning workorder attempt`);
630
+ }
631
+ const workOrder = this.getWorkOrderById(existing.lastWorkOrderId);
632
+ if (!workOrder) {
633
+ throw new Error(`temporal generation '${input.generationKey}' owns missing workorder ${existing.lastWorkOrderId}`);
634
+ }
635
+ this.assertTemporalPayloadMatches(workOrder, existing);
636
+ if (workOrder.payload.sourceChannel !== sourceChannelRef ||
637
+ workOrder.payload.sourceEventId !== sourceEventIdRef) {
638
+ throw new Error(`temporal generation '${input.generationKey}' conflicts with stored source identifiers`);
639
+ }
640
+ result = { generation: existing, workOrder, created: false };
641
+ }
642
+ else {
643
+ const task = this.getRowById(input.taskId);
644
+ if (!task || task.kind !== 'owner') {
645
+ throw new Error(`temporal generation: owner task ${input.taskId} does not exist`);
646
+ }
647
+ if (task.status === 'done' || task.status === 'cancelled') {
648
+ throw new Error(`temporal generation: task ${input.taskId} is closed`);
649
+ }
650
+ const currentOccurrence = (0, task_temporal_js_1.occurrenceKeyForTask)(task);
651
+ if (task.temporalEpoch !== input.temporalEpoch ||
652
+ currentOccurrence !== input.occurrenceKey) {
653
+ throw new Error(`temporal generation: task occurrence no longer matches enqueue input`);
654
+ }
655
+ if (this.temporalSourceIdentifierRef(task.sourceChannel) !== sourceChannelRef ||
656
+ this.temporalSourceIdentifierRef(task.sourceEventId) !== sourceEventIdRef) {
657
+ throw new Error(`temporal generation: source identifiers do not match owner task`);
658
+ }
659
+ const openCollision = this.db
660
+ .prepare(`SELECT id FROM operator_tasks
661
+ WHERE kind = 'system' AND source_channel = 'workorder:temporal'
662
+ AND source_event_id = ? AND status IN ('pending','in_progress')`)
663
+ .get(input.generationKey);
664
+ if (openCollision) {
665
+ throw new Error(`temporal generation '${input.generationKey}' has orphan open workorder ${openCollision.id}`);
666
+ }
667
+ const timestamp = this.now();
668
+ this.db
669
+ .prepare(`INSERT INTO operator_temporal_generations
670
+ (generation_key, task_id, temporal_epoch, occurrence_key, check_at,
671
+ disposition, created_at, updated_at)
672
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`)
673
+ .run(input.generationKey, input.taskId, input.temporalEpoch, input.occurrenceKey, input.checkAt, timestamp, timestamp);
674
+ const workOrder = this.insertWorkOrder({
675
+ workKind: 'temporal',
676
+ idempotencyKey: input.generationKey,
677
+ input: {
678
+ generationKey: input.generationKey,
679
+ taskId: input.taskId,
680
+ temporalEpoch: input.temporalEpoch,
681
+ occurrenceKey: input.occurrenceKey,
682
+ checkAt: input.checkAt,
683
+ sourceChannel: sourceChannelRef,
684
+ sourceEventId: sourceEventIdRef,
685
+ },
686
+ priority: input.priority ?? 'normal',
687
+ }, 1);
688
+ this.db
689
+ .prepare(`UPDATE operator_temporal_generations
690
+ SET last_workorder_id = ?, updated_at = ? WHERE generation_key = ?`)
691
+ .run(workOrder.id, timestamp, input.generationKey);
692
+ const generation = this.getTemporalGeneration(input.generationKey);
693
+ if (!generation) {
694
+ throw new Error(`temporal generation '${input.generationKey}' could not be read back`);
695
+ }
696
+ result = { generation, workOrder, created: true };
697
+ }
698
+ this.db.exec('COMMIT');
699
+ }
700
+ catch (error) {
701
+ this.db.exec('ROLLBACK');
702
+ throw error;
703
+ }
704
+ if (!result) {
705
+ throw new Error(`temporal generation '${input.generationKey}' produced no result`);
706
+ }
707
+ return result;
708
+ }
709
+ getTemporalGeneration(generationKey) {
710
+ const row = this.db
711
+ .prepare(`SELECT * FROM operator_temporal_generations WHERE generation_key = ?`)
712
+ .get(generationKey);
713
+ if (!row)
714
+ return null;
715
+ return {
716
+ generationKey: row.generation_key,
717
+ taskId: row.task_id,
718
+ temporalEpoch: row.temporal_epoch,
719
+ occurrenceKey: row.occurrence_key,
720
+ checkAt: row.check_at,
721
+ disposition: row.disposition,
722
+ lastWorkOrderId: row.last_workorder_id,
723
+ reason: row.reason,
724
+ createdAt: row.created_at,
725
+ updatedAt: row.updated_at,
726
+ };
727
+ }
728
+ loadTemporalWorkContext(attemptId) {
729
+ return this.loadTemporalWorkContextInternal(attemptId);
730
+ }
731
+ assertTemporalWorkContextActive(suppliedContext) {
732
+ const trustedContext = this.loadTemporalWorkContextInternal(suppliedContext.attemptId);
733
+ this.assertTemporalContextMatches(suppliedContext, trustedContext);
734
+ return trustedContext;
735
+ }
736
+ getTemporalEffect(attemptId) {
737
+ const row = this.db
738
+ .prepare(`SELECT * FROM operator_temporal_effects WHERE workorder_attempt_id = ?`)
739
+ .get(attemptId);
740
+ if (!row)
741
+ return null;
742
+ const changedFields = JSON.parse(row.changed_fields);
743
+ if (!Array.isArray(changedFields) ||
744
+ !changedFields.every((field) => typeof field === 'string')) {
745
+ throw new Error(`temporal effect ${attemptId} has invalid changed_fields`);
746
+ }
747
+ if (!['resolved', 'final_no_update', 'deferred'].includes(row.outcome)) {
748
+ throw new Error(`temporal effect ${attemptId} has invalid outcome '${row.outcome}'`);
749
+ }
750
+ return {
751
+ workorderAttemptId: row.workorder_attempt_id,
752
+ taskId: row.task_id,
753
+ generationKey: row.generation_key,
754
+ occurrenceKey: row.occurrence_key,
755
+ outcome: row.outcome,
756
+ beforeRevision: row.before_revision,
757
+ afterRevision: row.after_revision,
758
+ changedFields,
759
+ reason: row.reason,
760
+ attestationVersion: row.attestation_version === 1 ? 1 : 0,
761
+ contextPacketId: row.context_packet_id ?? '',
762
+ contextPacketSha256: row.context_packet_sha256 ?? '',
763
+ nextTemporalCheckAt: row.next_temporal_check_at,
764
+ createdAt: row.created_at,
765
+ };
766
+ }
767
+ /**
768
+ * Read and validate the durable state that decides a temporal attempt's
769
+ * terminal outcome. This intentionally does not require an active attempt:
770
+ * consumers must be able to arbitrate committed, superseded, and exhausted
771
+ * attempts after runner/auditor failures and daemon restarts.
772
+ */
773
+ inspectTemporalAttempt(attemptId) {
774
+ const workOrder = this.getWorkOrderById(attemptId);
775
+ if (!workOrder || workOrder.workKind !== 'temporal') {
776
+ throw new Error(`temporal attempt state: no temporal attempt ${attemptId}`);
777
+ }
778
+ const generationKey = this.requirePayloadString(workOrder.payload, 'generationKey', attemptId);
779
+ const generation = this.getTemporalGeneration(generationKey);
780
+ if (!generation) {
781
+ throw new Error(`temporal attempt state: generation '${generationKey}' is missing`);
782
+ }
783
+ this.assertTemporalPayloadMatches(workOrder, generation);
784
+ const receipt = this.getTemporalEffect(attemptId);
785
+ if (receipt) {
786
+ const receiptError = (0, temporal_effect_js_1.temporalReceiptInvariantError)(receipt, {
787
+ attemptId,
788
+ taskId: generation.taskId,
789
+ generationKey: generation.generationKey,
790
+ occurrenceKey: generation.occurrenceKey,
791
+ });
792
+ if (receiptError) {
793
+ throw new Error(`temporal attempt state: ${receiptError}`);
794
+ }
795
+ if (workOrder.status !== 'done' ||
796
+ generation.lastWorkOrderId !== attemptId ||
797
+ generation.disposition !== receipt.outcome) {
798
+ throw new Error(`temporal attempt state: receipt ${attemptId} is not atomically committed`);
799
+ }
800
+ const task = this.getById(generation.taskId);
801
+ if (!task || task.revision < receipt.afterRevision) {
802
+ throw new Error(`temporal attempt state: receipt ${attemptId} owner revision is invalid`);
803
+ }
804
+ if (receipt.outcome !== 'resolved') {
805
+ const scope = (0, temporal_effect_js_1.temporalNoUpdateScope)({
806
+ attemptId,
807
+ generationKey: generation.generationKey,
808
+ taskId: generation.taskId,
809
+ temporalEpoch: generation.temporalEpoch,
810
+ occurrenceKey: generation.occurrenceKey,
811
+ checkAt: generation.checkAt,
812
+ revision: receipt.beforeRevision,
813
+ sourceChannel: null,
814
+ sourceEventId: null,
815
+ });
816
+ const note = this.db
817
+ .prepare(`SELECT id FROM operator_no_update_notes
818
+ WHERE scope = ? AND reason = ? AND created_at = ? LIMIT 1`)
819
+ .get(scope, receipt.reason, receipt.createdAt);
820
+ if (!note) {
821
+ throw new Error(`temporal attempt state: exact-scope no-update note is missing`);
822
+ }
823
+ }
824
+ if (task.revision === receipt.afterRevision) {
825
+ if (task.lastTemporalAttemptId !== attemptId ||
826
+ task.lastTemporalCheckedAt !== receipt.createdAt) {
827
+ throw new Error(`temporal attempt state: owner attempt markers are invalid`);
828
+ }
829
+ if (receipt.outcome === 'resolved' || receipt.outcome === 'final_no_update') {
830
+ if (task.temporalReconciledOccurrenceKey !== generation.occurrenceKey ||
831
+ task.nextTemporalCheckAt !== null) {
832
+ throw new Error(`temporal attempt state: owner final markers are invalid`);
833
+ }
834
+ }
835
+ else if (task.nextTemporalCheckAt !== receipt.nextTemporalCheckAt) {
836
+ throw new Error(`temporal attempt state: owner deferred check is invalid`);
837
+ }
838
+ }
839
+ }
840
+ else if (workOrder.status === 'done') {
841
+ throw new Error(`temporal attempt state: done attempt ${attemptId} has no receipt`);
842
+ }
843
+ if (workOrder.status === 'in_progress') {
844
+ if (receipt ||
845
+ generation.disposition !== 'active' ||
846
+ generation.lastWorkOrderId !== attemptId) {
847
+ throw new Error(`temporal attempt state: active attempt ${attemptId} lost ownership`);
848
+ }
849
+ }
850
+ if (workOrder.status === 'cancelled' &&
851
+ generation.disposition !== 'superseded' &&
852
+ (generation.disposition !== 'active' || generation.lastWorkOrderId !== attemptId)) {
853
+ throw new Error(`temporal attempt state: cancelled attempt ${attemptId} has invalid ownership`);
854
+ }
855
+ if (workOrder.status === 'failed' &&
856
+ generation.disposition === 'active' &&
857
+ generation.lastWorkOrderId !== attemptId) {
858
+ const replacement = generation.lastWorkOrderId === null
859
+ ? null
860
+ : this.getWorkOrderById(generation.lastWorkOrderId);
861
+ if (!replacement ||
862
+ replacement.workKind !== 'temporal' ||
863
+ (replacement.status !== 'pending' && replacement.status !== 'in_progress')) {
864
+ throw new Error(`temporal attempt state: retry replacement is missing or terminal`);
865
+ }
866
+ this.assertTemporalPayloadMatches(replacement, generation);
867
+ }
868
+ if (generation.disposition === 'superseded' &&
869
+ workOrder.status !== 'cancelled' &&
870
+ workOrder.status !== 'failed') {
871
+ throw new Error(`temporal attempt state: superseded attempt ${attemptId} is still open`);
872
+ }
873
+ return { workOrder, generation, receipt };
874
+ }
875
+ applyTemporalEffect(suppliedContext, input, evidence, now = this.now()) {
876
+ if (!Number.isSafeInteger(now)) {
877
+ throw new Error('temporal effect time must be an epoch millisecond integer');
878
+ }
879
+ this.validateTemporalEffectInput(input, now);
880
+ if (!evidence ||
881
+ typeof evidence.contextPacketId !== 'string' ||
882
+ evidence.contextPacketId.trim().length < 1 ||
883
+ evidence.contextPacketId.length > 300 ||
884
+ !/^[a-f0-9]{64}$/.test(evidence.contextPacketSha256)) {
885
+ throw new Error('temporal effect requires a valid host evidence attestation');
886
+ }
887
+ let receipt = null;
888
+ this.db.exec('BEGIN IMMEDIATE');
889
+ try {
890
+ const context = this.assertTemporalWorkContextActive(suppliedContext);
891
+ if (input.expected_revision !== context.revision) {
892
+ throw new Error(`temporal effect revision mismatch: expected ${input.expected_revision}, current ${context.revision}`);
893
+ }
894
+ const existing = this.db
895
+ .prepare(`SELECT * FROM operator_tasks WHERE id = ?`)
896
+ .get(context.taskId);
897
+ if (!existing || existing.kind !== 'owner' || existing.revision !== context.revision) {
898
+ throw new Error(`temporal effect owner task no longer matches trusted context`);
899
+ }
900
+ const next = {
901
+ status: existing.status,
902
+ deadline: existing.deadline,
903
+ due_at: existing.due_at,
904
+ deadline_offset_minutes: existing.deadline_offset_minutes,
905
+ temporal_epoch: existing.temporal_epoch,
906
+ temporal_reconciled_occurrence_key: existing.temporal_reconciled_occurrence_key,
907
+ last_temporal_checked_at: now,
908
+ next_temporal_check_at: null,
909
+ last_temporal_attempt_id: context.attemptId,
910
+ };
911
+ const auditParts = [`reason=${input.reason.trim()}`];
912
+ if (input.outcome === 'final_no_update') {
913
+ auditParts.push(`evidence=${input.evidence_summary.trim()}`);
914
+ }
915
+ const receiptReason = this.temporalAuditText(`effect-${input.outcome}`, auditParts);
916
+ let nextCheck = null;
917
+ let rescheduled = false;
918
+ if (input.outcome === 'resolved') {
919
+ if (input.status !== undefined)
920
+ next.status = input.status;
921
+ if (Object.prototype.hasOwnProperty.call(input, 'due_at')) {
922
+ if (input.due_at === null) {
923
+ next.due_at = null;
924
+ }
925
+ else if (input.due_at !== undefined) {
926
+ const exactDue = (0, task_temporal_js_1.parseExactDueAt)(input.due_at);
927
+ if (exactDue.dueAt !== existing.due_at) {
928
+ next.due_at = exactDue.dueAt;
929
+ next.deadline = exactDue.deadline;
930
+ next.deadline_offset_minutes = exactDue.offsetMinutes;
931
+ }
932
+ }
933
+ }
934
+ const resolvedEffectChanged = next.status !== existing.status || next.due_at !== existing.due_at;
935
+ if (!resolvedEffectChanged) {
936
+ throw new Error('temporal resolved outcome requires an actual status or due_at change');
937
+ }
938
+ rescheduled = next.due_at !== existing.due_at;
939
+ if (rescheduled)
940
+ next.temporal_epoch = existing.temporal_epoch + 1;
941
+ next.temporal_reconciled_occurrence_key = context.occurrenceKey;
942
+ }
943
+ else if (input.outcome === 'final_no_update') {
944
+ next.temporal_reconciled_occurrence_key = context.occurrenceKey;
945
+ }
946
+ else {
947
+ nextCheck = (0, task_temporal_js_1.parseExactDueAt)(input.next_temporal_check_at).dueAt;
948
+ next.next_temporal_check_at = nextCheck;
949
+ }
950
+ const persistedColumns = [
951
+ 'status',
952
+ 'deadline',
953
+ 'due_at',
954
+ 'deadline_offset_minutes',
955
+ 'temporal_epoch',
956
+ 'temporal_reconciled_occurrence_key',
957
+ 'last_temporal_checked_at',
958
+ 'next_temporal_check_at',
959
+ 'last_temporal_attempt_id',
960
+ ];
961
+ const changedFields = persistedColumns.filter((column) => next[column] !== existing[column]);
962
+ const afterRevision = existing.revision + 1;
963
+ const sets = changedFields.map((column) => `${column} = ?`);
964
+ const values = changedFields.map((column) => next[column]);
965
+ sets.push('revision = ?', 'updated_at = ?');
966
+ values.push(afterRevision, now);
967
+ const ownerUpdate = this.db
968
+ .prepare(`UPDATE operator_tasks SET ${sets.join(', ')} WHERE id = ? AND kind = 'owner' AND revision = ?`)
969
+ .run(...values, context.taskId, existing.revision);
970
+ if (ownerUpdate.changes !== 1) {
971
+ throw new Error(`temporal effect lost owner task revision for task ${context.taskId}`);
972
+ }
973
+ if (rescheduled) {
974
+ this.supersedeTemporalGenerationsInTransaction(context.taskId, Number(next.temporal_epoch), context.generationKey, now);
975
+ }
976
+ const generationUpdate = this.db
977
+ .prepare(`UPDATE operator_temporal_generations
978
+ SET disposition = ?, reason = ?, updated_at = ?
979
+ WHERE generation_key = ? AND disposition = 'active' AND last_workorder_id = ?`)
980
+ .run(input.outcome, receiptReason, now, context.generationKey, context.attemptId);
981
+ if (generationUpdate.changes !== 1) {
982
+ throw new Error(`temporal effect lost generation ownership for attempt ${context.attemptId}`);
983
+ }
984
+ if (input.outcome === 'resolved' && (next.status === 'done' || next.status === 'cancelled')) {
985
+ this.supersedeAllActiveTemporalGenerationsInTransaction(context.taskId, now);
986
+ }
987
+ if (input.outcome !== 'resolved') {
988
+ this.insertNoUpdateNote((0, temporal_effect_js_1.temporalNoUpdateScope)(context), receiptReason, now);
989
+ }
990
+ this.db
991
+ .prepare(`INSERT INTO operator_temporal_effects
992
+ (workorder_attempt_id, task_id, generation_key, occurrence_key, outcome,
993
+ before_revision, after_revision, changed_fields, reason,
994
+ attestation_version, context_packet_id, context_packet_sha256,
995
+ next_temporal_check_at, created_at)
996
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`)
997
+ .run(context.attemptId, context.taskId, context.generationKey, context.occurrenceKey, input.outcome, existing.revision, afterRevision, JSON.stringify(changedFields), receiptReason, evidence.contextPacketId, evidence.contextPacketSha256, nextCheck, now);
998
+ const workOrderUpdate = this.db
999
+ .prepare(`UPDATE operator_tasks SET status = 'done', latest_event = ?, updated_at = ?
1000
+ WHERE id = ? AND kind = 'system' AND status = 'in_progress'
1001
+ AND source_channel = 'workorder:temporal' AND source_event_id = ?`)
1002
+ .run(receiptReason, now, context.attemptId, context.generationKey);
1003
+ if (workOrderUpdate.changes !== 1) {
1004
+ throw new Error(`temporal effect lost active workorder ${context.attemptId}`);
1005
+ }
1006
+ receipt = this.getTemporalEffect(context.attemptId);
1007
+ if (!receipt)
1008
+ throw new Error(`temporal effect receipt could not be read back`);
1009
+ const receiptError = (0, temporal_effect_js_1.temporalReceiptInvariantError)(receipt, {
1010
+ attemptId: context.attemptId,
1011
+ taskId: context.taskId,
1012
+ generationKey: context.generationKey,
1013
+ occurrenceKey: context.occurrenceKey,
1014
+ beforeRevision: context.revision,
1015
+ });
1016
+ if (receiptError) {
1017
+ throw new Error(`temporal effect receipt invariant failed: ${receiptError}`);
1018
+ }
1019
+ this.db.exec('COMMIT');
1020
+ }
1021
+ catch (error) {
1022
+ this.db.exec('ROLLBACK');
1023
+ throw error;
1024
+ }
1025
+ if (!receipt)
1026
+ throw new Error(`temporal effect produced no receipt`);
1027
+ return receipt;
1028
+ }
1029
+ requeueTemporalWorkOrder(attemptId, reason) {
1030
+ this.assertTemporalReason(reason);
1031
+ const auditReason = this.temporalAuditText('worker-failure', [`failure=${reason}`]);
1032
+ let replacement = null;
1033
+ this.db.exec('BEGIN IMMEDIATE');
1034
+ try {
1035
+ const context = this.loadTemporalWorkContextInternal(attemptId);
1036
+ const workOrder = this.getWorkOrderById(attemptId);
1037
+ if (workOrder.payload.attempts >= exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS) {
1038
+ throw new Error(`temporal retry budget exhausted for attempt ${attemptId}`);
1039
+ }
1040
+ replacement = this.requeueTemporalWorkOrderInTransaction(context, workOrder, auditReason);
1041
+ this.db.exec('COMMIT');
1042
+ }
1043
+ catch (error) {
1044
+ this.db.exec('ROLLBACK');
1045
+ throw error;
1046
+ }
1047
+ if (!replacement)
1048
+ throw new Error(`temporal retry for attempt ${attemptId} produced no row`);
1049
+ return replacement;
1050
+ }
1051
+ exhaustTemporalWorkOrder(attemptId, reason) {
1052
+ this.assertTemporalReason(reason);
1053
+ const auditReason = this.temporalAuditText('worker-failure', [`failure=${reason}`]);
1054
+ this.db.exec('BEGIN IMMEDIATE');
1055
+ try {
1056
+ const context = this.loadTemporalWorkContextInternal(attemptId);
1057
+ const workOrder = this.getWorkOrderById(attemptId);
1058
+ if (workOrder.payload.attempts !== exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS) {
1059
+ throw new Error(`temporal exhaustion requires attempt ${exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS}, got ${workOrder.payload.attempts}`);
1060
+ }
1061
+ this.exhaustTemporalWorkOrderInTransaction(context, auditReason);
1062
+ this.db.exec('COMMIT');
1063
+ }
1064
+ catch (error) {
1065
+ this.db.exec('ROLLBACK');
1066
+ throw error;
1067
+ }
1068
+ }
1069
+ failTemporalWorkOrder(attemptId, reason) {
1070
+ this.assertTemporalReason(reason);
1071
+ const auditReason = this.temporalAuditText('worker-failure', [`failure=${reason}`]);
1072
+ let result = null;
1073
+ this.db.exec('BEGIN IMMEDIATE');
1074
+ try {
1075
+ const repairedAttempt = this.repairClosedTemporalOwnershipInTransaction(attemptId);
1076
+ if (repairedAttempt !== null) {
1077
+ result = {
1078
+ disposition: 'superseded',
1079
+ attempt: repairedAttempt,
1080
+ maxAttempts: exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS,
1081
+ };
1082
+ this.db.exec('COMMIT');
1083
+ return result;
1084
+ }
1085
+ const context = this.loadTemporalWorkContextInternal(attemptId);
1086
+ const workOrder = this.getWorkOrderById(attemptId);
1087
+ const attempt = workOrder.payload.attempts;
1088
+ if (attempt < exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS) {
1089
+ result = {
1090
+ disposition: 'requeued',
1091
+ replacement: this.requeueTemporalWorkOrderInTransaction(context, workOrder, auditReason),
1092
+ attempt,
1093
+ maxAttempts: exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS,
1094
+ };
1095
+ }
1096
+ else if (attempt === exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS) {
1097
+ this.exhaustTemporalWorkOrderInTransaction(context, auditReason);
1098
+ result = {
1099
+ disposition: 'exhausted',
1100
+ attempt,
1101
+ maxAttempts: exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS,
1102
+ };
1103
+ }
1104
+ else {
1105
+ throw new Error(`temporal attempt ${attemptId} exceeds retry budget: ${attempt}`);
1106
+ }
1107
+ this.db.exec('COMMIT');
1108
+ }
1109
+ catch (error) {
1110
+ this.db.exec('ROLLBACK');
1111
+ throw error;
1112
+ }
1113
+ if (!result)
1114
+ throw new Error(`temporal failure for attempt ${attemptId} produced no result`);
1115
+ return result;
1116
+ }
1117
+ repairClosedTemporalOwnershipInTransaction(attemptId) {
1118
+ const workOrder = this.getWorkOrderById(attemptId);
1119
+ if (!workOrder || workOrder.workKind !== 'temporal') {
1120
+ return null;
1121
+ }
1122
+ const generationKey = this.requirePayloadString(workOrder.payload, 'generationKey', attemptId);
1123
+ const generation = this.getTemporalGeneration(generationKey);
1124
+ if (!generation || generation.lastWorkOrderId !== attemptId) {
1125
+ return null;
1126
+ }
1127
+ const task = this.getById(generation.taskId);
1128
+ if (!task || (task.status !== 'done' && task.status !== 'cancelled')) {
1129
+ return null;
1130
+ }
1131
+ const openAttempt = workOrder.status === 'pending' || workOrder.status === 'in_progress';
1132
+ if (generation.disposition === 'active' && openAttempt) {
1133
+ this.supersedeAllActiveTemporalGenerationsInTransaction(task.id);
1134
+ }
1135
+ else if (generation.disposition !== 'superseded' || workOrder.status !== 'cancelled') {
1136
+ return null;
1137
+ }
1138
+ return workOrder.payload.attempts;
1139
+ }
1140
+ /** Repair pre-fix databases where a terminal owner still has active temporal ownership. */
1141
+ repairClosedTemporalGenerations() {
1142
+ let repaired = 0;
1143
+ this.db.exec('BEGIN IMMEDIATE');
1144
+ try {
1145
+ const rows = this.db
1146
+ .prepare(`SELECT DISTINCT generation.task_id
1147
+ FROM operator_temporal_generations generation
1148
+ JOIN operator_tasks owner ON owner.id = generation.task_id
1149
+ WHERE generation.disposition = 'active'
1150
+ AND owner.kind = 'owner'
1151
+ AND owner.status IN ('done','cancelled')
1152
+ ORDER BY generation.task_id ASC`)
1153
+ .all();
1154
+ const updatedAt = this.now();
1155
+ for (const row of rows) {
1156
+ this.supersedeAllActiveTemporalGenerationsInTransaction(row.task_id, updatedAt);
1157
+ }
1158
+ repaired = rows.length;
1159
+ this.db.exec('COMMIT');
1160
+ }
1161
+ catch (error) {
1162
+ this.db.exec('ROLLBACK');
1163
+ throw error;
1164
+ }
1165
+ return repaired;
1166
+ }
1167
+ /** Control-plane pause: cancel open attempts but keep generations resumable. */
1168
+ pauseActiveTemporalWork(reason) {
1169
+ this.assertTemporalReason(reason);
1170
+ let changed = 0;
1171
+ this.db.exec('BEGIN IMMEDIATE');
1172
+ try {
1173
+ const result = this.db
1174
+ .prepare(`UPDATE operator_tasks
1175
+ SET status = 'cancelled', latest_event = ?, updated_at = ?
1176
+ WHERE kind = 'system' AND source_channel = 'workorder:temporal'
1177
+ AND status IN ('pending','in_progress')`)
1178
+ .run(reason, this.now());
1179
+ changed = result.changes;
1180
+ this.db.exec('COMMIT');
1181
+ }
1182
+ catch (error) {
1183
+ this.db.exec('ROLLBACK');
1184
+ throw error;
1185
+ }
1186
+ return changed;
1187
+ }
1188
+ /** Resume paused active generations without spending another model attempt. */
1189
+ resumePausedTemporalWork() {
1190
+ const resumed = [];
1191
+ this.db.exec('BEGIN IMMEDIATE');
1192
+ try {
1193
+ const rows = this.db
1194
+ .prepare(`SELECT generation_key, last_workorder_id
1195
+ FROM operator_temporal_generations generation
1196
+ WHERE disposition = 'active' AND last_workorder_id IS NOT NULL
1197
+ AND NOT EXISTS (
1198
+ SELECT 1 FROM operator_tasks open_attempt
1199
+ WHERE open_attempt.kind = 'system'
1200
+ AND open_attempt.source_channel = 'workorder:temporal'
1201
+ AND open_attempt.source_event_id = generation.generation_key
1202
+ AND open_attempt.status IN ('pending','in_progress')
1203
+ )
1204
+ ORDER BY generation_key ASC`)
1205
+ .all();
1206
+ for (const row of rows) {
1207
+ const generation = this.getTemporalGeneration(row.generation_key);
1208
+ const previous = this.getWorkOrderById(row.last_workorder_id);
1209
+ if (!generation || !previous || previous.status !== 'cancelled') {
1210
+ throw new Error(`temporal resume: active generation '${row.generation_key}' has no paused attempt`);
1211
+ }
1212
+ this.assertTemporalPayloadMatches(previous, generation);
1213
+ const { attempts, ...payload } = previous.payload;
1214
+ const replacement = this.insertWorkOrder({
1215
+ workKind: 'temporal',
1216
+ idempotencyKey: generation.generationKey,
1217
+ input: payload,
1218
+ priority: previous.priority,
1219
+ }, attempts);
1220
+ const ownership = this.db
1221
+ .prepare(`UPDATE operator_temporal_generations
1222
+ SET last_workorder_id = ?, updated_at = ?
1223
+ WHERE generation_key = ? AND disposition = 'active' AND last_workorder_id = ?`)
1224
+ .run(replacement.id, this.now(), generation.generationKey, previous.id);
1225
+ if (ownership.changes !== 1) {
1226
+ throw new Error(`temporal resume lost ownership for generation '${generation.generationKey}'`);
1227
+ }
1228
+ resumed.push(replacement);
1229
+ }
1230
+ this.db.exec('COMMIT');
1231
+ }
1232
+ catch (error) {
1233
+ this.db.exec('ROLLBACK');
1234
+ throw error;
1235
+ }
1236
+ return resumed;
1237
+ }
1238
+ requeueTemporalWorkOrderInTransaction(context, workOrder, reason) {
1239
+ this.transitionWorkOrder(workOrder.id, 'failed', reason);
1240
+ const { attempts: _attempts, ...payload } = workOrder.payload;
1241
+ const replacement = this.insertWorkOrder({
1242
+ workKind: 'temporal',
1243
+ idempotencyKey: context.generationKey,
1244
+ input: payload,
1245
+ priority: workOrder.priority,
1246
+ }, workOrder.payload.attempts + 1);
1247
+ const ownership = this.db
1248
+ .prepare(`UPDATE operator_temporal_generations
1249
+ SET last_workorder_id = ?, updated_at = ?
1250
+ WHERE generation_key = ? AND disposition = 'active' AND last_workorder_id = ?`)
1251
+ .run(replacement.id, this.now(), context.generationKey, workOrder.id);
1252
+ if (ownership.changes !== 1) {
1253
+ throw new Error(`temporal retry lost generation ownership for attempt ${workOrder.id}`);
1254
+ }
1255
+ return replacement;
1256
+ }
1257
+ exhaustTemporalWorkOrderInTransaction(context, reason) {
1258
+ this.transitionWorkOrder(context.attemptId, 'failed', reason);
1259
+ const update = this.db
1260
+ .prepare(`UPDATE operator_temporal_generations
1261
+ SET disposition = 'exhausted', reason = ?, updated_at = ?
1262
+ WHERE generation_key = ? AND disposition = 'active' AND last_workorder_id = ?`)
1263
+ .run(reason, this.now(), context.generationKey, context.attemptId);
1264
+ if (update.changes !== 1) {
1265
+ throw new Error(`temporal exhaustion lost ownership for attempt ${context.attemptId}`);
1266
+ }
1267
+ }
1268
+ supersedeTemporalGenerations(taskId, currentEpoch, excludeGenerationKey) {
1269
+ this.db.exec('BEGIN IMMEDIATE');
1270
+ try {
1271
+ this.supersedeTemporalGenerationsInTransaction(taskId, currentEpoch, excludeGenerationKey);
1272
+ this.db.exec('COMMIT');
1273
+ }
1274
+ catch (error) {
1275
+ this.db.exec('ROLLBACK');
1276
+ throw error;
1277
+ }
1278
+ }
1279
+ validateTemporalGenerationInput(input) {
1280
+ const bounded = (value, field, max) => {
1281
+ if (value.length < 1 || value.length > max) {
1282
+ throw new Error(`temporal generation: ${field} must contain 1-${max} characters`);
1283
+ }
1284
+ };
1285
+ bounded(input.generationKey, 'generationKey', 500);
1286
+ bounded(input.occurrenceKey, 'occurrenceKey', 300);
1287
+ if (!Number.isSafeInteger(input.taskId) || input.taskId < 1) {
1288
+ throw new Error(`temporal generation: taskId must be a positive integer`);
1289
+ }
1290
+ if (!Number.isSafeInteger(input.temporalEpoch) || input.temporalEpoch < 0) {
1291
+ throw new Error(`temporal generation: temporalEpoch must be a non-negative integer`);
1292
+ }
1293
+ if (!Number.isSafeInteger(input.checkAt)) {
1294
+ throw new Error(`temporal generation: checkAt must be an epoch millisecond integer`);
1295
+ }
1296
+ const canonicalKey = (0, task_temporal_js_1.temporalGenerationKey)(input.taskId, input.occurrenceKey, input.checkAt);
1297
+ if (input.generationKey !== canonicalKey) {
1298
+ throw new Error(`temporal generation: generationKey must equal canonical identity key`);
1299
+ }
1300
+ for (const [field, value] of [
1301
+ ['sourceChannel', input.sourceChannel],
1302
+ ['sourceEventId', input.sourceEventId],
1303
+ ]) {
1304
+ if (value !== null && typeof value !== 'string') {
1305
+ throw new Error(`temporal generation: ${field} must be a string or null`);
1306
+ }
1307
+ }
1308
+ if (input.priority !== undefined)
1309
+ assertEnum(input.priority, exports.TASK_PRIORITIES, 'priority');
1310
+ }
1311
+ /**
1312
+ * Workorder payloads carry only bounded references to owner-source identifiers.
1313
+ * Owner rows created by older releases may contain empty or arbitrarily long
1314
+ * connector identifiers; hashing preserves exact identity without making one
1315
+ * legacy row able to abort temporal boot or inflate every retry payload.
1316
+ */
1317
+ temporalSourceIdentifierRef(value) {
1318
+ if (value === null)
1319
+ return null;
1320
+ return `sha256:${(0, node_crypto_1.createHash)('sha256').update(value).digest('hex')}`;
1321
+ }
1322
+ validateTemporalEffectInput(input, now) {
1323
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1324
+ throw new Error('temporal effect input must be an object');
1325
+ }
1326
+ if (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 0) {
1327
+ throw new Error('temporal effect expected_revision must be a non-negative integer');
1328
+ }
1329
+ this.assertTemporalReason(input.reason);
1330
+ const baseKeys = ['expected_revision', 'outcome', 'reason'];
1331
+ let allowedKeys;
1332
+ if (input.outcome === 'resolved') {
1333
+ allowedKeys = [...baseKeys, 'status', 'due_at'];
1334
+ if (input.status !== undefined) {
1335
+ assertEnum(input.status, exports.TASK_STATUSES, 'temporal effect status');
1336
+ if (String(input.status) === 'failed') {
1337
+ throw new Error(`temporal effect: 'failed' is a system-only status`);
1338
+ }
1339
+ }
1340
+ if (input.due_at !== undefined && input.due_at !== null)
1341
+ (0, task_temporal_js_1.parseExactDueAt)(input.due_at);
1342
+ }
1343
+ else if (input.outcome === 'final_no_update') {
1344
+ allowedKeys = [...baseKeys, 'evidence_summary'];
1345
+ if (typeof input.evidence_summary !== 'string' ||
1346
+ input.evidence_summary.trim().length < 1 ||
1347
+ input.evidence_summary.length > 1_000) {
1348
+ throw new Error('temporal effect evidence_summary must contain 1-1000 characters');
1349
+ }
1350
+ }
1351
+ else if (input.outcome === 'deferred') {
1352
+ allowedKeys = [...baseKeys, 'next_temporal_check_at'];
1353
+ if (typeof input.next_temporal_check_at !== 'string') {
1354
+ throw new Error('temporal effect next_temporal_check_at must be RFC 3339');
1355
+ }
1356
+ const nextCheck = (0, task_temporal_js_1.parseExactDueAt)(input.next_temporal_check_at).dueAt;
1357
+ if (nextCheck <= now) {
1358
+ throw new Error('temporal effect deferred check must be strictly in the future');
1359
+ }
1360
+ }
1361
+ else {
1362
+ throw new Error(`temporal effect outcome is unknown or forbidden`);
1363
+ }
1364
+ const unknownKeys = Object.keys(input).filter((key) => !allowedKeys.includes(key));
1365
+ if (unknownKeys.length > 0) {
1366
+ throw new Error(`temporal effect contains unknown or forbidden fields: ${unknownKeys.join(', ')}`);
1367
+ }
1368
+ }
1369
+ assertTemporalContextMatches(supplied, trusted) {
1370
+ const fields = [
1371
+ 'attemptId',
1372
+ 'generationKey',
1373
+ 'taskId',
1374
+ 'temporalEpoch',
1375
+ 'occurrenceKey',
1376
+ 'checkAt',
1377
+ 'revision',
1378
+ 'sourceChannel',
1379
+ 'sourceEventId',
1380
+ ];
1381
+ if (fields.some((field) => supplied[field] !== trusted[field])) {
1382
+ throw new Error('temporal effect supplied context does not match trusted host context');
1383
+ }
1384
+ }
1385
+ loadTemporalWorkContextInternal(attemptId) {
1386
+ const workOrder = this.getWorkOrderById(attemptId);
1387
+ if (!workOrder || workOrder.workKind !== 'temporal') {
1388
+ throw new Error(`temporal context: no temporal attempt ${attemptId}`);
1389
+ }
1390
+ if (workOrder.status !== 'in_progress') {
1391
+ throw new Error(`temporal context: attempt ${attemptId} is '${workOrder.status}', expected active attempt`);
1392
+ }
1393
+ const payload = workOrder.payload;
1394
+ const generationKey = this.requirePayloadString(payload, 'generationKey', attemptId);
1395
+ const taskId = this.requirePayloadInteger(payload, 'taskId', attemptId, 1);
1396
+ const temporalEpoch = this.requirePayloadInteger(payload, 'temporalEpoch', attemptId, 0);
1397
+ const occurrenceKey = this.requirePayloadString(payload, 'occurrenceKey', attemptId);
1398
+ const checkAt = this.requirePayloadInteger(payload, 'checkAt', attemptId);
1399
+ const sourceChannel = this.requireNullablePayloadString(payload, 'sourceChannel', attemptId);
1400
+ const sourceEventId = this.requireNullablePayloadString(payload, 'sourceEventId', attemptId);
1401
+ const generation = this.getTemporalGeneration(generationKey);
1402
+ if (!generation || generation.disposition !== 'active') {
1403
+ throw new Error(`temporal context: generation '${generationKey}' is not active`);
1404
+ }
1405
+ if (generation.lastWorkOrderId !== attemptId) {
1406
+ throw new Error(`temporal context: attempt ${attemptId} no longer owns generation`);
1407
+ }
1408
+ this.assertTemporalPayloadMatches(workOrder, generation);
1409
+ const task = this.getRowById(taskId);
1410
+ if (!task || task.kind !== 'owner') {
1411
+ throw new Error(`temporal context: owner task ${taskId} does not exist`);
1412
+ }
1413
+ if (task.status === 'done' || task.status === 'cancelled') {
1414
+ throw new Error(`temporal context: owner task ${taskId} is closed`);
1415
+ }
1416
+ if (task.temporalEpoch !== temporalEpoch ||
1417
+ (0, task_temporal_js_1.occurrenceKeyForTask)(task) !== occurrenceKey ||
1418
+ this.temporalSourceIdentifierRef(task.sourceChannel) !== sourceChannel ||
1419
+ this.temporalSourceIdentifierRef(task.sourceEventId) !== sourceEventId ||
1420
+ generation.taskId !== taskId ||
1421
+ generation.temporalEpoch !== temporalEpoch ||
1422
+ generation.occurrenceKey !== occurrenceKey ||
1423
+ generation.checkAt !== checkAt) {
1424
+ throw new Error(`temporal context: attempt ${attemptId} payload no longer matches ownership`);
1425
+ }
1426
+ return {
1427
+ attemptId,
1428
+ generationKey,
1429
+ taskId,
1430
+ temporalEpoch,
1431
+ occurrenceKey,
1432
+ checkAt,
1433
+ revision: task.revision,
1434
+ sourceChannel,
1435
+ sourceEventId,
1436
+ };
1437
+ }
1438
+ assertTemporalPayloadMatches(workOrder, generation) {
1439
+ const payload = workOrder.payload;
1440
+ if (payload.generationKey !== generation.generationKey ||
1441
+ payload.taskId !== generation.taskId ||
1442
+ payload.temporalEpoch !== generation.temporalEpoch ||
1443
+ payload.occurrenceKey !== generation.occurrenceKey ||
1444
+ payload.checkAt !== generation.checkAt) {
1445
+ throw new Error(`temporal attempt ${workOrder.id} payload does not match generation ownership`);
1446
+ }
1447
+ }
1448
+ requirePayloadString(payload, field, attemptId) {
1449
+ const value = payload[field];
1450
+ if (typeof value !== 'string' || value.length === 0) {
1451
+ throw new Error(`temporal attempt ${attemptId}: ${field} must be a non-empty string`);
1452
+ }
1453
+ return value;
1454
+ }
1455
+ requireNullablePayloadString(payload, field, attemptId) {
1456
+ const value = payload[field];
1457
+ if (value !== null && typeof value !== 'string') {
1458
+ throw new Error(`temporal attempt ${attemptId}: ${field} must be a string or null`);
1459
+ }
1460
+ return value;
1461
+ }
1462
+ requirePayloadInteger(payload, field, attemptId, minimum = Number.MIN_SAFE_INTEGER) {
1463
+ const value = payload[field];
1464
+ if (!Number.isSafeInteger(value) || value < minimum) {
1465
+ throw new Error(`temporal attempt ${attemptId}: ${field} must be an integer`);
1466
+ }
1467
+ return value;
1468
+ }
1469
+ assertTemporalReason(reason) {
1470
+ if (reason.trim().length < 1 || reason.length > 500) {
1471
+ throw new Error('temporal workorder reason must contain 1-500 characters');
1472
+ }
1473
+ }
1474
+ temporalAuditText(kind, values) {
1475
+ const refs = values.map((value, index) => {
1476
+ const separator = value.indexOf('=');
1477
+ const label = separator > 0 ? value.slice(0, separator) : `value${index + 1}`;
1478
+ const text = separator > 0 ? value.slice(separator + 1) : value;
1479
+ const digest = (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
1480
+ return `${label}_sha256=${digest};${label}_length=${text.length}`;
1481
+ });
1482
+ return `temporal-${kind};${refs.join(';')}`;
1483
+ }
417
1484
  /**
418
1485
  * Claim the next pending workorder: priority high>normal>low, then id ASC
419
1486
  * (plan D2/E2 - CASE mapping, never lexicographic on the TEXT enum).
@@ -426,14 +1493,15 @@ class TaskLedger {
426
1493
  const row = this.db
427
1494
  .prepare(`SELECT * FROM operator_tasks
428
1495
  WHERE kind = 'system' AND status = 'pending'
1496
+ AND source_channel IN (${KNOWN_WORKORDER_CHANNELS_SQL})
429
1497
  ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END ASC,
430
1498
  id ASC
431
1499
  LIMIT 1`)
432
- .get();
1500
+ .get(...KNOWN_WORKORDER_CHANNELS);
433
1501
  if (row) {
434
1502
  this.db
435
1503
  .prepare(`UPDATE operator_tasks SET status = 'in_progress', updated_at = ? WHERE id = ?`)
436
- .run(Date.now(), row.id);
1504
+ .run(this.now(), row.id);
437
1505
  claimedId = row.id;
438
1506
  }
439
1507
  this.db.exec('COMMIT');
@@ -454,15 +1522,23 @@ class TaskLedger {
454
1522
  * unique index, hence one transaction, not two calls.
455
1523
  */
456
1524
  requeueWorkOrder(wo, reason) {
1525
+ if (wo.workKind === 'temporal') {
1526
+ return this.requeueTemporalWorkOrder(wo.id, reason);
1527
+ }
457
1528
  this.db.exec('BEGIN IMMEDIATE');
458
1529
  try {
459
- this.transitionWorkOrder(wo.id, 'failed', reason);
460
- const replacement = this.enqueueWorkOrder({
461
- workKind: wo.workKind,
462
- idempotencyKey: wo.idempotencyKey,
463
- input: { ...wo.payload, attempts: wo.payload.attempts + 1 },
464
- priority: wo.priority,
465
- });
1530
+ const current = this.getWorkOrderById(wo.id);
1531
+ if (!current || current.workKind !== wo.workKind) {
1532
+ throw new Error(`workorder retry: claimed row ${wo.id} no longer matches input`);
1533
+ }
1534
+ this.transitionWorkOrder(current.id, 'failed', reason);
1535
+ const { attempts: _attempts, ...input } = current.payload;
1536
+ const replacement = this.insertWorkOrder({
1537
+ workKind: current.workKind,
1538
+ idempotencyKey: current.idempotencyKey,
1539
+ input,
1540
+ priority: current.priority,
1541
+ }, current.payload.attempts + 1);
466
1542
  this.db.exec('COMMIT');
467
1543
  return replacement;
468
1544
  }
@@ -479,15 +1555,44 @@ class TaskLedger {
479
1555
  }
480
1556
  countPendingWorkOrders() {
481
1557
  const row = this.db
482
- .prepare(`SELECT COUNT(*) AS count FROM operator_tasks WHERE kind = 'system' AND status = 'pending'`)
483
- .get();
1558
+ .prepare(`SELECT COUNT(*) AS count FROM operator_tasks
1559
+ WHERE kind = 'system' AND status = 'pending'
1560
+ AND source_channel IN (${KNOWN_WORKORDER_CHANNELS_SQL})`)
1561
+ .get(...KNOWN_WORKORDER_CHANNELS);
1562
+ return row.count;
1563
+ }
1564
+ countOpenWorkOrders(kind) {
1565
+ const row = (kind
1566
+ ? this.db
1567
+ .prepare(`SELECT COUNT(*) AS count FROM operator_tasks
1568
+ WHERE kind = 'system' AND status IN ('pending','in_progress')
1569
+ AND source_channel = ?`)
1570
+ .get(`${exports.WORKORDER_CHANNEL_PREFIX}${kind}`)
1571
+ : this.db
1572
+ .prepare(`SELECT COUNT(*) AS count FROM operator_tasks
1573
+ WHERE kind = 'system' AND status IN ('pending','in_progress')`)
1574
+ .get());
484
1575
  return row.count;
485
1576
  }
1577
+ findTemporalGenerationKeys(generationKeys) {
1578
+ if (generationKeys.length === 0)
1579
+ return new Set();
1580
+ if (generationKeys.length > 500) {
1581
+ throw new Error('findTemporalGenerationKeys accepts at most 500 keys');
1582
+ }
1583
+ const rows = this.db
1584
+ .prepare(`SELECT generation_key FROM operator_temporal_generations
1585
+ WHERE generation_key IN (${generationKeys.map(() => '?').join(',')})`)
1586
+ .all(...generationKeys);
1587
+ return new Set(rows.map((row) => row.generation_key));
1588
+ }
486
1589
  /** In-progress system rows at boot = crash artifacts (single serial consumer). */
487
1590
  listStaleClaims() {
488
1591
  const rows = this.db
489
- .prepare(`SELECT * FROM operator_tasks WHERE kind = 'system' AND status = 'in_progress'`)
490
- .all();
1592
+ .prepare(`SELECT * FROM operator_tasks
1593
+ WHERE kind = 'system' AND status = 'in_progress'
1594
+ AND source_channel IN (${KNOWN_WORKORDER_CHANNELS_SQL})`)
1595
+ .all(...KNOWN_WORKORDER_CHANNELS);
491
1596
  return rows.map((row) => this.rowToWorkOrder(row));
492
1597
  }
493
1598
  /**
@@ -502,15 +1607,14 @@ class TaskLedger {
502
1607
  if (onlyKinds !== undefined && onlyKinds.length === 0) {
503
1608
  return 0;
504
1609
  }
505
- const kindFilter = onlyKinds && onlyKinds.length > 0
506
- ? ` AND source_channel IN (${onlyKinds.map(() => '?').join(',')})`
507
- : '';
508
- const kindParams = (onlyKinds ?? []).map((kind) => `${exports.WORKORDER_CHANNEL_PREFIX}${kind}`);
1610
+ const selectedKinds = onlyKinds ?? exports.WORKORDER_KINDS;
1611
+ const kindFilter = ` AND source_channel IN (${selectedKinds.map(() => '?').join(',')})`;
1612
+ const kindParams = selectedKinds.map((kind) => `${exports.WORKORDER_CHANNEL_PREFIX}${kind}`);
509
1613
  const result = this.db
510
1614
  .prepare(`UPDATE operator_tasks
511
1615
  SET status = 'cancelled', latest_event = ?, updated_at = ?
512
1616
  WHERE kind = 'system' AND status IN ('pending','in_progress')${kindFilter}`)
513
- .run(reason, Date.now(), ...kindParams);
1617
+ .run(reason, this.now(), ...kindParams);
514
1618
  return result.changes;
515
1619
  }
516
1620
  /** Per-kind stats for the workorder_status surface. */
@@ -556,7 +1660,7 @@ class TaskLedger {
556
1660
  this.db
557
1661
  .prepare(`UPDATE operator_tasks SET status = ?, latest_event = COALESCE(?, latest_event),
558
1662
  updated_at = ? WHERE id = ?`)
559
- .run(status, reason, Date.now(), id);
1663
+ .run(status, reason, this.now(), id);
560
1664
  }
561
1665
  rowToWorkOrder(row) {
562
1666
  const workKind = (row.source_channel ?? '').slice(exports.WORKORDER_CHANNEL_PREFIX.length);
@@ -594,9 +1698,12 @@ class TaskLedger {
594
1698
  if (!scope || !reason) {
595
1699
  throw new Error('contract_no_update requires both scope and reason');
596
1700
  }
1701
+ return this.insertNoUpdateNote(scope, reason, this.now());
1702
+ }
1703
+ insertNoUpdateNote(scope, reason, createdAt) {
597
1704
  const result = this.db
598
1705
  .prepare(`INSERT INTO operator_no_update_notes (scope, reason, created_at) VALUES (?, ?, ?)`)
599
- .run(scope, reason, Date.now());
1706
+ .run(scope, reason, createdAt);
600
1707
  return { id: Number(result.lastInsertRowid) };
601
1708
  }
602
1709
  /** Max no-update note id, optionally scoped - the verifier's note snapshot. */