@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.
- package/CHANGELOG.md +8 -0
- package/README.md +9 -6
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +8 -0
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/code-act/constants.js +2 -2
- package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
- package/dist/agent/code-act/host-bridge.js +37 -4
- package/dist/agent/code-act/host-bridge.js.map +1 -1
- package/dist/agent/code-act/type-definition-generator.d.ts.map +1 -1
- package/dist/agent/code-act/type-definition-generator.js +2 -4
- package/dist/agent/code-act/type-definition-generator.js.map +1 -1
- package/dist/agent/context-compile-service.d.ts +2 -0
- package/dist/agent/context-compile-service.d.ts.map +1 -1
- package/dist/agent/context-compile-service.js +26 -7
- package/dist/agent/context-compile-service.js.map +1 -1
- package/dist/agent/gateway-tool-executor.d.ts +2 -0
- package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
- package/dist/agent/gateway-tool-executor.js +312 -18
- package/dist/agent/gateway-tool-executor.js.map +1 -1
- package/dist/agent/gateway-tools.md +4 -3
- package/dist/agent/tool-registry.d.ts.map +1 -1
- package/dist/agent/tool-registry.js +9 -3
- package/dist/agent/tool-registry.js.map +1 -1
- package/dist/agent/types.d.ts +27 -6
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/api/operator-tasks-handler.d.ts.map +1 -1
- package/dist/api/operator-tasks-handler.js +23 -1
- package/dist/api/operator-tasks-handler.js.map +1 -1
- package/dist/cli/commands/start.d.ts +7 -3
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +113 -65
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/runtime/agent-loop-init.d.ts +2 -0
- package/dist/cli/runtime/agent-loop-init.d.ts.map +1 -1
- package/dist/cli/runtime/agent-loop-init.js +37 -12
- package/dist/cli/runtime/agent-loop-init.js.map +1 -1
- package/dist/cli/runtime/api-routes-init.js +3 -3
- package/dist/cli/runtime/api-routes-init.js.map +1 -1
- package/dist/cli/runtime/temporal-init.d.ts +28 -0
- package/dist/cli/runtime/temporal-init.d.ts.map +1 -0
- package/dist/cli/runtime/temporal-init.js +74 -0
- package/dist/cli/runtime/temporal-init.js.map +1 -0
- package/dist/db/migrations/operator-task-temporal.d.ts +8 -0
- package/dist/db/migrations/operator-task-temporal.d.ts.map +1 -0
- package/dist/db/migrations/operator-task-temporal.js +209 -0
- package/dist/db/migrations/operator-task-temporal.js.map +1 -0
- package/dist/envelope/enforcer.d.ts.map +1 -1
- package/dist/envelope/enforcer.js +6 -6
- package/dist/envelope/enforcer.js.map +1 -1
- package/dist/multi-agent/dashboard-agent-persona.d.ts.map +1 -1
- package/dist/multi-agent/dashboard-agent-persona.js +9 -4
- package/dist/multi-agent/dashboard-agent-persona.js.map +1 -1
- package/dist/operator/action-verifier.d.ts +23 -0
- package/dist/operator/action-verifier.d.ts.map +1 -1
- package/dist/operator/action-verifier.js +76 -0
- package/dist/operator/action-verifier.js.map +1 -1
- package/dist/operator/board-reconcile.d.ts.map +1 -1
- package/dist/operator/board-reconcile.js +3 -0
- package/dist/operator/board-reconcile.js.map +1 -1
- package/dist/operator/board-slot-instructions.d.ts +3 -3
- package/dist/operator/board-slot-instructions.d.ts.map +1 -1
- package/dist/operator/board-slot-instructions.js +17 -8
- package/dist/operator/board-slot-instructions.js.map +1 -1
- package/dist/operator/briefs.d.ts.map +1 -1
- package/dist/operator/briefs.js +3 -0
- package/dist/operator/briefs.js.map +1 -1
- package/dist/operator/task-ledger.d.ts +134 -8
- package/dist/operator/task-ledger.d.ts.map +1 -1
- package/dist/operator/task-ledger.js +1166 -65
- package/dist/operator/task-ledger.js.map +1 -1
- package/dist/operator/task-temporal.d.ts +21 -0
- package/dist/operator/task-temporal.d.ts.map +1 -0
- package/dist/operator/task-temporal.js +111 -0
- package/dist/operator/task-temporal.js.map +1 -0
- package/dist/operator/temporal-effect.d.ts +62 -0
- package/dist/operator/temporal-effect.d.ts.map +1 -0
- package/dist/operator/temporal-effect.js +71 -0
- package/dist/operator/temporal-effect.js.map +1 -0
- package/dist/operator/temporal-reconcile.d.ts +48 -0
- package/dist/operator/temporal-reconcile.d.ts.map +1 -0
- package/dist/operator/temporal-reconcile.js +215 -0
- package/dist/operator/temporal-reconcile.js.map +1 -0
- package/dist/operator/temporal-runtime.d.ts +54 -0
- package/dist/operator/temporal-runtime.d.ts.map +1 -0
- package/dist/operator/temporal-runtime.js +110 -0
- package/dist/operator/temporal-runtime.js.map +1 -0
- package/dist/operator/temporal-worker.d.ts +19 -0
- package/dist/operator/temporal-worker.d.ts.map +1 -0
- package/dist/operator/temporal-worker.js +105 -0
- package/dist/operator/temporal-worker.js.map +1 -0
- package/dist/operator/worker-run.d.ts +6 -2
- package/dist/operator/worker-run.d.ts.map +1 -1
- package/dist/operator/worker-run.js +14 -0
- package/dist/operator/worker-run.js.map +1 -1
- package/dist/operator/workorder-consumer.d.ts +27 -10
- package/dist/operator/workorder-consumer.d.ts.map +1 -1
- package/dist/operator/workorder-consumer.js +195 -14
- package/dist/operator/workorder-consumer.js.map +1 -1
- package/dist/operator/workorder-hooks.d.ts +3 -0
- package/dist/operator/workorder-hooks.d.ts.map +1 -1
- package/dist/operator/workorder-hooks.js +23 -0
- package/dist/operator/workorder-hooks.js.map +1 -1
- package/dist/operator/workorder-publishers.d.ts +10 -1
- package/dist/operator/workorder-publishers.d.ts.map +1 -1
- package/dist/operator/workorder-publishers.js +40 -3
- package/dist/operator/workorder-publishers.js.map +1 -1
- package/package.json +1 -1
- package/public/ui/assets/{index-OSgiwL24.js → index-BDXDGEV6.js} +15 -15
- package/public/ui/assets/index-po2tN0xq.css +1 -0
- package/public/ui/index.html +2 -2
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (operator-interfaces.ts) so the board projects
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
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(
|
|
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 ?
|
|
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
|
|
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
|
-
//
|
|
299
|
-
//
|
|
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,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 =
|
|
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(
|
|
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.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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. */
|