@jungjaehoon/mama-os 0.24.2 → 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 +8 -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 +1166 -65
  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,7 +38,8 @@ 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:';
41
45
  const KNOWN_WORKORDER_CHANNELS = exports.WORKORDER_KINDS.map((workKind) => `${exports.WORKORDER_CHANNEL_PREFIX}${workKind}`);
@@ -64,7 +68,7 @@ function assertIsoDate(value, field) {
64
68
  throw new Error(`${field} must be an ISO date (YYYY-MM-DD), got: ${value}`);
65
69
  }
66
70
  }
67
- function rowToRecord(row) {
71
+ function rowToRecord(row, now, timeZone) {
68
72
  return {
69
73
  id: row.id,
70
74
  title: row.title,
@@ -79,20 +83,44 @@ function rowToRecord(row) {
79
83
  latestEvent: row.latest_event,
80
84
  autoCreated: row.auto_created === 1,
81
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),
82
100
  createdAt: row.created_at,
83
101
  updatedAt: row.updated_at,
84
102
  };
85
103
  }
86
104
  class TaskLedger {
87
105
  db;
88
- constructor(db) {
106
+ now;
107
+ timeZone;
108
+ constructor(db, options = {}) {
89
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);
90
114
  this.runMigration();
91
115
  }
116
+ toRecord(row) {
117
+ return rowToRecord(row, this.now(), this.timeZone);
118
+ }
92
119
  runMigration() {
93
120
  // Both construction sites (start.ts boot + operator-handler lazy) run this;
94
121
  // busy_timeout here covers BOTH connections against the rebuild race.
95
122
  this.db.pragma('busy_timeout = 5000');
123
+ this.db.pragma('foreign_keys = ON');
96
124
  this.db.exec(`
97
125
  CREATE TABLE IF NOT EXISTS operator_tasks (${TaskLedger.TABLE_COLUMNS_SQL});
98
126
  ${TaskLedger.INDEXES_SQL}
@@ -107,6 +135,10 @@ class TaskLedger {
107
135
  ON operator_no_update_notes(scope, id);
108
136
  `);
109
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
+ }
110
142
  }
111
143
  /** Full current column set - single source for CREATE and the rebuild copy. */
112
144
  static TABLE_COLUMNS_SQL = `
@@ -124,6 +156,15 @@ class TaskLedger {
124
156
  latest_event TEXT,
125
157
  auto_created INTEGER NOT NULL DEFAULT 1,
126
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,
127
168
  created_at INTEGER NOT NULL,
128
169
  updated_at INTEGER NOT NULL`;
129
170
  // Unique-index predicate excludes terminal rows so a terminal keyed workorder
@@ -187,6 +228,7 @@ class TaskLedger {
187
228
  }
188
229
  }
189
230
  }
231
+ (0, operator_task_temporal_js_1.applyOperatorTaskTemporalMigration)(this.db);
190
232
  // Old-predicate unique index (no terminal exclusion) -> swap in place.
191
233
  const idxRow = this.db
192
234
  .prepare(`SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_operator_tasks_source'`)
@@ -249,7 +291,26 @@ class TaskLedger {
249
291
  ORDER BY ${order}
250
292
  LIMIT ?`)
251
293
  .all(...params, limit);
252
- 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));
253
314
  }
254
315
  /** Owner rows only - system workorder rows are invisible to external reads. */
255
316
  getById(id) {
@@ -259,7 +320,7 @@ class TaskLedger {
259
320
  /** Internal fetch without the kind filter (guards and workorder paths). */
260
321
  getRowById(id) {
261
322
  const row = this.db.prepare(`SELECT * FROM operator_tasks WHERE id = ?`).get(id);
262
- return row ? rowToRecord(row) : null;
323
+ return row ? this.toRecord(row) : null;
263
324
  }
264
325
  /**
265
326
  * Create a task. Idempotent under at-least-once delivery: a duplicate
@@ -286,7 +347,13 @@ class TaskLedger {
286
347
  assertEnum(input.priority, exports.TASK_PRIORITIES, 'priority');
287
348
  if (input.deadline !== undefined)
288
349
  assertIsoDate(input.deadline, 'deadline');
289
- 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();
290
357
  if (input.source_channel && input.source_event_id) {
291
358
  // kind='owner' probe: an agent-supplied (channel, event) pair can never
292
359
  // reach a system workorder row (Stage-2 tamper guard).
@@ -295,13 +362,15 @@ class TaskLedger {
295
362
  WHERE source_channel = ? AND source_event_id = ? AND kind = 'owner'`)
296
363
  .get(input.source_channel, input.source_event_id);
297
364
  if (existing) {
298
- // Upsert carries every provided field EXCEPT title (the original naming
299
- // 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.
300
368
  return this.update(existing.id, {
301
369
  ...(input.status !== undefined ? { status: input.status } : {}),
302
370
  ...(input.priority !== undefined ? { priority: input.priority } : {}),
303
371
  ...(input.assignee !== undefined ? { assignee: input.assignee } : {}),
304
372
  ...(input.deadline !== undefined ? { deadline: input.deadline } : {}),
373
+ ...(input.due_at !== undefined ? { due_at: input.due_at } : {}),
305
374
  ...(input.confirmed !== undefined ? { confirmed: input.confirmed } : {}),
306
375
  ...(input.latest_event !== undefined ? { latest_event: input.latest_event } : {}),
307
376
  });
@@ -309,26 +378,17 @@ class TaskLedger {
309
378
  }
310
379
  const result = this.db
311
380
  .prepare(`INSERT INTO operator_tasks
312
- (title, status, priority, assignee, deadline, source_channel, source_event_id,
313
- latest_event, auto_created, confirmed, created_at, updated_at)
314
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
315
- .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);
316
386
  const created = this.getById(Number(result.lastInsertRowid));
317
387
  if (!created)
318
388
  throw new Error('task_create: inserted row could not be read back');
319
389
  return created;
320
390
  }
321
391
  update(id, patch) {
322
- const existing = this.getRowById(id);
323
- if (!existing)
324
- throw new Error(`task_update: no task with id ${id}`);
325
- // Stage-2 tamper guards: system workorder rows are host-managed (their
326
- // transitions go through the workorder API only), and 'failed' can never
327
- // be set on an owner task from any external surface (REST PATCH + gateway
328
- // task_update both land here).
329
- if (existing.kind === 'system') {
330
- throw new Error(`task_update: task ${id} is a system workorder row (host-managed)`);
331
- }
332
392
  if (patch.status === 'failed') {
333
393
  throw new Error(`task_update: 'failed' is a system-only status`);
334
394
  }
@@ -342,29 +402,154 @@ class TaskLedger {
342
402
  if (patch.title !== undefined && patch.title.trim() === '') {
343
403
  throw new Error('task title must be a non-empty string');
344
404
  }
345
- const sets = ['updated_at = ?'];
346
- const params = [Date.now()];
347
- const assign = (column, value) => {
348
- sets.push(`${column} = ?`);
349
- params.push(value);
350
- };
351
- if (patch.title !== undefined)
352
- assign('title', patch.title.trim());
353
- if (patch.status !== undefined)
354
- assign('status', patch.status);
355
- if (patch.priority !== undefined)
356
- assign('priority', patch.priority);
357
- if (patch.assignee !== undefined)
358
- assign('assignee', patch.assignee);
359
- if (patch.deadline !== undefined)
360
- assign('deadline', patch.deadline);
361
- if (patch.latest_event !== undefined)
362
- assign('latest_event', patch.latest_event);
363
- if (patch.confirmed !== undefined)
364
- assign('confirmed', patch.confirmed ? 1 : 0);
365
- 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
+ }
366
517
  return this.getById(id);
367
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
+ }
368
553
  /**
369
554
  * Stable hash over ordered OWNER rows - the Phase-2 verifier's ledger
370
555
  * snapshot. kind filter keeps concurrent system enqueues from shaking the
@@ -387,6 +572,15 @@ class TaskLedger {
387
572
  * unique index excludes terminal statuses) and a fresh row is inserted.
388
573
  */
389
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) {
390
584
  assertEnum(order.workKind, exports.WORKORDER_KINDS, 'workKind');
391
585
  if (order.priority !== undefined) {
392
586
  assertEnum(order.priority, exports.TASK_PRIORITIES, 'priority');
@@ -403,11 +597,11 @@ class TaskLedger {
403
597
  if (open) {
404
598
  return this.rowToWorkOrder(open);
405
599
  }
406
- const attempts = typeof order.input.attempts === 'number' && order.input.attempts >= 1
407
- ? order.input.attempts
408
- : 1;
600
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
601
+ throw new Error(`insertWorkOrder: attempts must be a positive integer, got: ${attempts}`);
602
+ }
409
603
  const payload = JSON.stringify({ ...order.input, attempts });
410
- const now = Date.now();
604
+ const now = this.now();
411
605
  const result = this.db
412
606
  .prepare(`INSERT INTO operator_tasks
413
607
  (title, status, priority, kind, payload, source_channel, source_event_id,
@@ -416,6 +610,877 @@ class TaskLedger {
416
610
  .run(`workorder:${order.workKind}`, order.priority ?? 'normal', payload, channel, order.idempotencyKey, now, now);
417
611
  return this.getWorkOrderById(Number(result.lastInsertRowid));
418
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
+ }
419
1484
  /**
420
1485
  * Claim the next pending workorder: priority high>normal>low, then id ASC
421
1486
  * (plan D2/E2 - CASE mapping, never lexicographic on the TEXT enum).
@@ -436,7 +1501,7 @@ class TaskLedger {
436
1501
  if (row) {
437
1502
  this.db
438
1503
  .prepare(`UPDATE operator_tasks SET status = 'in_progress', updated_at = ? WHERE id = ?`)
439
- .run(Date.now(), row.id);
1504
+ .run(this.now(), row.id);
440
1505
  claimedId = row.id;
441
1506
  }
442
1507
  this.db.exec('COMMIT');
@@ -457,15 +1522,23 @@ class TaskLedger {
457
1522
  * unique index, hence one transaction, not two calls.
458
1523
  */
459
1524
  requeueWorkOrder(wo, reason) {
1525
+ if (wo.workKind === 'temporal') {
1526
+ return this.requeueTemporalWorkOrder(wo.id, reason);
1527
+ }
460
1528
  this.db.exec('BEGIN IMMEDIATE');
461
1529
  try {
462
- this.transitionWorkOrder(wo.id, 'failed', reason);
463
- const replacement = this.enqueueWorkOrder({
464
- workKind: wo.workKind,
465
- idempotencyKey: wo.idempotencyKey,
466
- input: { ...wo.payload, attempts: wo.payload.attempts + 1 },
467
- priority: wo.priority,
468
- });
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);
469
1542
  this.db.exec('COMMIT');
470
1543
  return replacement;
471
1544
  }
@@ -488,6 +1561,31 @@ class TaskLedger {
488
1561
  .get(...KNOWN_WORKORDER_CHANNELS);
489
1562
  return row.count;
490
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());
1575
+ return row.count;
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
+ }
491
1589
  /** In-progress system rows at boot = crash artifacts (single serial consumer). */
492
1590
  listStaleClaims() {
493
1591
  const rows = this.db
@@ -516,7 +1614,7 @@ class TaskLedger {
516
1614
  .prepare(`UPDATE operator_tasks
517
1615
  SET status = 'cancelled', latest_event = ?, updated_at = ?
518
1616
  WHERE kind = 'system' AND status IN ('pending','in_progress')${kindFilter}`)
519
- .run(reason, Date.now(), ...kindParams);
1617
+ .run(reason, this.now(), ...kindParams);
520
1618
  return result.changes;
521
1619
  }
522
1620
  /** Per-kind stats for the workorder_status surface. */
@@ -562,7 +1660,7 @@ class TaskLedger {
562
1660
  this.db
563
1661
  .prepare(`UPDATE operator_tasks SET status = ?, latest_event = COALESCE(?, latest_event),
564
1662
  updated_at = ? WHERE id = ?`)
565
- .run(status, reason, Date.now(), id);
1663
+ .run(status, reason, this.now(), id);
566
1664
  }
567
1665
  rowToWorkOrder(row) {
568
1666
  const workKind = (row.source_channel ?? '').slice(exports.WORKORDER_CHANNEL_PREFIX.length);
@@ -600,9 +1698,12 @@ class TaskLedger {
600
1698
  if (!scope || !reason) {
601
1699
  throw new Error('contract_no_update requires both scope and reason');
602
1700
  }
1701
+ return this.insertNoUpdateNote(scope, reason, this.now());
1702
+ }
1703
+ insertNoUpdateNote(scope, reason, createdAt) {
603
1704
  const result = this.db
604
1705
  .prepare(`INSERT INTO operator_no_update_notes (scope, reason, created_at) VALUES (?, ?, ?)`)
605
- .run(scope, reason, Date.now());
1706
+ .run(scope, reason, createdAt);
606
1707
  return { id: Number(result.lastInsertRowid) };
607
1708
  }
608
1709
  /** Max no-update note id, optionally scoped - the verifier's note snapshot. */