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