@dsh-enhanced/assistant-work-items 0.0.0-stage → 0.1.67
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/LICENSE +21 -0
- package/README.md +685 -2
- package/cordis.patch.yml +11 -0
- package/lib/acceptance-binding.d.ts +86 -0
- package/lib/acceptance-binding.d.ts.map +1 -0
- package/lib/acceptance-binding.js +250 -0
- package/lib/acceptance-binding.js.map +1 -0
- package/lib/admission.d.ts +95 -0
- package/lib/admission.d.ts.map +1 -0
- package/lib/admission.js +642 -0
- package/lib/admission.js.map +1 -0
- package/lib/authority.d.ts +99 -0
- package/lib/authority.d.ts.map +1 -0
- package/lib/authority.js +233 -0
- package/lib/authority.js.map +1 -0
- package/lib/config.d.ts +365 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +672 -0
- package/lib/config.js.map +1 -0
- package/lib/continuation-contract.d.ts +126 -0
- package/lib/continuation-contract.d.ts.map +1 -0
- package/lib/continuation-contract.js +109 -0
- package/lib/continuation-contract.js.map +1 -0
- package/lib/continuation-record.d.ts +32 -0
- package/lib/continuation-record.d.ts.map +1 -0
- package/lib/continuation-record.js +57 -0
- package/lib/continuation-record.js.map +1 -0
- package/lib/continuation.d.ts +89 -0
- package/lib/continuation.d.ts.map +1 -0
- package/lib/continuation.js +607 -0
- package/lib/continuation.js.map +1 -0
- package/lib/diagnosis-contract.d.ts +95 -0
- package/lib/diagnosis-contract.d.ts.map +1 -0
- package/lib/diagnosis-contract.js +167 -0
- package/lib/diagnosis-contract.js.map +1 -0
- package/lib/diagnosis.d.ts +70 -0
- package/lib/diagnosis.d.ts.map +1 -0
- package/lib/diagnosis.js +466 -0
- package/lib/diagnosis.js.map +1 -0
- package/lib/dispatch.d.ts +100 -0
- package/lib/dispatch.d.ts.map +1 -0
- package/lib/dispatch.js +317 -0
- package/lib/dispatch.js.map +1 -0
- package/lib/engineering-context.d.ts +86 -0
- package/lib/engineering-context.d.ts.map +1 -0
- package/lib/engineering-context.js +263 -0
- package/lib/engineering-context.js.map +1 -0
- package/lib/evaluation.d.ts +125 -0
- package/lib/evaluation.d.ts.map +1 -0
- package/lib/evaluation.js +295 -0
- package/lib/evaluation.js.map +1 -0
- package/lib/event-follow.d.ts +93 -0
- package/lib/event-follow.d.ts.map +1 -0
- package/lib/event-follow.js +150 -0
- package/lib/event-follow.js.map +1 -0
- package/lib/feedback-targets.d.ts +91 -0
- package/lib/feedback-targets.d.ts.map +1 -0
- package/lib/feedback-targets.js +362 -0
- package/lib/feedback-targets.js.map +1 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +25 -0
- package/lib/index.js.map +1 -0
- package/lib/intake-admission.d.ts +49 -0
- package/lib/intake-admission.d.ts.map +1 -0
- package/lib/intake-admission.js +100 -0
- package/lib/intake-admission.js.map +1 -0
- package/lib/intake-evaluation-runtime.d.ts +74 -0
- package/lib/intake-evaluation-runtime.d.ts.map +1 -0
- package/lib/intake-evaluation-runtime.js +343 -0
- package/lib/intake-evaluation-runtime.js.map +1 -0
- package/lib/intake-evaluation.d.ts +180 -0
- package/lib/intake-evaluation.d.ts.map +1 -0
- package/lib/intake-evaluation.js +440 -0
- package/lib/intake-evaluation.js.map +1 -0
- package/lib/intake.d.ts +118 -0
- package/lib/intake.d.ts.map +1 -0
- package/lib/intake.js +137 -0
- package/lib/intake.js.map +1 -0
- package/lib/ledger.d.ts +86 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +145 -0
- package/lib/ledger.js.map +1 -0
- package/lib/mandate.d.ts +45 -0
- package/lib/mandate.d.ts.map +1 -0
- package/lib/mandate.js +168 -0
- package/lib/mandate.js.map +1 -0
- package/lib/outcome.d.ts +137 -0
- package/lib/outcome.d.ts.map +1 -0
- package/lib/outcome.js +303 -0
- package/lib/outcome.js.map +1 -0
- package/lib/owner-access.d.ts +35 -0
- package/lib/owner-access.d.ts.map +1 -0
- package/lib/owner-access.js +152 -0
- package/lib/owner-access.js.map +1 -0
- package/lib/owner-continuation.d.ts +27 -0
- package/lib/owner-continuation.d.ts.map +1 -0
- package/lib/owner-continuation.js +131 -0
- package/lib/owner-continuation.js.map +1 -0
- package/lib/owner-recall.d.ts +85 -0
- package/lib/owner-recall.d.ts.map +1 -0
- package/lib/owner-recall.js +134 -0
- package/lib/owner-recall.js.map +1 -0
- package/lib/owner-replies.d.ts +44 -0
- package/lib/owner-replies.d.ts.map +1 -0
- package/lib/owner-replies.js +268 -0
- package/lib/owner-replies.js.map +1 -0
- package/lib/peers.d.ts +624 -0
- package/lib/peers.d.ts.map +1 -0
- package/lib/peers.js +10 -0
- package/lib/peers.js.map +1 -0
- package/lib/policy-notice.d.ts +31 -0
- package/lib/policy-notice.d.ts.map +1 -0
- package/lib/policy-notice.js +137 -0
- package/lib/policy-notice.js.map +1 -0
- package/lib/reply-contract.d.ts +67 -0
- package/lib/reply-contract.d.ts.map +1 -0
- package/lib/reply-contract.js +58 -0
- package/lib/reply-contract.js.map +1 -0
- package/lib/service.d.ts +632 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +2447 -0
- package/lib/service.js.map +1 -0
- package/lib/settlement.d.ts +74 -0
- package/lib/settlement.d.ts.map +1 -0
- package/lib/settlement.js +233 -0
- package/lib/settlement.js.map +1 -0
- package/lib/signals.d.ts +176 -0
- package/lib/signals.d.ts.map +1 -0
- package/lib/signals.js +497 -0
- package/lib/signals.js.map +1 -0
- package/lib/store.d.ts +791 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +3266 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts +7 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +443 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +831 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +29 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/lib/workspace.d.ts +43 -0
- package/lib/workspace.d.ts.map +1 -0
- package/lib/workspace.js +137 -0
- package/lib/workspace.js.map +1 -0
- package/package.json +101 -3
package/lib/store.js
ADDED
|
@@ -0,0 +1,3266 @@
|
|
|
1
|
+
import { chmodSync, closeSync, constants, lstatSync, mkdirSync, openSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { acceptanceCanonicalJson } from '@dsh-enhanced/task-acceptance-contract';
|
|
5
|
+
import { continuationUtf8, validateContinuationInput } from './continuation-record.js';
|
|
6
|
+
import { canonicalDiagnosis, diagnosisPlanMatchesItem, MAX_DIAGNOSIS_EVENT_BYTES, parseDiagnosisPlan, strictDiagnosisJson } from './diagnosis-contract.js';
|
|
7
|
+
import { deriveReportOutcome, deriveRetryExhaustedOutcome, deriveWithdrawalOutcome, expiredOutcome, observedOutcome, threadClosureOutcome, } from "./outcome.js";
|
|
8
|
+
import { truncateUtf8 } from "./config.js";
|
|
9
|
+
import { acceptanceBindingIsUsable } from "./acceptance-binding.js";
|
|
10
|
+
import { CLAIMABLE_STATES, OPEN_STATES, TERMINAL_REPORT_STATUSES, TERMINAL_STATES, WorkItemError } from "./types.js";
|
|
11
|
+
const SCHEMA_VERSION = 13;
|
|
12
|
+
const WORK_ITEM_TABLE_NAMES = ['work_item', 'work_item_claim', 'work_item_continuation', 'work_item_dispatch', 'work_item_event', 'work_item_follow', 'work_item_intake', 'work_item_intake_admission', 'work_item_intake_evaluation', 'work_item_outcome', 'work_item_report', 'work_item_schema_meta'];
|
|
13
|
+
const CONTINUATION_TABLE = `CREATE TABLE work_item_continuation (
|
|
14
|
+
item_id TEXT PRIMARY KEY REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
15
|
+
parent_item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
16
|
+
root_item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
17
|
+
source_key TEXT NOT NULL UNIQUE, source_digest TEXT NOT NULL,
|
|
18
|
+
input_text BLOB NOT NULL, input_sha256 TEXT NOT NULL,
|
|
19
|
+
parent_report_revision INTEGER, parent_outcome_revision INTEGER,
|
|
20
|
+
plan_json BLOB NOT NULL, plan_digest TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
21
|
+
FOREIGN KEY(parent_item_id,parent_report_revision) REFERENCES work_item_report(item_id,revision),
|
|
22
|
+
FOREIGN KEY(parent_item_id,parent_outcome_revision) REFERENCES work_item_outcome(item_id,revision)
|
|
23
|
+
) STRICT, WITHOUT ROWID`;
|
|
24
|
+
const CONTINUATION_TRIGGERS = [
|
|
25
|
+
['work_item_continuation_no_update', `CREATE TRIGGER work_item_continuation_no_update BEFORE UPDATE ON work_item_continuation BEGIN SELECT RAISE(ABORT, 'continuation is append-only'); END`],
|
|
26
|
+
['work_item_continuation_no_delete', `CREATE TRIGGER work_item_continuation_no_delete BEFORE DELETE ON work_item_continuation BEGIN SELECT RAISE(ABORT, 'continuation is append-only'); END`],
|
|
27
|
+
];
|
|
28
|
+
const CONTINUATION_SCHEMA = `${CONTINUATION_TABLE};\n${CONTINUATION_TRIGGERS.map(([, sql]) => sql + ';').join('\n')}`;
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic intake ledger (schema 10). One append-only row per
|
|
31
|
+
* `(source, source_event_id)`: decision, reason, Host-computed strength and
|
|
32
|
+
* normalized boolean signals. No body, title or sender identity column exists;
|
|
33
|
+
* content is represented at most by a caller-computed SHA-256 digest.
|
|
34
|
+
*/
|
|
35
|
+
const INTAKE_TABLE = `CREATE TABLE work_item_intake (
|
|
36
|
+
source TEXT NOT NULL, source_event_id TEXT NOT NULL,
|
|
37
|
+
kind TEXT NOT NULL CHECK(kind IN ('message', 'event')),
|
|
38
|
+
decision TEXT NOT NULL CHECK(decision IN ('stored', 'duplicate', 'filtered')),
|
|
39
|
+
reason TEXT NOT NULL,
|
|
40
|
+
strength TEXT NOT NULL CHECK(strength IN ('explicit', 'inferred', 'ignore')),
|
|
41
|
+
strength_reason TEXT NOT NULL,
|
|
42
|
+
signals TEXT NOT NULL,
|
|
43
|
+
content_digest TEXT, metadata_digest TEXT NOT NULL,
|
|
44
|
+
occurred_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL,
|
|
45
|
+
PRIMARY KEY(source, source_event_id)
|
|
46
|
+
) STRICT, WITHOUT ROWID`;
|
|
47
|
+
const INTAKE_CONTENT_INDEX = `CREATE INDEX work_item_intake_content_idx
|
|
48
|
+
ON work_item_intake(source, content_digest) WHERE decision = 'stored' AND content_digest IS NOT NULL`;
|
|
49
|
+
const INTAKE_TRIGGERS = [
|
|
50
|
+
['work_item_intake_no_update', `CREATE TRIGGER work_item_intake_no_update BEFORE UPDATE ON work_item_intake BEGIN SELECT RAISE(ABORT, 'intake is append-only'); END`],
|
|
51
|
+
['work_item_intake_no_delete', `CREATE TRIGGER work_item_intake_no_delete BEFORE DELETE ON work_item_intake BEGIN SELECT RAISE(ABORT, 'intake is append-only'); END`],
|
|
52
|
+
];
|
|
53
|
+
const INTAKE_SCHEMA = `${INTAKE_TABLE};\n${INTAKE_CONTENT_INDEX};\n${INTAKE_TRIGGERS.map(([, sql]) => sql + ';').join('\n')}`;
|
|
54
|
+
/**
|
|
55
|
+
* Inferred-candidate evaluation results (schema 11). Append-only, one row per
|
|
56
|
+
* `(intake row, rule, Policy version)`. Quotes are UTF-16 spans into the
|
|
57
|
+
* authenticated text, never the text itself.
|
|
58
|
+
*/
|
|
59
|
+
const INTAKE_EVALUATION_TABLE = `CREATE TABLE work_item_intake_evaluation (
|
|
60
|
+
source TEXT NOT NULL, source_event_id TEXT NOT NULL,
|
|
61
|
+
rule_id TEXT NOT NULL, policy_version TEXT NOT NULL,
|
|
62
|
+
source_digest TEXT NOT NULL,
|
|
63
|
+
mode TEXT NOT NULL CHECK(mode IN ('observe', 'auto')),
|
|
64
|
+
status TEXT NOT NULL CHECK(status IN ('decided', 'unavailable')),
|
|
65
|
+
level TEXT NOT NULL CHECK(level IN ('ignore', 'digest', 'notify', 'accept')),
|
|
66
|
+
proposed_level TEXT CHECK(proposed_level IN ('ignore', 'digest', 'notify', 'accept')),
|
|
67
|
+
ceiling TEXT NOT NULL CHECK(ceiling IN ('ignore', 'digest', 'notify', 'accept')),
|
|
68
|
+
quote_spans TEXT NOT NULL, reasons TEXT NOT NULL,
|
|
69
|
+
would_accept INTEGER NOT NULL CHECK(would_accept IN (0, 1)),
|
|
70
|
+
attempts INTEGER NOT NULL, decided_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL,
|
|
71
|
+
PRIMARY KEY(source, source_event_id, rule_id, policy_version),
|
|
72
|
+
FOREIGN KEY(source, source_event_id) REFERENCES work_item_intake(source, source_event_id)
|
|
73
|
+
) STRICT, WITHOUT ROWID`;
|
|
74
|
+
const INTAKE_EVALUATION_TRIGGERS = [
|
|
75
|
+
['work_item_intake_evaluation_no_update', `CREATE TRIGGER work_item_intake_evaluation_no_update BEFORE UPDATE ON work_item_intake_evaluation BEGIN SELECT RAISE(ABORT, 'intake evaluation is append-only'); END`],
|
|
76
|
+
['work_item_intake_evaluation_no_delete', `CREATE TRIGGER work_item_intake_evaluation_no_delete BEFORE DELETE ON work_item_intake_evaluation BEGIN SELECT RAISE(ABORT, 'intake evaluation is append-only'); END`],
|
|
77
|
+
];
|
|
78
|
+
const INTAKE_EVALUATION_SCHEMA = `${INTAKE_EVALUATION_TABLE};\n${INTAKE_EVALUATION_TRIGGERS.map(([, sql]) => sql + ';').join('\n')}`;
|
|
79
|
+
/**
|
|
80
|
+
* Admission outcome of one accepting evaluation (schema 12). Append-only, one
|
|
81
|
+
* row per evaluation row: `observe` records only `would-admit`; `auto` records
|
|
82
|
+
* the `admitDecision` outcome, a refusal with its reason code, and the item.
|
|
83
|
+
*/
|
|
84
|
+
const INTAKE_ADMISSION_TABLE = `CREATE TABLE work_item_intake_admission (
|
|
85
|
+
source TEXT NOT NULL, source_event_id TEXT NOT NULL,
|
|
86
|
+
rule_id TEXT NOT NULL, policy_version TEXT NOT NULL,
|
|
87
|
+
mode TEXT NOT NULL CHECK(mode IN ('observe', 'auto')),
|
|
88
|
+
outcome TEXT NOT NULL CHECK(outcome IN ('would-admit', 'admitted', 'replayed', 'refused')),
|
|
89
|
+
reason_code TEXT, decision_id TEXT NOT NULL,
|
|
90
|
+
item_id TEXT REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
91
|
+
recorded_at INTEGER NOT NULL,
|
|
92
|
+
CHECK((mode = 'observe') = (outcome = 'would-admit')),
|
|
93
|
+
CHECK((outcome = 'refused') = (reason_code IS NOT NULL)),
|
|
94
|
+
CHECK((outcome IN ('admitted', 'replayed')) = (item_id IS NOT NULL)),
|
|
95
|
+
PRIMARY KEY(source, source_event_id, rule_id, policy_version),
|
|
96
|
+
FOREIGN KEY(source, source_event_id, rule_id, policy_version)
|
|
97
|
+
REFERENCES work_item_intake_evaluation(source, source_event_id, rule_id, policy_version)
|
|
98
|
+
) STRICT, WITHOUT ROWID`;
|
|
99
|
+
const INTAKE_ADMISSION_ADMITTED_INDEX = `CREATE INDEX work_item_intake_admission_admitted_idx
|
|
100
|
+
ON work_item_intake_admission(recorded_at) WHERE outcome = 'admitted'`;
|
|
101
|
+
/**
|
|
102
|
+
* Item lookup for `intakeProvenance` (schema 13). Partial: only rows that name
|
|
103
|
+
* an item (`admitted`/`replayed`) are indexed; it also backs the
|
|
104
|
+
* `item_id REFERENCES work_item(id)` foreign key.
|
|
105
|
+
*/
|
|
106
|
+
const INTAKE_ADMISSION_ITEM_INDEX = `CREATE INDEX work_item_intake_admission_item_idx
|
|
107
|
+
ON work_item_intake_admission(item_id) WHERE item_id IS NOT NULL`;
|
|
108
|
+
const INTAKE_ADMISSION_TRIGGERS = [
|
|
109
|
+
['work_item_intake_admission_no_update', `CREATE TRIGGER work_item_intake_admission_no_update BEFORE UPDATE ON work_item_intake_admission BEGIN SELECT RAISE(ABORT, 'intake admission is append-only'); END`],
|
|
110
|
+
['work_item_intake_admission_no_delete', `CREATE TRIGGER work_item_intake_admission_no_delete BEFORE DELETE ON work_item_intake_admission BEGIN SELECT RAISE(ABORT, 'intake admission is append-only'); END`],
|
|
111
|
+
];
|
|
112
|
+
const INTAKE_ADMISSION_SCHEMA = `${INTAKE_ADMISSION_TABLE};\n${INTAKE_ADMISSION_ADMITTED_INDEX};\n${INTAKE_ADMISSION_TRIGGERS.map(([, sql]) => sql + ';').join('\n')}`;
|
|
113
|
+
const EVALUATION_LEVELS = ['ignore', 'digest', 'notify', 'accept'];
|
|
114
|
+
/** Only TEXT fields represented in continuationItemBasis; body/display text is excluded. */
|
|
115
|
+
const CONTINUATION_ITEM_TEXT_COLUMNS = ['id', 'dedup_key', 'source_id', 'kind', 'external_id', 'origin_binding_id', 'workspace', 'agent_preset',
|
|
116
|
+
'principal_id', 'principal_record_id', 'owner_route_id', 'worker_automation_id', 'authority_basis', 'work_dir'];
|
|
117
|
+
const WAITING_RESUME_CURSOR = 'scheduler.waiting-resume.cursor';
|
|
118
|
+
const WAITING_RESUME_DUE_INDEX = `CREATE INDEX work_item_dispatch_waiting_due_idx
|
|
119
|
+
ON work_item_dispatch(occurred_at, item_id) WHERE state = 'pending' AND reason = 'waiting-resume'`;
|
|
120
|
+
const ACTIVE_CLAIM_ITEM_INDEX = `CREATE INDEX work_item_claim_active_item_idx
|
|
121
|
+
ON work_item_claim(item_id) WHERE state = 'active'`;
|
|
122
|
+
/** Waiting reasons that mean an admission mandate failed revalidation. */
|
|
123
|
+
const AUTHORITY_REVOKED = 'authority-revoked';
|
|
124
|
+
const AUTHORITY_UNAVAILABLE = 'authority-unavailable';
|
|
125
|
+
/** Ambiguous wake failures one generation may retry before the gap stays visible. */
|
|
126
|
+
const MAX_UNKNOWN_WAKE_ATTEMPTS = 1;
|
|
127
|
+
/** Default bounded settlement window: seven days. */
|
|
128
|
+
const DEFAULT_SETTLE_WINDOW_MS = 604_800_000;
|
|
129
|
+
/** Newest prior attempts one resume brief may carry. */
|
|
130
|
+
const RESUME_ATTEMPT_LIMIT = 5;
|
|
131
|
+
/** Newest prior report summaries one resume brief may carry. */
|
|
132
|
+
const RESUME_REPORT_LIMIT = 5;
|
|
133
|
+
/** UTF-8 byte budget for one resume report summary. */
|
|
134
|
+
const RESUME_SUMMARY_BYTES = 512;
|
|
135
|
+
/** Hard row bound for the resume event scan; the newest events win. */
|
|
136
|
+
const RESUME_EVENT_SCAN_LIMIT = 500;
|
|
137
|
+
const MEMORY_DATABASE = ':memory:';
|
|
138
|
+
/** Bound parameters for `state IN (...)`, derived from the single state list. */
|
|
139
|
+
const OPEN_STATE_PLACEHOLDERS = OPEN_STATES.map(() => '?').join(', ');
|
|
140
|
+
/**
|
|
141
|
+
* Append-only outcome ledger.
|
|
142
|
+
*
|
|
143
|
+
* One row per outcome revision; a later observation of the same item appends a
|
|
144
|
+
* wider revision instead of rewriting the earlier one, so a `ready` history is
|
|
145
|
+
* never laundered into a success. The primary key already orders by item and
|
|
146
|
+
* revision, so the only extra indexes support finding pending windows by
|
|
147
|
+
* deadline and replaying outcomes that still owe one trusted Evaluation append.
|
|
148
|
+
*/
|
|
149
|
+
const OUTCOME_SCHEMA = `CREATE TABLE work_item_outcome (
|
|
150
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
151
|
+
revision INTEGER NOT NULL,
|
|
152
|
+
settled_by TEXT NOT NULL CHECK(settled_by IN ('report-ready', 'report-blocked', 'report-failed', 'withdrawn', 'retry-exhausted', 'observed-evidence', 'window-expired', 'closed-by-thread')),
|
|
153
|
+
execution_status TEXT NOT NULL CHECK(execution_status IN ('succeeded', 'failed', 'timed-out', 'cancelled', 'unknown')),
|
|
154
|
+
objective_status TEXT NOT NULL CHECK(objective_status IN ('achieved', 'partial', 'not-achieved', 'unknown')),
|
|
155
|
+
trust TEXT NOT NULL CHECK(trust IN ('agent-self-report', 'external-receipt', 'deterministic-check')),
|
|
156
|
+
evidence_state TEXT NOT NULL CHECK(evidence_state IN ('pending', 'observed', 'unverified', 'not-applicable')),
|
|
157
|
+
evidence_reason TEXT CHECK(evidence_reason IN ('no-independent-source', 'evidence-too-large', 'window-expired', 'channel-unavailable', 'closed-by-thread')),
|
|
158
|
+
evidence TEXT, baseline_sequence INTEGER, inbox_id TEXT, authority_hash TEXT, deadline_at INTEGER,
|
|
159
|
+
settled_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL,
|
|
160
|
+
evaluation_state TEXT NOT NULL DEFAULT 'pending' CHECK(evaluation_state IN ('pending', 'exported', 'skipped')),
|
|
161
|
+
evaluation_digest TEXT, evaluation_recorded_at INTEGER,
|
|
162
|
+
evaluation_attempts INTEGER NOT NULL DEFAULT 0,
|
|
163
|
+
PRIMARY KEY(item_id, revision)
|
|
164
|
+
) STRICT, WITHOUT ROWID;
|
|
165
|
+
CREATE INDEX work_item_outcome_pending_idx ON work_item_outcome(evidence_state, deadline_at);
|
|
166
|
+
CREATE INDEX work_item_outcome_evaluation_idx ON work_item_outcome(evaluation_state, evidence_state);
|
|
167
|
+
`;
|
|
168
|
+
/**
|
|
169
|
+
* Frozen v3 shape of the outcome ledger. The v3 upgrade block must keep creating
|
|
170
|
+
* exactly this table because later version blocks add their own columns; reusing
|
|
171
|
+
* the current definition there would duplicate those columns on a v1/v2 upgrade.
|
|
172
|
+
*/
|
|
173
|
+
const OUTCOME_SCHEMA_V3 = `CREATE TABLE work_item_outcome (
|
|
174
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
175
|
+
revision INTEGER NOT NULL,
|
|
176
|
+
settled_by TEXT NOT NULL CHECK(settled_by IN ('report-ready', 'report-blocked', 'report-failed', 'withdrawn', 'retry-exhausted', 'observed-evidence', 'window-expired')),
|
|
177
|
+
execution_status TEXT NOT NULL CHECK(execution_status IN ('succeeded', 'failed', 'timed-out', 'cancelled', 'unknown')),
|
|
178
|
+
objective_status TEXT NOT NULL CHECK(objective_status IN ('achieved', 'partial', 'not-achieved', 'unknown')),
|
|
179
|
+
trust TEXT NOT NULL CHECK(trust IN ('agent-self-report', 'external-receipt', 'deterministic-check')),
|
|
180
|
+
evidence_state TEXT NOT NULL CHECK(evidence_state IN ('pending', 'observed', 'unverified', 'not-applicable')),
|
|
181
|
+
evidence_reason TEXT CHECK(evidence_reason IN ('no-independent-source', 'evidence-too-large', 'window-expired', 'channel-unavailable')),
|
|
182
|
+
evidence TEXT, baseline_sequence INTEGER, inbox_id TEXT, authority_hash TEXT, deadline_at INTEGER,
|
|
183
|
+
settled_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL,
|
|
184
|
+
PRIMARY KEY(item_id, revision)
|
|
185
|
+
) STRICT, WITHOUT ROWID;
|
|
186
|
+
CREATE INDEX work_item_outcome_pending_idx ON work_item_outcome(evidence_state, deadline_at);
|
|
187
|
+
`;
|
|
188
|
+
/**
|
|
189
|
+
* Followed external threads whose closure bounds an item's settlement wait.
|
|
190
|
+
*
|
|
191
|
+
* A row is not authority: it records which host-registered source observed
|
|
192
|
+
* which thread, at which registration generation. The table is append-only in
|
|
193
|
+
* practice (`open` -> `closed` once, CAS-guarded), so a duplicate or
|
|
194
|
+
* out-of-order closure can never reopen or double-settle an item.
|
|
195
|
+
*/
|
|
196
|
+
const FOLLOW_SCHEMA = `CREATE TABLE work_item_follow (
|
|
197
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
198
|
+
position INTEGER NOT NULL,
|
|
199
|
+
kind TEXT NOT NULL,
|
|
200
|
+
ref TEXT NOT NULL,
|
|
201
|
+
state TEXT NOT NULL CHECK(state IN ('open', 'closed')),
|
|
202
|
+
outcome TEXT CHECK(outcome IN ('succeeded', 'failed', 'cancelled', 'unknown')),
|
|
203
|
+
digest TEXT,
|
|
204
|
+
source TEXT NOT NULL,
|
|
205
|
+
source_generation TEXT NOT NULL,
|
|
206
|
+
opened_at INTEGER NOT NULL,
|
|
207
|
+
closed_at INTEGER,
|
|
208
|
+
CHECK((state = 'open' AND outcome IS NULL AND digest IS NULL AND closed_at IS NULL)
|
|
209
|
+
OR (state = 'closed' AND outcome IS NOT NULL AND digest IS NOT NULL AND closed_at IS NOT NULL)),
|
|
210
|
+
PRIMARY KEY(item_id, position)
|
|
211
|
+
) STRICT, WITHOUT ROWID;
|
|
212
|
+
CREATE INDEX work_item_follow_state_idx ON work_item_follow(state, source);
|
|
213
|
+
`;
|
|
214
|
+
/** Every durable item column, in insert order. */
|
|
215
|
+
const ITEM_COLUMNS = [
|
|
216
|
+
'id', 'dedup_key', 'dedup_bucket', 'source_id', 'kind', 'external_id', 'title', 'brief', 'brief_truncated',
|
|
217
|
+
'origin_binding_id', 'acceptance_ref', 'workspace', 'agent_preset', 'principal_id', 'principal_record_id',
|
|
218
|
+
'principal_version', 'binding_version', 'owner_generation', 'owner_route_id', 'scope_expires_at', 'worker_automation_id',
|
|
219
|
+
'state', 'state_reason', 'attempt', 'max_attempts', 'lease_until', 'lease_run_id', 'occurrence_id', 'work_dir',
|
|
220
|
+
'last_report_status', 'observed', 'created_at', 'updated_at',
|
|
221
|
+
'authority_basis', 'authority_checked_at',
|
|
222
|
+
];
|
|
223
|
+
/** Same columns projected with camelCase aliases for row decoding. */
|
|
224
|
+
const ITEM_ALIASES = ITEM_COLUMNS.map(column => {
|
|
225
|
+
const name = column.replace(/_([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
226
|
+
return name === column ? column : `${column} AS ${name}`;
|
|
227
|
+
}).join(', ');
|
|
228
|
+
const INSERT_ITEM = `INSERT INTO work_item(${ITEM_COLUMNS.join(', ')}) VALUES (${ITEM_COLUMNS.map(() => '?').join(', ')})`;
|
|
229
|
+
const REPORTABLE_STATES = ['claimed', 'working', 'waiting'];
|
|
230
|
+
/** Deferred terminal report statuses, the only ones a follow can defer. */
|
|
231
|
+
const DEFERRED_REPORT_STATUSES = ['ready', 'blocked', 'failed'];
|
|
232
|
+
/** One validated follow, already resolved to its registered source at admission. */
|
|
233
|
+
/** Append-only event kinds for WP21 notices and follow-up ownership (no schema change). */
|
|
234
|
+
export const POLICY_NOTICE_EVENT = 'policy-change-noticed';
|
|
235
|
+
export const FOLLOW_UP_OWNED_EVENT = 'follow-up-owned';
|
|
236
|
+
export const FOLLOW_UP_PROCESSED_EVENT = 'follow-up-processed';
|
|
237
|
+
export const FOLLOW_UP_RELEASED_EVENT = 'follow-up-released';
|
|
238
|
+
/** WP21 slice 4: the independent verifier's verdict, read back once per bound item. */
|
|
239
|
+
export const ACCEPTANCE_VERIFIED_EVENT = 'acceptance-verified';
|
|
240
|
+
/** WP21 slice 6: a worker run's contract bound after wake; the latest row is current. */
|
|
241
|
+
export const ACCEPTANCE_BOUND_EVENT = 'acceptance-bound';
|
|
242
|
+
/** Message of the fail-closed storage error for a malformed latest `acceptance-bound` row. */
|
|
243
|
+
export const MALFORMED_ACCEPTANCE_BINDING = 'recorded acceptance binding is malformed';
|
|
244
|
+
/**
|
|
245
|
+
* Deterministic external event identity for one dispatch generation.
|
|
246
|
+
*
|
|
247
|
+
* Generation 0 is the admission identity and equals the item id, so existing
|
|
248
|
+
* rows keep their exact `(event_id, occurred_at)` pair. Later generations append
|
|
249
|
+
* a suffix, because the automation intake dedupes occurrences by
|
|
250
|
+
* `(automationId, eventId, occurredAt)` and would replay a completed occurrence
|
|
251
|
+
* instead of starting a new run.
|
|
252
|
+
*/
|
|
253
|
+
export function dispatchEventId(itemId, generation) {
|
|
254
|
+
return generation === 0 ? itemId : `${itemId}#${String(generation)}`;
|
|
255
|
+
}
|
|
256
|
+
function fail(code, message, detail) {
|
|
257
|
+
throw new WorkItemError(code, message, detail);
|
|
258
|
+
}
|
|
259
|
+
function privateFile(path) {
|
|
260
|
+
const stat = lstatSync(path);
|
|
261
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (process.getuid?.() !== undefined && stat.uid !== process.getuid?.())) {
|
|
262
|
+
fail('storage', 'work-item database file is not private');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function privateDirectory(path) {
|
|
266
|
+
const stat = lstatSync(path);
|
|
267
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0 || (process.getuid?.() !== undefined && stat.uid !== process.getuid?.())) {
|
|
268
|
+
fail('storage', 'work-item database directory is not private');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Create a mode-0700 directory and prove it is not a link or group-writable. */
|
|
272
|
+
export function preparePrivateDirectory(path) {
|
|
273
|
+
if (!isAbsolute(path))
|
|
274
|
+
fail('storage', 'private directory path must be absolute');
|
|
275
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
276
|
+
privateDirectory(path);
|
|
277
|
+
chmodSync(path, 0o700);
|
|
278
|
+
}
|
|
279
|
+
/** Create the SQLite file with mode 0600 before SQLite opens it. */
|
|
280
|
+
export function prepareDatabaseFile(path) {
|
|
281
|
+
if (!isAbsolute(path))
|
|
282
|
+
fail('storage', 'database path must be absolute');
|
|
283
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
284
|
+
privateDirectory(dirname(path));
|
|
285
|
+
let present = false;
|
|
286
|
+
try {
|
|
287
|
+
lstatSync(path);
|
|
288
|
+
present = true;
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
|
|
292
|
+
fail('storage', 'database path is unsafe');
|
|
293
|
+
}
|
|
294
|
+
if (!present) {
|
|
295
|
+
const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
296
|
+
closeSync(descriptor);
|
|
297
|
+
chmodSync(path, 0o600);
|
|
298
|
+
}
|
|
299
|
+
privateFile(path);
|
|
300
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
301
|
+
try {
|
|
302
|
+
privateFile(`${path}${suffix}`);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
306
|
+
continue;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// `validateSchema` reads the version before any write lock, so a concurrent
|
|
312
|
+
// opener may advance the schema while this one waits on `busy_timeout`. Each
|
|
313
|
+
// step re-reads `user_version` under its own `BEGIN IMMEDIATE` and runs its DDL
|
|
314
|
+
// only when the file is still at `from`; otherwise it rolls back and returns
|
|
315
|
+
// false so the caller re-validates the file as the other opener left it.
|
|
316
|
+
function migrateStep(database, from, sql) {
|
|
317
|
+
database.exec('BEGIN IMMEDIATE');
|
|
318
|
+
try {
|
|
319
|
+
if (database.prepare('PRAGMA user_version').get()['user_version'] !== from) {
|
|
320
|
+
database.exec('ROLLBACK');
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
database.exec(sql);
|
|
324
|
+
database.exec('COMMIT');
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (database.isTransaction)
|
|
329
|
+
database.exec('ROLLBACK');
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function validateSchema(database) {
|
|
334
|
+
// One read snapshot, so the version and the object list cannot straddle a
|
|
335
|
+
// concurrent opener's migration commit.
|
|
336
|
+
database.exec('BEGIN');
|
|
337
|
+
const version = database.prepare('PRAGMA user_version').get()['user_version'];
|
|
338
|
+
const objects = database.prepare("SELECT type, name FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name").all();
|
|
339
|
+
database.exec('COMMIT');
|
|
340
|
+
// Reject unowned objects before migrations can fire a trigger or update a
|
|
341
|
+
// foreign schema. `_` is literal here: sqliteXshadow is an application name.
|
|
342
|
+
if (version !== 0 && objects.some(row => String(row['type']) !== 'index'
|
|
343
|
+
&& !(String(row['type']) === 'trigger' && version >= 9 && CONTINUATION_TRIGGERS.some(([name]) => name === row['name']))
|
|
344
|
+
&& !(String(row['type']) === 'trigger' && version >= 10 && INTAKE_TRIGGERS.some(([name]) => name === row['name']))
|
|
345
|
+
&& !(String(row['type']) === 'trigger' && version >= 11 && INTAKE_EVALUATION_TRIGGERS.some(([name]) => name === row['name']))
|
|
346
|
+
&& !(String(row['type']) === 'trigger' && version >= 12 && INTAKE_ADMISSION_TRIGGERS.some(([name]) => name === row['name']))
|
|
347
|
+
&& (String(row['type']) !== 'table' || !WORK_ITEM_TABLE_NAMES.includes(String(row['name']))))) {
|
|
348
|
+
fail('storage', 'work-item database contains an unexpected schema object');
|
|
349
|
+
}
|
|
350
|
+
// Below 10 no intake object may exist yet; a pre-created one is drift, not
|
|
351
|
+
// something the additive upgrade may adopt or trip over mid-transaction.
|
|
352
|
+
if (version >= 1 && version < 10 && objects.some(row => String(row['name']).startsWith('work_item_intake'))) {
|
|
353
|
+
fail('storage', 'work-item database contains an intake object before schema 10');
|
|
354
|
+
}
|
|
355
|
+
if (version === 10 && objects.some(row => String(row['name']).startsWith('work_item_intake_evaluation'))) {
|
|
356
|
+
fail('storage', 'work-item database contains an intake evaluation object before schema 11');
|
|
357
|
+
}
|
|
358
|
+
if (version >= 10 && version <= 11 && objects.some(row => String(row['name']).startsWith('work_item_intake_admission'))) {
|
|
359
|
+
fail('storage', 'work-item database contains an intake admission object before schema 12');
|
|
360
|
+
}
|
|
361
|
+
if (version >= 1 && version <= 12 && objects.some(row => String(row['name']) === 'work_item_intake_admission_item_idx')) {
|
|
362
|
+
fail('storage', 'work-item database contains the intake admission item index before schema 13');
|
|
363
|
+
}
|
|
364
|
+
if (version === 0) {
|
|
365
|
+
if (objects.length !== 0)
|
|
366
|
+
fail('storage', 'new work-item database is not empty');
|
|
367
|
+
if (!migrateStep(database, 0, `
|
|
368
|
+
CREATE TABLE work_item (
|
|
369
|
+
id TEXT PRIMARY KEY, dedup_key TEXT NOT NULL, dedup_bucket INTEGER NOT NULL,
|
|
370
|
+
source_id TEXT NOT NULL, kind TEXT NOT NULL, external_id TEXT NOT NULL,
|
|
371
|
+
title TEXT NOT NULL, brief TEXT NOT NULL, brief_truncated INTEGER NOT NULL CHECK(brief_truncated IN (0, 1)),
|
|
372
|
+
origin_binding_id TEXT NOT NULL, acceptance_ref TEXT,
|
|
373
|
+
workspace TEXT NOT NULL, agent_preset TEXT NOT NULL,
|
|
374
|
+
principal_id TEXT NOT NULL, principal_record_id TEXT NOT NULL, principal_version INTEGER NOT NULL,
|
|
375
|
+
binding_version INTEGER NOT NULL, owner_generation INTEGER NOT NULL, owner_route_id TEXT NOT NULL,
|
|
376
|
+
scope_expires_at INTEGER NOT NULL,
|
|
377
|
+
worker_automation_id TEXT NOT NULL,
|
|
378
|
+
state TEXT NOT NULL CHECK(state IN ('queued', 'claimed', 'working', 'waiting', 'ready', 'blocked', 'failed', 'dropped')),
|
|
379
|
+
state_reason TEXT,
|
|
380
|
+
attempt INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
|
|
381
|
+
lease_until INTEGER, lease_run_id TEXT, occurrence_id TEXT,
|
|
382
|
+
work_dir TEXT NOT NULL,
|
|
383
|
+
last_report_status TEXT CHECK(last_report_status IN ('progress', 'waiting', 'ready', 'blocked', 'failed')),
|
|
384
|
+
observed INTEGER NOT NULL CHECK(observed IN (0, 1)),
|
|
385
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
386
|
+
authority_basis TEXT, authority_checked_at INTEGER,
|
|
387
|
+
UNIQUE(dedup_key, dedup_bucket)
|
|
388
|
+
) STRICT;
|
|
389
|
+
CREATE TABLE work_item_event (
|
|
390
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
391
|
+
sequence INTEGER NOT NULL, kind TEXT NOT NULL, actor TEXT NOT NULL,
|
|
392
|
+
detail TEXT, recorded_at INTEGER NOT NULL,
|
|
393
|
+
PRIMARY KEY(item_id, sequence)
|
|
394
|
+
) STRICT, WITHOUT ROWID;
|
|
395
|
+
CREATE TABLE work_item_claim (
|
|
396
|
+
claim_id INTEGER PRIMARY KEY,
|
|
397
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
398
|
+
run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL,
|
|
399
|
+
state TEXT NOT NULL CHECK(state IN ('active', 'completed', 'released', 'expired')),
|
|
400
|
+
leased_until INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
401
|
+
) STRICT;
|
|
402
|
+
CREATE INDEX work_item_claim_run_idx ON work_item_claim(run_id);
|
|
403
|
+
CREATE TABLE work_item_report (
|
|
404
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
405
|
+
revision INTEGER NOT NULL,
|
|
406
|
+
status TEXT NOT NULL CHECK(status IN ('progress', 'waiting', 'ready', 'blocked', 'failed')),
|
|
407
|
+
summary TEXT NOT NULL, truncated INTEGER NOT NULL CHECK(truncated IN (0, 1)),
|
|
408
|
+
artifact_paths TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE,
|
|
409
|
+
delivery_key TEXT NOT NULL UNIQUE,
|
|
410
|
+
delivery_state TEXT CHECK(delivery_state IN ('pending', 'sent', 'failed', 'unknown')),
|
|
411
|
+
delivery_id TEXT, recorded_at INTEGER NOT NULL,
|
|
412
|
+
PRIMARY KEY(item_id, revision)
|
|
413
|
+
) STRICT;
|
|
414
|
+
${OUTCOME_SCHEMA}
|
|
415
|
+
CREATE TABLE work_item_dispatch (
|
|
416
|
+
item_id TEXT PRIMARY KEY REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
417
|
+
automation_id TEXT NOT NULL, event_id TEXT NOT NULL,
|
|
418
|
+
occurred_at INTEGER NOT NULL,
|
|
419
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
420
|
+
wake_attempts INTEGER NOT NULL DEFAULT 0,
|
|
421
|
+
state TEXT NOT NULL CHECK(state IN ('pending', 'dispatched', 'failed')),
|
|
422
|
+
occurrence_id TEXT, reason TEXT,
|
|
423
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
424
|
+
UNIQUE(automation_id, event_id)
|
|
425
|
+
) STRICT;
|
|
426
|
+
${FOLLOW_SCHEMA}
|
|
427
|
+
${WAITING_RESUME_DUE_INDEX};
|
|
428
|
+
${ACTIVE_CLAIM_ITEM_INDEX};
|
|
429
|
+
CREATE TABLE work_item_schema_meta (
|
|
430
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
|
431
|
+
) STRICT, WITHOUT ROWID;
|
|
432
|
+
${CONTINUATION_SCHEMA};
|
|
433
|
+
${INTAKE_SCHEMA};
|
|
434
|
+
${INTAKE_EVALUATION_SCHEMA};
|
|
435
|
+
${INTAKE_ADMISSION_SCHEMA};
|
|
436
|
+
${INTAKE_ADMISSION_ITEM_INDEX};
|
|
437
|
+
INSERT INTO work_item_schema_meta(key, value) VALUES ('schema_version', '13');
|
|
438
|
+
PRAGMA user_version = 13;
|
|
439
|
+
`))
|
|
440
|
+
return validateSchema(database);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (version === 1) {
|
|
444
|
+
// A run may legitimately reclaim an expired lease, which needs a fresh claim
|
|
445
|
+
// row for the same run. Rebuild the claim table without the (item, run) key
|
|
446
|
+
// and add a surrogate `claim_id`; every existing row maps to a new id.
|
|
447
|
+
if (!migrateStep(database, 1, `
|
|
448
|
+
ALTER TABLE work_item_claim RENAME TO work_item_claim_v1;
|
|
449
|
+
CREATE TABLE work_item_claim (
|
|
450
|
+
claim_id INTEGER PRIMARY KEY,
|
|
451
|
+
item_id TEXT NOT NULL REFERENCES work_item(id) ON DELETE RESTRICT,
|
|
452
|
+
run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL,
|
|
453
|
+
state TEXT NOT NULL CHECK(state IN ('active', 'completed', 'released', 'expired')),
|
|
454
|
+
leased_until INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
455
|
+
) STRICT;
|
|
456
|
+
INSERT INTO work_item_claim(item_id, run_id, occurrence_id, state, leased_until, created_at, updated_at)
|
|
457
|
+
SELECT item_id, run_id, occurrence_id, state, leased_until, created_at, updated_at FROM work_item_claim_v1;
|
|
458
|
+
DROP TABLE work_item_claim_v1;
|
|
459
|
+
CREATE INDEX work_item_claim_run_idx ON work_item_claim(run_id);
|
|
460
|
+
PRAGMA user_version = 2;
|
|
461
|
+
`))
|
|
462
|
+
return validateSchema(database);
|
|
463
|
+
}
|
|
464
|
+
if (version === 1 || version === 2) {
|
|
465
|
+
// v3 adds the append-only outcome ledger. No existing table changes, so the
|
|
466
|
+
// upgrade is a pure table creation that keeps every stored report intact.
|
|
467
|
+
if (!migrateStep(database, 2, `
|
|
468
|
+
${OUTCOME_SCHEMA_V3}
|
|
469
|
+
UPDATE work_item_schema_meta SET value = '3' WHERE key = 'schema_version';
|
|
470
|
+
PRAGMA user_version = 3;
|
|
471
|
+
`))
|
|
472
|
+
return validateSchema(database);
|
|
473
|
+
}
|
|
474
|
+
if (version === 1 || version === 2 || version === 3) {
|
|
475
|
+
// v4 scopes a dispatch row to one delivery generation. Existing rows are
|
|
476
|
+
// generation 0, whose identity is exactly the stored `event_id` /
|
|
477
|
+
// `occurred_at`; no value is rewritten, so an in-flight wake stays valid.
|
|
478
|
+
if (!migrateStep(database, 3, `
|
|
479
|
+
ALTER TABLE work_item_dispatch ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
|
|
480
|
+
ALTER TABLE work_item_dispatch ADD COLUMN wake_attempts INTEGER NOT NULL DEFAULT 0;
|
|
481
|
+
UPDATE work_item_schema_meta SET value = '4' WHERE key = 'schema_version';
|
|
482
|
+
PRAGMA user_version = 4;
|
|
483
|
+
`))
|
|
484
|
+
return validateSchema(database);
|
|
485
|
+
}
|
|
486
|
+
if (version === 1 || version === 2 || version === 3 || version === 4) {
|
|
487
|
+
// v5 records the trusted Evaluation export state per settled outcome. Legacy
|
|
488
|
+
// rows default to `pending`, so an upgraded database replays exactly the
|
|
489
|
+
// settlements that have not reached Evaluation yet; no existing column is
|
|
490
|
+
// rewritten and no prior revision is dropped.
|
|
491
|
+
if (!migrateStep(database, 4, `
|
|
492
|
+
ALTER TABLE work_item_outcome ADD COLUMN evaluation_state TEXT NOT NULL DEFAULT 'pending' CHECK(evaluation_state IN ('pending', 'exported', 'skipped'));
|
|
493
|
+
ALTER TABLE work_item_outcome ADD COLUMN evaluation_digest TEXT;
|
|
494
|
+
ALTER TABLE work_item_outcome ADD COLUMN evaluation_recorded_at INTEGER;
|
|
495
|
+
ALTER TABLE work_item_outcome ADD COLUMN evaluation_attempts INTEGER NOT NULL DEFAULT 0;
|
|
496
|
+
CREATE INDEX work_item_outcome_evaluation_idx ON work_item_outcome(evaluation_state, evidence_state);
|
|
497
|
+
UPDATE work_item_schema_meta SET value = '5' WHERE key = 'schema_version';
|
|
498
|
+
PRAGMA user_version = 5;
|
|
499
|
+
`))
|
|
500
|
+
return validateSchema(database);
|
|
501
|
+
}
|
|
502
|
+
if (version === 1 || version === 2 || version === 3 || version === 4 || version === 5) {
|
|
503
|
+
// v6 adds followed-thread settlement. Two of its enum values land on the
|
|
504
|
+
// append-only outcome ledger, and SQLite cannot alter a CHECK, so the ledger
|
|
505
|
+
// is rebuilt: RENAME, create the widened shape, copy every row column for
|
|
506
|
+
// column, drop the old table, recreate both indexes. No row is dropped and
|
|
507
|
+
// no stored value changes; only the two CHECK lists gain `closed-by-thread`.
|
|
508
|
+
// The follow table itself is a pure additive creation.
|
|
509
|
+
if (!migrateStep(database, 5, `
|
|
510
|
+
ALTER TABLE work_item_outcome RENAME TO work_item_outcome_v5;
|
|
511
|
+
DROP INDEX work_item_outcome_pending_idx;
|
|
512
|
+
DROP INDEX work_item_outcome_evaluation_idx;
|
|
513
|
+
${OUTCOME_SCHEMA}
|
|
514
|
+
INSERT INTO work_item_outcome(item_id, revision, settled_by, execution_status, objective_status, trust, evidence_state, evidence_reason, evidence, baseline_sequence, inbox_id, authority_hash, deadline_at, settled_at, recorded_at, evaluation_state, evaluation_digest, evaluation_recorded_at, evaluation_attempts)
|
|
515
|
+
SELECT item_id, revision, settled_by, execution_status, objective_status, trust, evidence_state, evidence_reason, evidence, baseline_sequence, inbox_id, authority_hash, deadline_at, settled_at, recorded_at, evaluation_state, evaluation_digest, evaluation_recorded_at, evaluation_attempts FROM work_item_outcome_v5;
|
|
516
|
+
DROP TABLE work_item_outcome_v5;
|
|
517
|
+
${FOLLOW_SCHEMA}
|
|
518
|
+
UPDATE work_item_schema_meta SET value = '6' WHERE key = 'schema_version';
|
|
519
|
+
PRAGMA user_version = 6;
|
|
520
|
+
`))
|
|
521
|
+
return validateSchema(database);
|
|
522
|
+
}
|
|
523
|
+
if (version >= 1 && version <= 6) {
|
|
524
|
+
// v7 persists the audit-only admission mandate so claim and wake can replay
|
|
525
|
+
// the identical policy decision. Both columns are nullable and additive:
|
|
526
|
+
// pre-v7 rows stay `NULL` and are reported as legacy, never re-authorized
|
|
527
|
+
// from the current configuration.
|
|
528
|
+
if (!migrateStep(database, 6, `
|
|
529
|
+
ALTER TABLE work_item ADD COLUMN authority_basis TEXT;
|
|
530
|
+
ALTER TABLE work_item ADD COLUMN authority_checked_at INTEGER;
|
|
531
|
+
UPDATE work_item_schema_meta SET value = '7' WHERE key = 'schema_version';
|
|
532
|
+
PRAGMA user_version = 7;
|
|
533
|
+
`))
|
|
534
|
+
return validateSchema(database);
|
|
535
|
+
}
|
|
536
|
+
if (version >= 1 && version <= 7) {
|
|
537
|
+
// Add only bounded scheduler lookup indexes. No stored task, report,
|
|
538
|
+
// occurrence, attempt, authority or budget value is rewritten.
|
|
539
|
+
if (!migrateStep(database, 7, `${WAITING_RESUME_DUE_INDEX};
|
|
540
|
+
${ACTIVE_CLAIM_ITEM_INDEX};
|
|
541
|
+
UPDATE work_item_schema_meta SET value = '8' WHERE key = 'schema_version';
|
|
542
|
+
PRAGMA user_version = 8;`))
|
|
543
|
+
return validateSchema(database);
|
|
544
|
+
}
|
|
545
|
+
if (version >= 1 && version <= 8) {
|
|
546
|
+
if (!migrateStep(database, 8, `${CONTINUATION_SCHEMA};
|
|
547
|
+
UPDATE work_item_schema_meta SET value = '9' WHERE key = 'schema_version';
|
|
548
|
+
PRAGMA user_version = 9;`))
|
|
549
|
+
return validateSchema(database);
|
|
550
|
+
}
|
|
551
|
+
if (version >= 1 && version <= 9) {
|
|
552
|
+
// Additive only: the intake ledger starts empty and no existing row moves.
|
|
553
|
+
if (!migrateStep(database, 9, `${INTAKE_SCHEMA};
|
|
554
|
+
UPDATE work_item_schema_meta SET value = '10' WHERE key = 'schema_version';
|
|
555
|
+
PRAGMA user_version = 10;`))
|
|
556
|
+
return validateSchema(database);
|
|
557
|
+
}
|
|
558
|
+
if (version >= 1 && version <= 10) {
|
|
559
|
+
// Additive only: the evaluation table starts empty; no intake row moves.
|
|
560
|
+
if (!migrateStep(database, 10, `${INTAKE_EVALUATION_SCHEMA};
|
|
561
|
+
UPDATE work_item_schema_meta SET value = '11' WHERE key = 'schema_version';
|
|
562
|
+
PRAGMA user_version = 11;`))
|
|
563
|
+
return validateSchema(database);
|
|
564
|
+
}
|
|
565
|
+
if (version >= 1 && version <= 11) {
|
|
566
|
+
// Additive only: the admission table starts empty; no evaluation row moves.
|
|
567
|
+
if (!migrateStep(database, 11, `${INTAKE_ADMISSION_SCHEMA};
|
|
568
|
+
UPDATE work_item_schema_meta SET value = '12' WHERE key = 'schema_version';
|
|
569
|
+
PRAGMA user_version = 12;`))
|
|
570
|
+
return validateSchema(database);
|
|
571
|
+
}
|
|
572
|
+
if (version >= 1 && version <= 12) {
|
|
573
|
+
// Additive only: an index over existing admission rows; no row moves.
|
|
574
|
+
if (!migrateStep(database, 12, `${INTAKE_ADMISSION_ITEM_INDEX};
|
|
575
|
+
UPDATE work_item_schema_meta SET value = '13' WHERE key = 'schema_version';
|
|
576
|
+
PRAGMA user_version = 13;`))
|
|
577
|
+
return validateSchema(database);
|
|
578
|
+
}
|
|
579
|
+
if (!Number.isSafeInteger(version) || version < 1 || version > SCHEMA_VERSION)
|
|
580
|
+
fail('storage', `unsupported work-item schema version ${String(version)}`);
|
|
581
|
+
const expected = JSON.stringify(WORK_ITEM_TABLE_NAMES);
|
|
582
|
+
const tables = database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT GLOB 'sqlite_*' ORDER BY name").all().map(row => String(row['name']));
|
|
583
|
+
if (JSON.stringify(tables) !== expected)
|
|
584
|
+
fail('storage', 'work-item database schema is not the expected shape');
|
|
585
|
+
for (const [name, sql] of [['work_item_continuation', CONTINUATION_TABLE], ...CONTINUATION_TRIGGERS]) {
|
|
586
|
+
const row = database.prepare('SELECT sql FROM sqlite_schema WHERE name=?').get(name);
|
|
587
|
+
if (typeof row?.['sql'] !== 'string' || row['sql'].replace(/\s+/gu, ' ').trim() !== sql.replace(/\s+/gu, ' ').trim())
|
|
588
|
+
fail('storage', 'continuation schema differs');
|
|
589
|
+
}
|
|
590
|
+
for (const [name, sql] of [['work_item_intake', INTAKE_TABLE], ['work_item_intake_content_idx', INTAKE_CONTENT_INDEX], ...INTAKE_TRIGGERS]) {
|
|
591
|
+
const row = database.prepare('SELECT sql FROM sqlite_schema WHERE name = ?').get(name);
|
|
592
|
+
if (typeof row?.['sql'] !== 'string' || row['sql'].replace(/\s+/gu, ' ').trim() !== sql.replace(/\s+/gu, ' ').trim())
|
|
593
|
+
fail('storage', 'intake schema differs');
|
|
594
|
+
}
|
|
595
|
+
for (const [name, sql] of [['work_item_intake_evaluation', INTAKE_EVALUATION_TABLE], ...INTAKE_EVALUATION_TRIGGERS]) {
|
|
596
|
+
const row = database.prepare('SELECT sql FROM sqlite_schema WHERE name = ?').get(name);
|
|
597
|
+
if (typeof row?.['sql'] !== 'string' || row['sql'].replace(/\s+/gu, ' ').trim() !== sql.replace(/\s+/gu, ' ').trim())
|
|
598
|
+
fail('storage', 'intake evaluation schema differs');
|
|
599
|
+
}
|
|
600
|
+
for (const [name, sql] of [['work_item_intake_admission', INTAKE_ADMISSION_TABLE], ['work_item_intake_admission_admitted_idx', INTAKE_ADMISSION_ADMITTED_INDEX],
|
|
601
|
+
['work_item_intake_admission_item_idx', INTAKE_ADMISSION_ITEM_INDEX], ...INTAKE_ADMISSION_TRIGGERS]) {
|
|
602
|
+
const row = database.prepare('SELECT sql FROM sqlite_schema WHERE name = ?').get(name);
|
|
603
|
+
if (typeof row?.['sql'] !== 'string' || row['sql'].replace(/\s+/gu, ' ').trim() !== sql.replace(/\s+/gu, ' ').trim())
|
|
604
|
+
fail('storage', 'intake admission schema differs');
|
|
605
|
+
}
|
|
606
|
+
for (const [name, sql] of [['work_item_dispatch_waiting_due_idx', WAITING_RESUME_DUE_INDEX], ['work_item_claim_active_item_idx', ACTIVE_CLAIM_ITEM_INDEX]]) {
|
|
607
|
+
const row = database.prepare('SELECT sql FROM sqlite_schema WHERE type = ? AND name = ?').get('index', name);
|
|
608
|
+
if (typeof row?.['sql'] !== 'string' || row['sql'].replace(/\s+/gu, ' ').trim() !== sql.replace(/\s+/gu, ' ').trim()) {
|
|
609
|
+
fail('storage', 'work-item scheduler indexes do not match the expected shape');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function intakeFromRow(row) {
|
|
614
|
+
const signals = JSON.parse(String(row['signals']));
|
|
615
|
+
return Object.freeze({
|
|
616
|
+
source: String(row['source']),
|
|
617
|
+
sourceEventId: String(row['source_event_id']),
|
|
618
|
+
kind: String(row['kind']),
|
|
619
|
+
decision: String(row['decision']),
|
|
620
|
+
reason: String(row['reason']),
|
|
621
|
+
strength: String(row['strength']),
|
|
622
|
+
strengthReason: String(row['strength_reason']),
|
|
623
|
+
signals: Object.freeze({ ...signals }),
|
|
624
|
+
contentDigest: optString(row['content_digest']),
|
|
625
|
+
metadataDigest: String(row['metadata_digest']),
|
|
626
|
+
occurredAt: Number(row['occurred_at']),
|
|
627
|
+
recordedAt: Number(row['recorded_at']),
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
function intakeEvaluationFromRow(row) {
|
|
631
|
+
const spans = JSON.parse(String(row['quote_spans'])).map(span => Object.freeze([span[0], span[1]]));
|
|
632
|
+
return Object.freeze({
|
|
633
|
+
source: String(row['source']),
|
|
634
|
+
sourceEventId: String(row['source_event_id']),
|
|
635
|
+
ruleId: String(row['rule_id']),
|
|
636
|
+
policyVersion: String(row['policy_version']),
|
|
637
|
+
sourceDigest: String(row['source_digest']),
|
|
638
|
+
mode: String(row['mode']),
|
|
639
|
+
status: String(row['status']),
|
|
640
|
+
level: String(row['level']),
|
|
641
|
+
proposedLevel: optString(row['proposed_level']),
|
|
642
|
+
ceiling: String(row['ceiling']),
|
|
643
|
+
quoteSpans: Object.freeze(spans),
|
|
644
|
+
reasons: Object.freeze(JSON.parse(String(row['reasons']))),
|
|
645
|
+
wouldAccept: Number(row['would_accept']) === 1,
|
|
646
|
+
attempts: Number(row['attempts']),
|
|
647
|
+
decidedAt: Number(row['decided_at']),
|
|
648
|
+
recordedAt: Number(row['recorded_at']),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
function intakeAdmissionFromRow(row) {
|
|
652
|
+
return Object.freeze({
|
|
653
|
+
source: String(row['source']),
|
|
654
|
+
sourceEventId: String(row['source_event_id']),
|
|
655
|
+
ruleId: String(row['rule_id']),
|
|
656
|
+
policyVersion: String(row['policy_version']),
|
|
657
|
+
mode: String(row['mode']),
|
|
658
|
+
outcome: String(row['outcome']),
|
|
659
|
+
reasonCode: optString(row['reason_code']),
|
|
660
|
+
decisionId: String(row['decision_id']),
|
|
661
|
+
itemId: optString(row['item_id']),
|
|
662
|
+
recordedAt: Number(row['recorded_at']),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
function optString(value) {
|
|
666
|
+
return value === null || value === undefined ? null : String(value);
|
|
667
|
+
}
|
|
668
|
+
function optNumber(value) {
|
|
669
|
+
return value === null || value === undefined ? null : Number(value);
|
|
670
|
+
}
|
|
671
|
+
/** Positive safe integer or `null`; malformed detail never throws. */
|
|
672
|
+
function optCount(value) {
|
|
673
|
+
const parsed = Number(value);
|
|
674
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
675
|
+
}
|
|
676
|
+
/** Decode one event detail object; absent or malformed JSON yields `null`. */
|
|
677
|
+
function eventDetail(value) {
|
|
678
|
+
if (value === null || value === undefined)
|
|
679
|
+
return null;
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(String(value));
|
|
682
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
683
|
+
return null;
|
|
684
|
+
return parsed;
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Decode the persisted advisory basis.
|
|
692
|
+
*
|
|
693
|
+
* Unreadable JSON is returned as a structurally unusable sentinel rather than
|
|
694
|
+
* `null`: `null` means a legitimate pre-v7 row that must keep its legacy
|
|
695
|
+
* behaviour, while a malformed stored basis must be revalidated as revoked.
|
|
696
|
+
*/
|
|
697
|
+
function authorityBasisFromRow(value) {
|
|
698
|
+
if (value === null || value === undefined)
|
|
699
|
+
return null;
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(String(value));
|
|
702
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
703
|
+
return {};
|
|
704
|
+
return Object.freeze(parsed);
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return {};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function itemFromRow(row) {
|
|
711
|
+
const scope = Object.freeze({
|
|
712
|
+
workspace: String(row['workspace']),
|
|
713
|
+
preset: String(row['agentPreset']),
|
|
714
|
+
principalId: String(row['principalId']),
|
|
715
|
+
ownerRouteId: String(row['ownerRouteId']),
|
|
716
|
+
expiresAt: Number(row['scopeExpiresAt']),
|
|
717
|
+
});
|
|
718
|
+
const owner = Object.freeze({
|
|
719
|
+
principalRecordId: String(row['principalRecordId']),
|
|
720
|
+
principalVersion: Number(row['principalVersion']),
|
|
721
|
+
workspace: String(row['workspace']),
|
|
722
|
+
agentPreset: String(row['agentPreset']),
|
|
723
|
+
bindingVersion: Number(row['bindingVersion']),
|
|
724
|
+
generation: Number(row['ownerGeneration']),
|
|
725
|
+
});
|
|
726
|
+
return Object.freeze({
|
|
727
|
+
id: String(row['id']),
|
|
728
|
+
dedupKey: String(row['dedupKey']),
|
|
729
|
+
dedupBucket: Number(row['dedupBucket']),
|
|
730
|
+
sourceId: String(row['sourceId']),
|
|
731
|
+
kind: String(row['kind']),
|
|
732
|
+
externalId: String(row['externalId']),
|
|
733
|
+
title: String(row['title']),
|
|
734
|
+
brief: String(row['brief']),
|
|
735
|
+
briefTruncated: Number(row['briefTruncated']) === 1,
|
|
736
|
+
originBindingId: String(row['originBindingId']),
|
|
737
|
+
acceptanceRef: optString(row['acceptanceRef']),
|
|
738
|
+
scope,
|
|
739
|
+
owner,
|
|
740
|
+
workerAutomationId: String(row['workerAutomationId']),
|
|
741
|
+
authority: authorityBasisFromRow(row['authorityBasis']),
|
|
742
|
+
state: String(row['state']),
|
|
743
|
+
stateReason: optString(row['stateReason']),
|
|
744
|
+
attempt: Number(row['attempt']),
|
|
745
|
+
maxAttempts: Number(row['maxAttempts']),
|
|
746
|
+
leaseUntil: optNumber(row['leaseUntil']),
|
|
747
|
+
leaseRunId: optString(row['leaseRunId']),
|
|
748
|
+
occurrenceId: optString(row['occurrenceId']),
|
|
749
|
+
workDir: String(row['workDir']),
|
|
750
|
+
lastReportStatus: optString(row['lastReportStatus']),
|
|
751
|
+
observed: Number(row['observed']) === 1,
|
|
752
|
+
createdAt: Number(row['createdAt']),
|
|
753
|
+
updatedAt: Number(row['updatedAt']),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function claimFromRow(row) {
|
|
757
|
+
return Object.freeze({
|
|
758
|
+
claimId: Number(row['claim_id']),
|
|
759
|
+
itemId: String(row['item_id']),
|
|
760
|
+
runId: String(row['run_id']),
|
|
761
|
+
occurrenceId: String(row['occurrence_id']),
|
|
762
|
+
state: String(row['state']),
|
|
763
|
+
leasedUntil: Number(row['leased_until']),
|
|
764
|
+
createdAt: Number(row['created_at']),
|
|
765
|
+
updatedAt: Number(row['updated_at']),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
function reportFromRow(row) {
|
|
769
|
+
return Object.freeze({
|
|
770
|
+
itemId: String(row['item_id']),
|
|
771
|
+
revision: Number(row['revision']),
|
|
772
|
+
status: String(row['status']),
|
|
773
|
+
summary: String(row['summary']),
|
|
774
|
+
truncated: Number(row['truncated']) === 1,
|
|
775
|
+
artifactPaths: Object.freeze(JSON.parse(String(row['artifact_paths']))),
|
|
776
|
+
idempotencyKey: String(row['idempotency_key']),
|
|
777
|
+
deliveryKey: String(row['delivery_key']),
|
|
778
|
+
deliveryState: optString(row['delivery_state']),
|
|
779
|
+
deliveryId: optString(row['delivery_id']),
|
|
780
|
+
recordedAt: Number(row['recorded_at']),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function dispatchFromRow(row) {
|
|
784
|
+
return Object.freeze({
|
|
785
|
+
itemId: String(row['item_id']),
|
|
786
|
+
automationId: String(row['automation_id']),
|
|
787
|
+
eventId: String(row['event_id']),
|
|
788
|
+
occurredAt: Number(row['occurred_at']),
|
|
789
|
+
generation: Number(row['generation'] ?? 0),
|
|
790
|
+
wakeAttempts: Number(row['wake_attempts'] ?? 0),
|
|
791
|
+
state: String(row['state']),
|
|
792
|
+
occurrenceId: optString(row['occurrence_id']),
|
|
793
|
+
reason: optString(row['reason']),
|
|
794
|
+
createdAt: Number(row['created_at']),
|
|
795
|
+
updatedAt: Number(row['updated_at']),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
/** Decode stored evidence; malformed JSON fails closed to `null`. */
|
|
799
|
+
function parseEvidence(stored) {
|
|
800
|
+
try {
|
|
801
|
+
return Object.freeze(JSON.parse(stored));
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function outcomeFromRow(row) {
|
|
808
|
+
const evidence = optString(row['evidence']);
|
|
809
|
+
return Object.freeze({
|
|
810
|
+
itemId: String(row['item_id']),
|
|
811
|
+
revision: Number(row['revision']),
|
|
812
|
+
settledBy: String(row['settled_by']),
|
|
813
|
+
executionStatus: String(row['execution_status']),
|
|
814
|
+
objectiveStatus: String(row['objective_status']),
|
|
815
|
+
trust: String(row['trust']),
|
|
816
|
+
evidenceState: String(row['evidence_state']),
|
|
817
|
+
evidenceReason: optString(row['evidence_reason']),
|
|
818
|
+
// Malformed stored evidence is treated as absent rather than thrown: an
|
|
819
|
+
// unreadable receipt can never prove successful repair.
|
|
820
|
+
evidence: evidence === null ? null : parseEvidence(evidence),
|
|
821
|
+
baselineSequence: optNumber(row['baseline_sequence']),
|
|
822
|
+
inboxId: optString(row['inbox_id']),
|
|
823
|
+
authorityHash: optString(row['authority_hash']),
|
|
824
|
+
deadlineAt: optNumber(row['deadline_at']),
|
|
825
|
+
settledAt: Number(row['settled_at']),
|
|
826
|
+
recordedAt: Number(row['recorded_at']),
|
|
827
|
+
evaluationState: String(row['evaluation_state'] ?? 'pending'),
|
|
828
|
+
evaluationDigest: optString(row['evaluation_digest']),
|
|
829
|
+
evaluationRecordedAt: optNumber(row['evaluation_recorded_at']),
|
|
830
|
+
evaluationAttempts: Number(row['evaluation_attempts'] ?? 0),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/** Latest outcome revision per item; older revisions stay as immutable history. */
|
|
834
|
+
function followFromRow(row) {
|
|
835
|
+
return Object.freeze({
|
|
836
|
+
itemId: String(row['item_id']),
|
|
837
|
+
position: Number(row['position']),
|
|
838
|
+
kind: String(row['kind']),
|
|
839
|
+
ref: String(row['ref']),
|
|
840
|
+
state: String(row['state']),
|
|
841
|
+
outcome: optString(row['outcome']),
|
|
842
|
+
digest: optString(row['digest']),
|
|
843
|
+
source: String(row['source']),
|
|
844
|
+
sourceGeneration: String(row['source_generation']),
|
|
845
|
+
openedAt: Number(row['opened_at']),
|
|
846
|
+
closedAt: optNumber(row['closed_at']),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const LATEST_OUTCOME = 'SELECT item_id, MAX(revision) AS revision FROM work_item_outcome GROUP BY item_id';
|
|
850
|
+
/**
|
|
851
|
+
* Single-writer SQLite ledger.
|
|
852
|
+
*
|
|
853
|
+
* Every mutation runs inside `BEGIN IMMEDIATE`; the database file is created
|
|
854
|
+
* 0600 next to a 0700 directory, WAL keeps readers unblocked, and schema
|
|
855
|
+
* upgrades are bounded to the single-writer upgrade boundary at open time.
|
|
856
|
+
*/
|
|
857
|
+
export class WorkItemStore {
|
|
858
|
+
#database;
|
|
859
|
+
#statements = new Map();
|
|
860
|
+
#now;
|
|
861
|
+
#settleWindowMs;
|
|
862
|
+
#closed = false;
|
|
863
|
+
constructor(path, options = {}) {
|
|
864
|
+
this.#now = options.now ?? (() => Date.now());
|
|
865
|
+
const settleWindowMs = options.settleWindowMs ?? DEFAULT_SETTLE_WINDOW_MS;
|
|
866
|
+
if (!Number.isSafeInteger(settleWindowMs) || settleWindowMs <= 0)
|
|
867
|
+
fail('invalid-input', 'settleWindowMs must be a positive integer');
|
|
868
|
+
this.#settleWindowMs = settleWindowMs;
|
|
869
|
+
if (path !== MEMORY_DATABASE)
|
|
870
|
+
prepareDatabaseFile(path);
|
|
871
|
+
this.#database = new DatabaseSync(path);
|
|
872
|
+
if (path !== MEMORY_DATABASE) {
|
|
873
|
+
this.#database.exec('PRAGMA journal_mode = WAL;');
|
|
874
|
+
this.#database.exec('PRAGMA synchronous = FULL;');
|
|
875
|
+
}
|
|
876
|
+
this.#database.exec('PRAGMA foreign_keys = ON;');
|
|
877
|
+
this.#database.exec('PRAGMA busy_timeout = 5000;');
|
|
878
|
+
try {
|
|
879
|
+
validateSchema(this.#database);
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
this.#database.close();
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
#statement(sql) {
|
|
887
|
+
const cached = this.#statements.get(sql);
|
|
888
|
+
if (cached !== undefined)
|
|
889
|
+
return cached;
|
|
890
|
+
const statement = this.#database.prepare(sql);
|
|
891
|
+
this.#statements.set(sql, statement);
|
|
892
|
+
return statement;
|
|
893
|
+
}
|
|
894
|
+
/** Keep report/plan sources writer-fenced through a synchronous Host commit.
|
|
895
|
+
* Delivery and Evaluation own their outer locks; this method never acquires
|
|
896
|
+
* either peer or starts any execution. */
|
|
897
|
+
withFeedbackSourceFence(work) {
|
|
898
|
+
if (this.#closed)
|
|
899
|
+
fail('storage', 'work-item feedback source store is closed');
|
|
900
|
+
if (typeof work !== 'function')
|
|
901
|
+
fail('invalid-input', 'feedback source fence requires a callback');
|
|
902
|
+
return this.#transaction(() => {
|
|
903
|
+
const result = work();
|
|
904
|
+
if ((typeof result === 'object' || typeof result === 'function') && result !== null && 'then' in result) {
|
|
905
|
+
// Reject async callbacks without evaluating an untrusted then getter.
|
|
906
|
+
// Attach a handler only when the return value has the native Promise
|
|
907
|
+
// brand, so a rejected Promise cannot escape as an unhandled rejection.
|
|
908
|
+
try {
|
|
909
|
+
Promise.prototype.then.call(result, undefined, () => undefined);
|
|
910
|
+
}
|
|
911
|
+
catch { /* Not a native Promise. */ }
|
|
912
|
+
fail('invalid-input', 'feedback source fence callback must be synchronous');
|
|
913
|
+
}
|
|
914
|
+
if (this.#closed)
|
|
915
|
+
fail('storage', 'work-item feedback source store closed during the callback');
|
|
916
|
+
return result;
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
#transaction(work) {
|
|
920
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
921
|
+
try {
|
|
922
|
+
const result = work();
|
|
923
|
+
this.#database.exec('COMMIT');
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
try {
|
|
928
|
+
this.#database.exec('ROLLBACK');
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// Keep the original failure; a broken transaction is surfaced via health.
|
|
932
|
+
}
|
|
933
|
+
throw error;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
#appendEvent(itemId, kind, actor, detail, now, canonical = false) {
|
|
937
|
+
const next = this.#statement('SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM work_item_event WHERE item_id = ?').get(itemId);
|
|
938
|
+
this.#statement('INSERT INTO work_item_event(item_id, sequence, kind, actor, detail, recorded_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
939
|
+
.run(itemId, Number(next['next']), kind, actor, detail === undefined ? null : canonical ? canonicalDiagnosis(detail) : JSON.stringify(detail), now);
|
|
940
|
+
}
|
|
941
|
+
#requireItem(itemId) {
|
|
942
|
+
const item = this.findItemById(itemId);
|
|
943
|
+
if (item === undefined)
|
|
944
|
+
fail('not-found', 'work item does not exist');
|
|
945
|
+
return item;
|
|
946
|
+
}
|
|
947
|
+
close() {
|
|
948
|
+
if (this.#closed)
|
|
949
|
+
return;
|
|
950
|
+
this.#closed = true;
|
|
951
|
+
this.#statements.clear();
|
|
952
|
+
this.#database.close();
|
|
953
|
+
}
|
|
954
|
+
get closed() {
|
|
955
|
+
return this.#closed;
|
|
956
|
+
}
|
|
957
|
+
stats() {
|
|
958
|
+
const count = (sql) => Number(this.#statement(sql).get()['count']);
|
|
959
|
+
const evaluations = this.countEvaluations();
|
|
960
|
+
return Object.freeze({
|
|
961
|
+
schemaVersion: Number(this.#database.prepare('PRAGMA user_version').get()['user_version']),
|
|
962
|
+
items: count('SELECT COUNT(*) AS count FROM work_item'),
|
|
963
|
+
openItems: count(`SELECT COUNT(*) AS count FROM work_item WHERE observed = 0 AND state IN ('queued', 'claimed', 'working', 'waiting')`),
|
|
964
|
+
reports: count('SELECT COUNT(*) AS count FROM work_item_report'),
|
|
965
|
+
dispatches: count('SELECT COUNT(*) AS count FROM work_item_dispatch'),
|
|
966
|
+
outcomes: count('SELECT COUNT(*) AS count FROM work_item_outcome'),
|
|
967
|
+
pendingOutcomes: count(`SELECT COUNT(*) AS count FROM work_item_outcome o JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision WHERE o.evidence_state = 'pending'`),
|
|
968
|
+
unverifiedOutcomes: count(`SELECT COUNT(*) AS count FROM work_item_outcome o JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision WHERE o.evidence_state = 'unverified'`),
|
|
969
|
+
evaluationPending: evaluations.pending,
|
|
970
|
+
evaluationExported: evaluations.exported,
|
|
971
|
+
evaluationSkipped: evaluations.skipped,
|
|
972
|
+
follows: count('SELECT COUNT(*) AS count FROM work_item_follow'),
|
|
973
|
+
authorityLegacy: this.countAuthorityLegacy(),
|
|
974
|
+
authorityParked: this.countAuthorityParked(),
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Record one classified intake candidate exactly once.
|
|
979
|
+
*
|
|
980
|
+
* A replay of the same `(source, sourceEventId)` returns the original row
|
|
981
|
+
* unchanged, with no second write. A new event whose content digest matches
|
|
982
|
+
* an earlier `stored` row of the same source and at least the same strength
|
|
983
|
+
* is recorded as `duplicate`; a stronger repeat (for example the same text
|
|
984
|
+
* re-posted with a mention) is never suppressed by a weaker original.
|
|
985
|
+
* This method writes only the intake table; it admits and dispatches nothing.
|
|
986
|
+
*/
|
|
987
|
+
recordIntake(candidate) {
|
|
988
|
+
if (this.#closed)
|
|
989
|
+
fail('storage', 'work-item store is closed');
|
|
990
|
+
return this.#transaction(() => {
|
|
991
|
+
const existing = this.getIntake(candidate.source, candidate.sourceEventId);
|
|
992
|
+
if (existing !== undefined) {
|
|
993
|
+
return Object.freeze({ record: existing, replayed: true,
|
|
994
|
+
...(existing.metadataDigest === candidate.metadataDigest ? {} : { metadataChanged: true }) });
|
|
995
|
+
}
|
|
996
|
+
let decision = candidate.decision;
|
|
997
|
+
let reason = candidate.reason;
|
|
998
|
+
if (decision === 'stored' && candidate.contentDigest !== null) {
|
|
999
|
+
const prior = this.#statement(`SELECT 1 AS hit FROM work_item_intake
|
|
1000
|
+
WHERE source = ? AND content_digest = ? AND decision = 'stored'
|
|
1001
|
+
AND (strength = 'explicit' OR strength = ?) LIMIT 1`).get(candidate.source, candidate.contentDigest, candidate.strength);
|
|
1002
|
+
if (prior !== undefined)
|
|
1003
|
+
[decision, reason] = ['duplicate', 'duplicate-content'];
|
|
1004
|
+
}
|
|
1005
|
+
this.#statement(`INSERT INTO work_item_intake(source, source_event_id, kind, decision, reason, strength, strength_reason,
|
|
1006
|
+
signals, content_digest, metadata_digest, occurred_at, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1007
|
+
.run(candidate.source, candidate.sourceEventId, candidate.kind, decision, reason, candidate.strength, candidate.strengthReason, JSON.stringify(candidate.signals), candidate.contentDigest, candidate.metadataDigest, candidate.occurredAt, this.#now());
|
|
1008
|
+
return Object.freeze({ record: this.getIntake(candidate.source, candidate.sourceEventId), replayed: false });
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
getIntake(source, sourceEventId) {
|
|
1012
|
+
const row = this.#statement('SELECT * FROM work_item_intake WHERE source = ? AND source_event_id = ?').get(source, sourceEventId);
|
|
1013
|
+
return row === undefined ? undefined : intakeFromRow(row);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Append one evaluation result exactly once per `(row, rule, Policy version)`.
|
|
1017
|
+
* The intake row must be a `stored` `inferred` row whose content digest is the
|
|
1018
|
+
* evaluated source digest. A replay returns the original row unchanged.
|
|
1019
|
+
*/
|
|
1020
|
+
recordIntakeEvaluation(record) {
|
|
1021
|
+
if (this.#closed)
|
|
1022
|
+
fail('storage', 'work-item store is closed');
|
|
1023
|
+
const id = (value, field) => {
|
|
1024
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256)
|
|
1025
|
+
fail('invalid-input', `intake evaluation ${field} is invalid`);
|
|
1026
|
+
return value;
|
|
1027
|
+
};
|
|
1028
|
+
const ruleId = id(record.ruleId, 'ruleId');
|
|
1029
|
+
const policyVersion = id(record.policyVersion, 'policyVersion');
|
|
1030
|
+
const spans = record.quoteSpans.map(span => [span[0], span[1]]);
|
|
1031
|
+
if (spans.some(([start, end]) => !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end <= start)) {
|
|
1032
|
+
fail('invalid-input', 'intake evaluation quote spans are invalid');
|
|
1033
|
+
}
|
|
1034
|
+
if (!EVALUATION_LEVELS.includes(record.level) || !EVALUATION_LEVELS.includes(record.ceiling)
|
|
1035
|
+
|| (record.proposedLevel !== null && !EVALUATION_LEVELS.includes(record.proposedLevel)))
|
|
1036
|
+
fail('invalid-input', 'intake evaluation level is invalid');
|
|
1037
|
+
if (!Number.isSafeInteger(record.attempts) || record.attempts < 0 || !Number.isSafeInteger(record.decidedAt))
|
|
1038
|
+
fail('invalid-input', 'intake evaluation counters are invalid');
|
|
1039
|
+
return this.#transaction(() => {
|
|
1040
|
+
const existing = this.getIntakeEvaluation(record.source, record.sourceEventId, ruleId, policyVersion);
|
|
1041
|
+
if (existing !== undefined)
|
|
1042
|
+
return Object.freeze({ record: existing, replayed: true });
|
|
1043
|
+
const intake = this.getIntake(record.source, record.sourceEventId);
|
|
1044
|
+
if (intake === undefined || intake.decision !== 'stored' || intake.strength !== 'inferred' || intake.contentDigest !== record.sourceDigest) {
|
|
1045
|
+
fail('invalid-state', 'intake evaluation requires a stored inferred row with the same content digest');
|
|
1046
|
+
}
|
|
1047
|
+
this.#statement(`INSERT INTO work_item_intake_evaluation(source, source_event_id, rule_id, policy_version, source_digest, mode, status,
|
|
1048
|
+
level, proposed_level, ceiling, quote_spans, reasons, would_accept, attempts, decided_at, recorded_at)
|
|
1049
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1050
|
+
.run(record.source, record.sourceEventId, ruleId, policyVersion, record.sourceDigest, record.mode, record.status, record.level, record.proposedLevel, record.ceiling, JSON.stringify(spans), JSON.stringify([...record.reasons]), record.wouldAccept ? 1 : 0, record.attempts, record.decidedAt, this.#now());
|
|
1051
|
+
return Object.freeze({ record: this.getIntakeEvaluation(record.source, record.sourceEventId, ruleId, policyVersion), replayed: false });
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
getIntakeEvaluation(source, sourceEventId, ruleId, policyVersion) {
|
|
1055
|
+
const row = this.#statement(`SELECT * FROM work_item_intake_evaluation
|
|
1056
|
+
WHERE source = ? AND source_event_id = ? AND rule_id = ? AND policy_version = ?`).get(source, sourceEventId, ruleId, policyVersion);
|
|
1057
|
+
return row === undefined ? undefined : intakeEvaluationFromRow(row);
|
|
1058
|
+
}
|
|
1059
|
+
/** Every persisted evaluation of one intake row, oldest first; read-only. */
|
|
1060
|
+
listIntakeEvaluations(source, sourceEventId) {
|
|
1061
|
+
const rows = this.#statement(`SELECT * FROM work_item_intake_evaluation WHERE source = ? AND source_event_id = ?
|
|
1062
|
+
ORDER BY recorded_at, rule_id, policy_version`).all(source, sourceEventId);
|
|
1063
|
+
return Object.freeze(rows.map(intakeEvaluationFromRow));
|
|
1064
|
+
}
|
|
1065
|
+
/** Status counts over the evaluation table; read-only. */
|
|
1066
|
+
countIntakeEvaluations() {
|
|
1067
|
+
const counts = { decided: 0, unavailable: 0 };
|
|
1068
|
+
for (const row of this.#statement('SELECT status, COUNT(*) AS count FROM work_item_intake_evaluation GROUP BY status').all()) {
|
|
1069
|
+
counts[String(row['status'])] = Number(row['count']);
|
|
1070
|
+
}
|
|
1071
|
+
return Object.freeze(counts);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Append one admission outcome exactly once per evaluation row. The
|
|
1075
|
+
* evaluation must exist and would accept. A replay returns the first row
|
|
1076
|
+
* unchanged, so a later outcome never rewrites an earlier one.
|
|
1077
|
+
*/
|
|
1078
|
+
recordIntakeAdmission(record) {
|
|
1079
|
+
if (this.#closed)
|
|
1080
|
+
fail('storage', 'work-item store is closed');
|
|
1081
|
+
return this.#transaction(() => {
|
|
1082
|
+
const existing = this.getIntakeAdmission(record.source, record.sourceEventId, record.ruleId, record.policyVersion);
|
|
1083
|
+
if (existing !== undefined)
|
|
1084
|
+
return existing;
|
|
1085
|
+
const evaluation = this.getIntakeEvaluation(record.source, record.sourceEventId, record.ruleId, record.policyVersion);
|
|
1086
|
+
if (evaluation === undefined || !evaluation.wouldAccept)
|
|
1087
|
+
fail('invalid-state', 'intake admission requires an accepting evaluation row');
|
|
1088
|
+
this.#statement(`INSERT INTO work_item_intake_admission(source, source_event_id, rule_id, policy_version, mode, outcome,
|
|
1089
|
+
reason_code, decision_id, item_id, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1090
|
+
.run(record.source, record.sourceEventId, record.ruleId, record.policyVersion, record.mode, record.outcome, record.reasonCode, record.decisionId, record.itemId, this.#now());
|
|
1091
|
+
return this.getIntakeAdmission(record.source, record.sourceEventId, record.ruleId, record.policyVersion);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
getIntakeAdmission(source, sourceEventId, ruleId, policyVersion) {
|
|
1095
|
+
const row = this.#statement(`SELECT * FROM work_item_intake_admission
|
|
1096
|
+
WHERE source = ? AND source_event_id = ? AND rule_id = ? AND policy_version = ?`).get(source, sourceEventId, ruleId, policyVersion);
|
|
1097
|
+
return row === undefined ? undefined : intakeAdmissionFromRow(row);
|
|
1098
|
+
}
|
|
1099
|
+
/** Every admission outcome of one intake row, oldest first; read-only. */
|
|
1100
|
+
listIntakeAdmissions(source, sourceEventId) {
|
|
1101
|
+
const rows = this.#statement(`SELECT * FROM work_item_intake_admission WHERE source = ? AND source_event_id = ?
|
|
1102
|
+
ORDER BY recorded_at, rule_id, policy_version`).all(source, sourceEventId);
|
|
1103
|
+
return Object.freeze(rows.map(intakeAdmissionFromRow));
|
|
1104
|
+
}
|
|
1105
|
+
/** Oldest accepting evaluations without an admission row, at most `limit`; read-only. */
|
|
1106
|
+
pendingIntakeAdmissions(limit) {
|
|
1107
|
+
const rows = this.#statement(`SELECT e.* FROM work_item_intake_evaluation e
|
|
1108
|
+
WHERE e.would_accept = 1 AND NOT EXISTS (SELECT 1 FROM work_item_intake_admission a
|
|
1109
|
+
WHERE a.source = e.source AND a.source_event_id = e.source_event_id AND a.rule_id = e.rule_id AND a.policy_version = e.policy_version)
|
|
1110
|
+
ORDER BY e.recorded_at, e.source, e.source_event_id, e.rule_id, e.policy_version LIMIT ?`).all(limit);
|
|
1111
|
+
return Object.freeze(rows.map(intakeEvaluationFromRow));
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The intake decision that admitted one item, if any: its first
|
|
1115
|
+
* `admitted` (or crash-recovered `replayed`) admission row joined with the
|
|
1116
|
+
* evaluation it acted on.
|
|
1117
|
+
* Read-only. Served by the partial `item_id` index (schema 13).
|
|
1118
|
+
*/
|
|
1119
|
+
intakeProvenance(itemId) {
|
|
1120
|
+
const row = this.#statement(`SELECT * FROM work_item_intake_admission WHERE item_id = ? AND outcome IN ('admitted', 'replayed')
|
|
1121
|
+
ORDER BY recorded_at, source, source_event_id, rule_id, policy_version LIMIT 1`).get(itemId);
|
|
1122
|
+
if (row === undefined)
|
|
1123
|
+
return undefined;
|
|
1124
|
+
const admission = intakeAdmissionFromRow(row);
|
|
1125
|
+
const evaluation = this.getIntakeEvaluation(admission.source, admission.sourceEventId, admission.ruleId, admission.policyVersion);
|
|
1126
|
+
if (evaluation === undefined)
|
|
1127
|
+
fail('storage', 'intake admission has no evaluation row');
|
|
1128
|
+
return Object.freeze({ admission, evaluation });
|
|
1129
|
+
}
|
|
1130
|
+
/** Items admitted from intake at or after `since`; the hourly intake budget. */
|
|
1131
|
+
countIntakeAdmitted(since) {
|
|
1132
|
+
const row = this.#statement(`SELECT COUNT(*) AS count FROM work_item_intake_admission
|
|
1133
|
+
WHERE outcome = 'admitted' AND recorded_at >= ?`).get(since);
|
|
1134
|
+
return Number(row['count']);
|
|
1135
|
+
}
|
|
1136
|
+
/** Outcome counts over the admission table; read-only. */
|
|
1137
|
+
countIntakeAdmissions() {
|
|
1138
|
+
const counts = { 'would-admit': 0, admitted: 0, replayed: 0, refused: 0 };
|
|
1139
|
+
for (const row of this.#statement('SELECT outcome, COUNT(*) AS count FROM work_item_intake_admission GROUP BY outcome').all()) {
|
|
1140
|
+
counts[String(row['outcome'])] = Number(row['count']);
|
|
1141
|
+
}
|
|
1142
|
+
return Object.freeze(counts);
|
|
1143
|
+
}
|
|
1144
|
+
/** Decision counts over the intake table; read-only. */
|
|
1145
|
+
countIntake() {
|
|
1146
|
+
const counts = { stored: 0, duplicate: 0, filtered: 0 };
|
|
1147
|
+
for (const row of this.#statement('SELECT decision, COUNT(*) AS count FROM work_item_intake GROUP BY decision').all()) {
|
|
1148
|
+
counts[String(row['decision'])] = Number(row['count']);
|
|
1149
|
+
}
|
|
1150
|
+
return Object.freeze(counts);
|
|
1151
|
+
}
|
|
1152
|
+
/** SQL owner filtering precedes keyset pagination; no recovery or writes. */
|
|
1153
|
+
listOwnerItems(owner, after, limit, itemId) {
|
|
1154
|
+
const rows = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item
|
|
1155
|
+
WHERE principal_id=? AND principal_record_id=? AND principal_version=? AND workspace=? AND agent_preset=?
|
|
1156
|
+
AND owner_route_id=? AND (worker_automation_id=? OR (? IS NOT NULL AND worker_automation_id=?))
|
|
1157
|
+
AND (owner_generation < ? OR (owner_generation=? AND binding_version=?))
|
|
1158
|
+
AND (? IS NULL OR id=?)
|
|
1159
|
+
AND (? IS NULL OR created_at < ? OR (created_at=? AND id>?))
|
|
1160
|
+
ORDER BY created_at DESC, id ASC LIMIT ?`).all(owner.principalId, owner.principalRecordId, owner.principalVersion, owner.workspace, owner.preset, owner.ownerRouteId, owner.workerAutomationId, owner.continuationAutomationId ?? null, owner.continuationAutomationId ?? null, owner.generation, owner.generation, owner.bindingVersion, itemId ?? null, itemId ?? null, after?.createdAt ?? null, after?.createdAt ?? null, after?.createdAt ?? null, after?.id ?? '', limit);
|
|
1161
|
+
return Object.freeze(rows.map(itemFromRow));
|
|
1162
|
+
}
|
|
1163
|
+
/** Exact UTF-8 body bytes after the caller has selected an owner-filtered item. */
|
|
1164
|
+
ownerRecallText(itemId, revision) {
|
|
1165
|
+
const row = revision === undefined
|
|
1166
|
+
? this.#statement('SELECT CAST(brief AS BLOB) AS body FROM work_item WHERE id=?').get(itemId)
|
|
1167
|
+
: this.#statement('SELECT CAST(summary AS BLOB) AS body FROM work_item_report WHERE item_id=? AND revision=?').get(itemId, revision);
|
|
1168
|
+
const bytes = row?.body;
|
|
1169
|
+
if (!(bytes instanceof Uint8Array) || bytes.length > 65_536)
|
|
1170
|
+
fail('storage', 'owner recall body is unavailable or exceeds bounds');
|
|
1171
|
+
try {
|
|
1172
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
return fail('storage', 'owner recall body is not valid UTF-8');
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
findItemById(itemId) {
|
|
1179
|
+
const row = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE id = ?`).get(itemId);
|
|
1180
|
+
return row === undefined ? undefined : itemFromRow(row);
|
|
1181
|
+
}
|
|
1182
|
+
findItemByDedup(dedupKey, dedupBucket) {
|
|
1183
|
+
const row = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE dedup_key = ? AND dedup_bucket = ?`).get(dedupKey, dedupBucket);
|
|
1184
|
+
return row === undefined ? undefined : itemFromRow(row);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Latest item admitted for one canonical dedup key, across all windows.
|
|
1188
|
+
*
|
|
1189
|
+
* The dedup window forgets history when a bucket rolls over; this lookup is
|
|
1190
|
+
* what lets a correction or withdrawal of an already-settled signal stay
|
|
1191
|
+
* settled instead of reviving a completed item.
|
|
1192
|
+
*/
|
|
1193
|
+
findLatestItemByDedupKey(dedupKey) {
|
|
1194
|
+
const row = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE dedup_key = ? ORDER BY created_at DESC, id DESC LIMIT 1`).get(dedupKey);
|
|
1195
|
+
return row === undefined ? undefined : itemFromRow(row);
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* The item whose frozen mandate already binds `contractId`, if any. One
|
|
1199
|
+
* acceptance contract settles exactly one item. A full scan over the stored
|
|
1200
|
+
* basis JSON (no index, no schema change); unreadable bases never match.
|
|
1201
|
+
*/
|
|
1202
|
+
findItemByAcceptanceContract(contractId) {
|
|
1203
|
+
const row = this.#statement(`
|
|
1204
|
+
SELECT ${ITEM_ALIASES} FROM work_item
|
|
1205
|
+
WHERE CASE WHEN json_valid(authority_basis) THEN json_extract(authority_basis, '$.mandate.acceptance.contractId') END = ?
|
|
1206
|
+
OR EXISTS (SELECT 1 FROM work_item_event e WHERE e.item_id = work_item.id AND e.kind = '${ACCEPTANCE_BOUND_EVENT}'
|
|
1207
|
+
AND CASE WHEN json_valid(e.detail) THEN json_extract(e.detail, '$.binding.contractId') END = ?)
|
|
1208
|
+
ORDER BY created_at ASC, id ASC LIMIT 1
|
|
1209
|
+
`).get(contractId, contractId);
|
|
1210
|
+
return row === undefined ? undefined : itemFromRow(row);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* WP21 slice 6 candidates: items whose worker dispatch is `dispatched` to
|
|
1214
|
+
* `workerAutomationId` under their current occurrence, whose mandate binds
|
|
1215
|
+
* no contract, that have no verdict yet and no `acceptance-bound` row for
|
|
1216
|
+
* that occurrence. Never-run, withdrawn, failed and dropped items are out.
|
|
1217
|
+
* (created_at, id) order strictly after `after`; bounded scan, no schema change.
|
|
1218
|
+
*/
|
|
1219
|
+
listLateAcceptanceCandidates(workerAutomationId, after, limit) {
|
|
1220
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10)
|
|
1221
|
+
fail('invalid-input', 'acceptance bind limit must be between 1 and 10');
|
|
1222
|
+
const rows = this.#statement(`
|
|
1223
|
+
SELECT ${ITEM_ALIASES} FROM work_item i
|
|
1224
|
+
WHERE EXISTS (SELECT 1 FROM work_item_dispatch d WHERE d.item_id = i.id AND d.state = 'dispatched'
|
|
1225
|
+
AND d.occurrence_id IS NOT NULL AND d.occurrence_id = i.occurrence_id AND (d.automation_id = ? OR d.automation_id = ''))
|
|
1226
|
+
AND i.state NOT IN ('failed', 'dropped')
|
|
1227
|
+
AND CASE WHEN json_valid(i.authority_basis) THEN json_extract(i.authority_basis, '$.mandate.acceptance') END IS NULL
|
|
1228
|
+
AND NOT EXISTS (SELECT 1 FROM work_item_event e WHERE e.item_id = i.id AND e.kind = '${ACCEPTANCE_VERIFIED_EVENT}')
|
|
1229
|
+
AND NOT EXISTS (SELECT 1 FROM work_item_event e WHERE e.item_id = i.id AND e.kind = '${ACCEPTANCE_BOUND_EVENT}'
|
|
1230
|
+
AND CASE WHEN json_valid(e.detail) THEN json_extract(e.detail, '$.occurrenceId') END = i.occurrence_id)
|
|
1231
|
+
AND (i.created_at, i.id) > (?, ?)
|
|
1232
|
+
ORDER BY i.created_at ASC, i.id ASC LIMIT ?
|
|
1233
|
+
`).all(workerAutomationId, after?.createdAt ?? -1, after?.id ?? '', limit);
|
|
1234
|
+
const candidates = [];
|
|
1235
|
+
for (const row of rows) {
|
|
1236
|
+
const item = itemFromRow(row);
|
|
1237
|
+
const dispatch = this.getDispatch(item.id);
|
|
1238
|
+
if (dispatch?.state !== 'dispatched' || dispatch.occurrenceId === null || dispatch.occurrenceId !== item.occurrenceId)
|
|
1239
|
+
continue;
|
|
1240
|
+
candidates.push(Object.freeze({ item, automationId: dispatch.automationId === '' ? workerAutomationId : dispatch.automationId, occurrenceId: dispatch.occurrenceId,
|
|
1241
|
+
// This generation was armed at `occurredAt` (a re-arm sets it to the
|
|
1242
|
+
// re-arm or resume-due time) and is woken no earlier, so its run
|
|
1243
|
+
// cannot have started before it or before the item existed.
|
|
1244
|
+
notBefore: Math.max(item.createdAt, dispatch.occurredAt) }));
|
|
1245
|
+
}
|
|
1246
|
+
return Object.freeze(candidates);
|
|
1247
|
+
}
|
|
1248
|
+
/** The current (latest) late acceptance binding of one item, if any; malformed rows fail closed. */
|
|
1249
|
+
lateAcceptanceBinding(itemId) {
|
|
1250
|
+
const row = this.#statement(`SELECT sequence, detail, recorded_at FROM work_item_event
|
|
1251
|
+
WHERE item_id = ? AND kind = '${ACCEPTANCE_BOUND_EVENT}' ORDER BY sequence DESC LIMIT 1`).get(itemId);
|
|
1252
|
+
if (row === undefined)
|
|
1253
|
+
return undefined;
|
|
1254
|
+
const detail = eventDetail(row['detail']);
|
|
1255
|
+
const occurrenceId = detail?.['occurrenceId'];
|
|
1256
|
+
if (detail === null || typeof occurrenceId !== 'string' || occurrenceId === '' || !acceptanceBindingIsUsable(detail['binding'])) {
|
|
1257
|
+
// The detail names the offending row so the caller can report it once.
|
|
1258
|
+
fail('storage', MALFORMED_ACCEPTANCE_BINDING, `${ACCEPTANCE_BOUND_EVENT}#${String(row['sequence'])}`);
|
|
1259
|
+
}
|
|
1260
|
+
return Object.freeze({ itemId, occurrenceId, binding: Object.freeze({ ...detail['binding'] }), recordedAt: Number(row['recorded_at']) });
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Append one late acceptance binding for the item's current occurrence.
|
|
1264
|
+
* Refused for a mandate-bound or already-verified item, for a stale
|
|
1265
|
+
* occurrence, and for a contract another item already binds. The same
|
|
1266
|
+
* occurrence and contract again is idempotent; a later occurrence's row
|
|
1267
|
+
* supersedes earlier ones. State, lease and mandate are untouched.
|
|
1268
|
+
*/
|
|
1269
|
+
recordLateAcceptanceBinding(input) {
|
|
1270
|
+
if (!acceptanceBindingIsUsable(input.binding))
|
|
1271
|
+
fail('invalid-input', 'acceptance binding is malformed');
|
|
1272
|
+
const now = this.#now();
|
|
1273
|
+
return this.#transaction(() => {
|
|
1274
|
+
const item = this.#requireItem(input.itemId);
|
|
1275
|
+
if (item.authority?.mandate?.acceptance !== undefined)
|
|
1276
|
+
fail('invalid-state', 'work item mandate already binds an acceptance contract');
|
|
1277
|
+
if (this.acceptanceVerdict(item.id) !== undefined)
|
|
1278
|
+
fail('invalid-state', 'work item already has an acceptance verdict');
|
|
1279
|
+
const dispatch = this.getDispatch(item.id);
|
|
1280
|
+
if (item.occurrenceId !== input.occurrenceId || dispatch?.state !== 'dispatched' || dispatch.occurrenceId !== input.occurrenceId) {
|
|
1281
|
+
fail('invalid-state', 'occurrence is not the item\'s current dispatch');
|
|
1282
|
+
}
|
|
1283
|
+
const owner = this.findItemByAcceptanceContract(input.binding.contractId);
|
|
1284
|
+
const current = this.lateAcceptanceBinding(item.id);
|
|
1285
|
+
if (current !== undefined && current.occurrenceId === input.occurrenceId) {
|
|
1286
|
+
if (current.binding.contractId !== input.binding.contractId || current.binding.contractDigest !== input.binding.contractDigest) {
|
|
1287
|
+
fail('identity-mismatch', 'occurrence already binds a different acceptance contract');
|
|
1288
|
+
}
|
|
1289
|
+
return Object.freeze({ binding: current, created: false });
|
|
1290
|
+
}
|
|
1291
|
+
if (owner !== undefined)
|
|
1292
|
+
fail('identity-mismatch', 'acceptance contract is already bound to a work item');
|
|
1293
|
+
this.#appendEvent(item.id, ACCEPTANCE_BOUND_EVENT, 'acceptance', { occurrenceId: input.occurrenceId, binding: input.binding }, now);
|
|
1294
|
+
const recorded = this.lateAcceptanceBinding(item.id);
|
|
1295
|
+
if (recorded === undefined)
|
|
1296
|
+
fail('storage', 'acceptance binding could not be read back');
|
|
1297
|
+
return Object.freeze({ binding: recorded, created: true });
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Executed (`ready`, attempt ≥ 1) items whose frozen mandate binds an
|
|
1302
|
+
* acceptance contract, or whose latest late binding is for the item's
|
|
1303
|
+
* current occurrence, and that have no recorded verdict yet, in
|
|
1304
|
+
* (created_at, id) order strictly after `after`. A bounded full scan over
|
|
1305
|
+
* the stored basis JSON, like `findItemByAcceptanceContract` (no schema change).
|
|
1306
|
+
*/
|
|
1307
|
+
listUnsettledAcceptanceItems(after, limit) {
|
|
1308
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10)
|
|
1309
|
+
fail('invalid-input', 'acceptance settle limit must be between 1 and 10');
|
|
1310
|
+
return Object.freeze(this.#statement(`
|
|
1311
|
+
SELECT ${ITEM_ALIASES} FROM work_item i
|
|
1312
|
+
WHERE i.state = 'ready' AND i.attempt >= 1
|
|
1313
|
+
AND (CASE WHEN json_valid(i.authority_basis) THEN json_extract(i.authority_basis, '$.mandate.acceptance.contractId') END IS NOT NULL
|
|
1314
|
+
OR (SELECT CASE WHEN json_valid(b.detail) THEN json_extract(b.detail, '$.occurrenceId') END FROM work_item_event b
|
|
1315
|
+
WHERE b.item_id = i.id AND b.kind = '${ACCEPTANCE_BOUND_EVENT}' ORDER BY b.sequence DESC LIMIT 1) = i.occurrence_id)
|
|
1316
|
+
AND NOT EXISTS (SELECT 1 FROM work_item_event e WHERE e.item_id = i.id AND e.kind = '${ACCEPTANCE_VERIFIED_EVENT}')
|
|
1317
|
+
AND (i.created_at, i.id) > (?, ?)
|
|
1318
|
+
ORDER BY i.created_at ASC, i.id ASC LIMIT ?
|
|
1319
|
+
`).all(after?.createdAt ?? -1, after?.id ?? '', limit).map(itemFromRow));
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* WP21 slice 8 (`acceptance.holdReport`): executed items whose latest report
|
|
1323
|
+
* is a terminal `ready` self-report that has not been handed to Delivery and
|
|
1324
|
+
* that is either still waiting for the verifier (`ready`) or was moved to
|
|
1325
|
+
* `blocked` by the verdict gate (`acceptance-*`). One row per item, oldest
|
|
1326
|
+
* report first. `after` is the `(recordedAt, itemId)` keyset position of the
|
|
1327
|
+
* previous page, so a full page advances the caller's in-memory round-robin
|
|
1328
|
+
* position instead of rescanning rows that are still waiting. Read-only and
|
|
1329
|
+
* bounded; no schema change.
|
|
1330
|
+
*/
|
|
1331
|
+
listHeldAcceptanceReports(limit, after) {
|
|
1332
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1333
|
+
fail('invalid-input', 'held report limit must be between 1 and 100');
|
|
1334
|
+
const keyset = after === undefined ? '' : ' AND (r.recorded_at > ? OR (r.recorded_at = ? AND i.id > ?))';
|
|
1335
|
+
const rows = this.#statement(`
|
|
1336
|
+
SELECT ${ITEM_ALIASES}, r.revision AS heldRevision, r.summary AS heldSummary, r.truncated AS heldTruncated,
|
|
1337
|
+
r.artifact_paths AS heldArtifactPaths, r.idempotency_key AS heldIdempotencyKey, r.delivery_key AS heldDeliveryKey,
|
|
1338
|
+
r.recorded_at AS heldRecordedAt
|
|
1339
|
+
FROM work_item_report r JOIN work_item i ON i.id = r.item_id
|
|
1340
|
+
WHERE r.revision = (SELECT MAX(revision) FROM work_item_report WHERE item_id = i.id)
|
|
1341
|
+
AND r.status = 'ready' AND r.delivery_state IS NULL
|
|
1342
|
+
AND i.observed = 0 AND i.attempt >= 1
|
|
1343
|
+
AND (i.state = 'ready' OR (i.state = 'blocked' AND i.state_reason LIKE 'acceptance-%'))
|
|
1344
|
+
${keyset}
|
|
1345
|
+
ORDER BY r.recorded_at ASC, i.id ASC LIMIT ?
|
|
1346
|
+
`).all(...(after === undefined ? [] : [after.recordedAt, after.recordedAt, after.itemId]), limit);
|
|
1347
|
+
return Object.freeze(rows.map(row => Object.freeze({
|
|
1348
|
+
item: itemFromRow(row),
|
|
1349
|
+
report: Object.freeze({
|
|
1350
|
+
itemId: String(row['id']),
|
|
1351
|
+
revision: Number(row['heldRevision']),
|
|
1352
|
+
summary: String(row['heldSummary']),
|
|
1353
|
+
truncated: Number(row['heldTruncated']) === 1,
|
|
1354
|
+
artifactPaths: Object.freeze(JSON.parse(String(row['heldArtifactPaths']))),
|
|
1355
|
+
idempotencyKey: String(row['heldIdempotencyKey']),
|
|
1356
|
+
deliveryKey: String(row['heldDeliveryKey']),
|
|
1357
|
+
deliveryState: null,
|
|
1358
|
+
deliveryId: null,
|
|
1359
|
+
recordedAt: Number(row['heldRecordedAt']),
|
|
1360
|
+
status: 'ready',
|
|
1361
|
+
}),
|
|
1362
|
+
})));
|
|
1363
|
+
}
|
|
1364
|
+
/** Read one durable key/value row from the schema-meta table. */
|
|
1365
|
+
meta(key) {
|
|
1366
|
+
const row = this.#statement('SELECT value FROM work_item_schema_meta WHERE key = ?').get(key);
|
|
1367
|
+
return row === undefined ? null : String(row['value']);
|
|
1368
|
+
}
|
|
1369
|
+
/** Persist one durable key/value row; no schema change is required for it. */
|
|
1370
|
+
setMeta(key, value) {
|
|
1371
|
+
this.#statement('INSERT INTO work_item_schema_meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
|
1372
|
+
}
|
|
1373
|
+
/** Full SQLite bytes; TEXT projection is insufficient for authority-bearing JSON. */
|
|
1374
|
+
readCanonicalMeta(key) {
|
|
1375
|
+
const row = this.#statement('SELECT CAST(value AS BLOB) AS bytes FROM work_item_schema_meta WHERE key = ?').get(key);
|
|
1376
|
+
if (row === undefined)
|
|
1377
|
+
return undefined;
|
|
1378
|
+
return strictDiagnosisJson(this.#diagnosisBytes(row['bytes']));
|
|
1379
|
+
}
|
|
1380
|
+
#diagnosisBytes(value) {
|
|
1381
|
+
if (!(value instanceof Uint8Array) || value.byteLength > MAX_DIAGNOSIS_EVENT_BYTES)
|
|
1382
|
+
fail('storage', 'diagnosis byte representation is invalid');
|
|
1383
|
+
try {
|
|
1384
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(value);
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
fail('storage', 'diagnosis bytes are not valid UTF-8');
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
/** Only the exact first admitted event may confer a diagnostic plan. Never scan later events for one. */
|
|
1391
|
+
readDiagnosisPlan(itemId) {
|
|
1392
|
+
const item = this.#requireItem(itemId);
|
|
1393
|
+
const first = this.#statement('SELECT sequence,kind,actor,recorded_at,CAST(detail AS BLOB) AS bytes FROM work_item_event WHERE item_id = ? ORDER BY sequence ASC LIMIT 1').get(itemId);
|
|
1394
|
+
if (!first || first['bytes'] === null)
|
|
1395
|
+
return undefined;
|
|
1396
|
+
const bytes = this.#diagnosisBytes(first['bytes']);
|
|
1397
|
+
let detail;
|
|
1398
|
+
try {
|
|
1399
|
+
detail = JSON.parse(bytes);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
fail('storage', 'diagnosis admission JSON is invalid');
|
|
1403
|
+
}
|
|
1404
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail) || !Object.hasOwn(detail, 'diagnosisPlan'))
|
|
1405
|
+
return undefined;
|
|
1406
|
+
if (first['sequence'] !== 1 || first['kind'] !== 'admitted' || first['actor'] !== 'admission'
|
|
1407
|
+
|| first['recorded_at'] !== item.createdAt || item.observed)
|
|
1408
|
+
fail('storage', 'diagnosis requires the original admitted event');
|
|
1409
|
+
if (!this.#statement("SELECT 1 FROM work_item_event WHERE item_id=? AND sequence=1 AND kind='admitted' AND actor='admission'").get(itemId))
|
|
1410
|
+
fail('storage', 'diagnosis original actor or kind differs');
|
|
1411
|
+
const originals = this.#statement("SELECT sequence FROM work_item_event WHERE item_id = ? AND kind IN ('admitted','observed') LIMIT 2").all(itemId);
|
|
1412
|
+
if (originals.length !== 1 || acceptanceCanonicalJson(detail) !== bytes)
|
|
1413
|
+
fail('storage', 'diagnosis admission event is not unique and canonical');
|
|
1414
|
+
const fields = detail;
|
|
1415
|
+
if (Object.keys(fields).sort().join(',') !== 'dedupBucket,diagnosisPlan,kind,provenance'
|
|
1416
|
+
|| fields['kind'] !== item.kind || fields['dedupBucket'] !== item.dedupBucket)
|
|
1417
|
+
fail('storage', 'diagnosis admission fields differ');
|
|
1418
|
+
const plan = parseDiagnosisPlan(fields['diagnosisPlan']);
|
|
1419
|
+
if (!diagnosisPlanMatchesItem(plan, item))
|
|
1420
|
+
fail('storage', 'diagnosis plan belongs to another admission');
|
|
1421
|
+
// Bound equality uses complete SQLite strings, including any hidden NUL suffix.
|
|
1422
|
+
if (!this.#statement(`SELECT 1 FROM work_item WHERE id=? AND external_id=? AND origin_binding_id=? AND source_id=?
|
|
1423
|
+
AND kind=? AND worker_automation_id=? AND workspace=? AND agent_preset=? AND principal_id=? AND principal_record_id=?
|
|
1424
|
+
AND owner_route_id=? AND authority_basis=? AND work_dir=? AND dedup_key=?`)
|
|
1425
|
+
.get(itemId, plan.item.externalId, plan.item.originBindingId, plan.item.sourceId, plan.item.kind, plan.item.workerAutomationId, plan.item.scope.workspace, plan.item.scope.preset, plan.item.scope.principalId, plan.item.owner.principalRecordId, plan.item.scope.ownerRouteId, JSON.stringify(item.authority), plan.item.workDir, plan.item.dedupKey))
|
|
1426
|
+
fail('storage', 'diagnosis item text differs from its sealed plan');
|
|
1427
|
+
return plan;
|
|
1428
|
+
}
|
|
1429
|
+
diagnosisInterventionPending(itemId) {
|
|
1430
|
+
return this.#statement(`SELECT 1 FROM work_item_event AS e WHERE e.item_id=?
|
|
1431
|
+
AND e.kind IN ('diagnosis-unknown','diagnosis-refused') AND e.actor='diagnosis-runtime'
|
|
1432
|
+
AND e.sequence > COALESCE((SELECT MAX(c.sequence) FROM work_item_event AS c WHERE c.item_id=e.item_id
|
|
1433
|
+
AND c.kind='diagnosis-intervention-cleared' AND c.actor='explicit-withdraw'),0) LIMIT 1`).get(itemId) !== undefined;
|
|
1434
|
+
}
|
|
1435
|
+
diagnosisInterventionItems(limit = 100) {
|
|
1436
|
+
return Object.freeze(this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE EXISTS (
|
|
1437
|
+
SELECT 1 FROM work_item_event AS e WHERE e.item_id=work_item.id
|
|
1438
|
+
AND e.kind IN ('diagnosis-unknown','diagnosis-refused') AND e.actor='diagnosis-runtime'
|
|
1439
|
+
AND e.sequence > COALESCE((SELECT MAX(c.sequence) FROM work_item_event AS c WHERE c.item_id=e.item_id
|
|
1440
|
+
AND c.kind='diagnosis-intervention-cleared' AND c.actor='explicit-withdraw'),0))
|
|
1441
|
+
ORDER BY created_at ASC,id ASC LIMIT ?`).all(limit).map(itemFromRow));
|
|
1442
|
+
}
|
|
1443
|
+
diagnosisExplicitlyDisposed(itemId) {
|
|
1444
|
+
return this.#statement(`SELECT 1 FROM work_item_event AS e WHERE e.item_id=?
|
|
1445
|
+
AND e.kind='diagnosis-intervention-cleared' AND e.actor='explicit-withdraw'
|
|
1446
|
+
AND e.sequence > COALESCE((SELECT MAX(previous.sequence) FROM work_item_event AS previous
|
|
1447
|
+
WHERE previous.item_id=e.item_id AND previous.kind IN ('reported','diagnosis-unknown','diagnosis-refused')),0) LIMIT 1`).get(itemId) !== undefined;
|
|
1448
|
+
}
|
|
1449
|
+
clearDiagnosisIntervention(itemId) {
|
|
1450
|
+
this.#requireItem(itemId);
|
|
1451
|
+
if (this.diagnosisExplicitlyDisposed(itemId))
|
|
1452
|
+
return;
|
|
1453
|
+
this.appendEvent(itemId, 'diagnosis-intervention-cleared', 'explicit-withdraw', { reduction: 'no-further-diagnostic-work' });
|
|
1454
|
+
}
|
|
1455
|
+
/** Locate a real report, then prove its released/completed claim using complete NUL-delimited bytes. */
|
|
1456
|
+
diagnosisReportOccurrence(itemId, revision) {
|
|
1457
|
+
const row = this.#statement("SELECT status,CAST(idempotency_key AS BLOB) AS bytes FROM work_item_report WHERE item_id=? AND revision=? AND status IN ('waiting','ready','blocked','failed')")
|
|
1458
|
+
.get(itemId, revision);
|
|
1459
|
+
if (!row)
|
|
1460
|
+
fail('storage', 'diagnosis report is unavailable');
|
|
1461
|
+
const key = this.#diagnosisBytes(row['bytes']), separator = key.lastIndexOf('\0'), tail = key.slice(separator + 1);
|
|
1462
|
+
if (!/^[1-9][0-9]*$/u.test(tail) || !Number.isSafeInteger(Number(tail)) || !key.endsWith(`\0${itemId}\0${tail}`))
|
|
1463
|
+
fail('storage', 'diagnosis report claim binding is invalid');
|
|
1464
|
+
const claim = this.#statement(`SELECT CAST(run_id AS BLOB) AS run_bytes,CAST(occurrence_id AS BLOB) AS occurrence_bytes
|
|
1465
|
+
FROM work_item_claim WHERE claim_id=? AND item_id=? AND state IN ('released','completed') AND run_id=occurrence_id`)
|
|
1466
|
+
.get(Number(tail), itemId);
|
|
1467
|
+
if (!claim || !this.#statement('SELECT 1 FROM work_item_report WHERE item_id=? AND revision=? AND idempotency_key=?').get(itemId, revision, key))
|
|
1468
|
+
fail('storage', 'diagnosis report is not bound to its completed claim');
|
|
1469
|
+
const runId = this.#diagnosisBytes(claim['run_bytes']), occurrenceId = this.#diagnosisBytes(claim['occurrence_bytes']);
|
|
1470
|
+
if (runId !== occurrenceId || !this.#statement('SELECT 1 FROM work_item_claim WHERE claim_id=? AND item_id=? AND run_id=? AND occurrence_id=?')
|
|
1471
|
+
.get(Number(tail), itemId, runId, occurrenceId))
|
|
1472
|
+
fail('storage', 'diagnosis report occurrence bytes differ');
|
|
1473
|
+
return Object.freeze({ itemId, revision, status: String(row['status']), occurrenceId });
|
|
1474
|
+
}
|
|
1475
|
+
/** Row order is database-owned; wall-clock jumps cannot choose a different last report. */
|
|
1476
|
+
latestWorkerDiagnosisReport(workerId) {
|
|
1477
|
+
const row = this.#statement(`SELECT report.item_id,report.revision FROM work_item_report AS report
|
|
1478
|
+
JOIN work_item AS item ON item.id=report.item_id WHERE (? IS NULL OR item.worker_automation_id=?)
|
|
1479
|
+
AND report.status IN ('waiting','ready','blocked','failed') ORDER BY report.rowid DESC LIMIT 1`).get(workerId ?? null, workerId ?? null);
|
|
1480
|
+
return row ? this.diagnosisReportOccurrence(String(row['item_id']), Number(row['revision'])) : undefined;
|
|
1481
|
+
}
|
|
1482
|
+
/** Drop one durable key/value row when its cursor no longer applies. */
|
|
1483
|
+
deleteMeta(key) {
|
|
1484
|
+
this.#statement('DELETE FROM work_item_schema_meta WHERE key = ?').run(key);
|
|
1485
|
+
}
|
|
1486
|
+
/** Count latest outcome revisions in one evidence state. */
|
|
1487
|
+
countLatestOutcomes(evidenceState) {
|
|
1488
|
+
const row = this.#statement(`
|
|
1489
|
+
SELECT COUNT(*) AS count FROM work_item_outcome o
|
|
1490
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
1491
|
+
WHERE o.evidence_state = ?
|
|
1492
|
+
`).get(evidenceState);
|
|
1493
|
+
return Number(row['count']);
|
|
1494
|
+
}
|
|
1495
|
+
findItemByOccurrence(occurrenceId) {
|
|
1496
|
+
const row = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE occurrence_id = ?`).get(occurrenceId);
|
|
1497
|
+
if (row !== undefined)
|
|
1498
|
+
return itemFromRow(row);
|
|
1499
|
+
const dispatch = this.#statement('SELECT item_id FROM work_item_dispatch WHERE occurrence_id = ?').get(occurrenceId);
|
|
1500
|
+
return dispatch === undefined ? undefined : this.findItemById(String(dispatch['item_id']));
|
|
1501
|
+
}
|
|
1502
|
+
listItems(options = {}) {
|
|
1503
|
+
const limit = options.limit ?? 50;
|
|
1504
|
+
const states = options.states;
|
|
1505
|
+
const includeObserved = options.includeObserved ?? false;
|
|
1506
|
+
let sql = `SELECT ${ITEM_ALIASES} FROM work_item`;
|
|
1507
|
+
const conditions = [];
|
|
1508
|
+
const parameters = [];
|
|
1509
|
+
if (states !== undefined && states.length > 0) {
|
|
1510
|
+
conditions.push(`state IN (${states.map(() => '?').join(', ')})`);
|
|
1511
|
+
parameters.push(...states);
|
|
1512
|
+
}
|
|
1513
|
+
if (!includeObserved)
|
|
1514
|
+
conditions.push('observed = 0');
|
|
1515
|
+
if (conditions.length > 0)
|
|
1516
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
1517
|
+
sql += ' ORDER BY created_at DESC, id DESC LIMIT ?';
|
|
1518
|
+
parameters.push(limit);
|
|
1519
|
+
return Object.freeze(this.#statement(sql).all(...parameters).map(itemFromRow));
|
|
1520
|
+
}
|
|
1521
|
+
lastAdmitAt(kind) {
|
|
1522
|
+
const row = kind === undefined
|
|
1523
|
+
? this.#statement('SELECT MAX(created_at) AS latest FROM work_item').get()
|
|
1524
|
+
: this.#statement('SELECT MAX(created_at) AS latest FROM work_item WHERE kind = ?').get(kind);
|
|
1525
|
+
return optNumber(row['latest']);
|
|
1526
|
+
}
|
|
1527
|
+
/** Latest successful dispatch timestamp, for bounded health reporting. */
|
|
1528
|
+
lastDispatchedAt() {
|
|
1529
|
+
const row = this.#statement("SELECT MAX(updated_at) AS latest FROM work_item_dispatch WHERE state = 'dispatched'").get();
|
|
1530
|
+
return optNumber(row['latest']);
|
|
1531
|
+
}
|
|
1532
|
+
countItemsSince(createdAt) {
|
|
1533
|
+
const row = this.#statement('SELECT COUNT(*) AS count FROM work_item WHERE created_at >= ? AND observed = 0').get(createdAt);
|
|
1534
|
+
return Number(row['count']);
|
|
1535
|
+
}
|
|
1536
|
+
countOpenItems() {
|
|
1537
|
+
const row = this.#statement(`SELECT COUNT(*) AS count FROM work_item WHERE observed = 0 AND state IN ('queued', 'claimed', 'working', 'waiting')`).get();
|
|
1538
|
+
return Number(row['count']);
|
|
1539
|
+
}
|
|
1540
|
+
/** Open items admitted before schema v7, i.e. with no persisted mandate. */
|
|
1541
|
+
countAuthorityLegacy() {
|
|
1542
|
+
const row = this.#statement(`
|
|
1543
|
+
SELECT COUNT(*) AS count FROM work_item
|
|
1544
|
+
WHERE observed = 0 AND state IN (${OPEN_STATE_PLACEHOLDERS}) AND authority_basis IS NULL
|
|
1545
|
+
`).get(...OPEN_STATES);
|
|
1546
|
+
return Number(row['count']);
|
|
1547
|
+
}
|
|
1548
|
+
/** Open items parked because their admission mandate failed revalidation. */
|
|
1549
|
+
countAuthorityParked() {
|
|
1550
|
+
const row = this.#statement(`
|
|
1551
|
+
SELECT COUNT(*) AS count FROM work_item
|
|
1552
|
+
WHERE observed = 0 AND state = 'waiting' AND state_reason IN ('${AUTHORITY_REVOKED}', '${AUTHORITY_UNAVAILABLE}')
|
|
1553
|
+
`).get();
|
|
1554
|
+
return Number(row['count']);
|
|
1555
|
+
}
|
|
1556
|
+
/** Follows of one item, in admission order (their stable position). */
|
|
1557
|
+
follows(itemId) {
|
|
1558
|
+
return Object.freeze(this.#statement('SELECT * FROM work_item_follow WHERE item_id = ? ORDER BY position ASC').all(itemId).map(followFromRow));
|
|
1559
|
+
}
|
|
1560
|
+
countOpenFollows(itemId) {
|
|
1561
|
+
const row = this.#statement("SELECT COUNT(*) AS count FROM work_item_follow WHERE item_id = ? AND state = 'open'").get(itemId);
|
|
1562
|
+
return Number(row['count']);
|
|
1563
|
+
}
|
|
1564
|
+
/** Global follow counts for health reporting (open rows of live items only). */
|
|
1565
|
+
countFollows() {
|
|
1566
|
+
const liveStates = OPEN_STATES.map(() => '?').join(', ');
|
|
1567
|
+
const open = this.#statement(`
|
|
1568
|
+
SELECT COUNT(*) AS count FROM work_item_follow AS follow
|
|
1569
|
+
JOIN work_item AS item ON item.id = follow.item_id
|
|
1570
|
+
WHERE follow.state = 'open' AND item.observed = 0 AND item.state IN (${liveStates})
|
|
1571
|
+
`).get(...OPEN_STATES);
|
|
1572
|
+
const closed = this.#statement("SELECT COUNT(*) AS count FROM work_item_follow WHERE state = 'closed'").get();
|
|
1573
|
+
return { open: Number(open['count']), closed: Number(closed['count']) };
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Identity of every open follow on a live item, for health projections.
|
|
1577
|
+
*
|
|
1578
|
+
* A follow row outlives the item it parked when that item is withdrawn or
|
|
1579
|
+
* otherwise settled by another path (no resolver ever observed those threads).
|
|
1580
|
+
* Reporting those rows as gaps would make health permanently degraded over
|
|
1581
|
+
* dead items, so only follows of still-open items count.
|
|
1582
|
+
*/
|
|
1583
|
+
openFollowIdentities() {
|
|
1584
|
+
const liveStates = OPEN_STATES.map(() => '?').join(', ');
|
|
1585
|
+
const rows = this.#statement(`
|
|
1586
|
+
SELECT follow.source, follow.source_generation FROM work_item_follow AS follow
|
|
1587
|
+
JOIN work_item AS item ON item.id = follow.item_id
|
|
1588
|
+
WHERE follow.state = 'open' AND item.observed = 0 AND item.state IN (${liveStates})
|
|
1589
|
+
`).all(...OPEN_STATES);
|
|
1590
|
+
return Object.freeze(rows.map(row => Object.freeze({
|
|
1591
|
+
source: String(row['source']),
|
|
1592
|
+
sourceGeneration: String(row['source_generation']),
|
|
1593
|
+
})));
|
|
1594
|
+
}
|
|
1595
|
+
/** Open follows across live items, oldest first, bounded for diagnostics. */
|
|
1596
|
+
listOpenFollows(limit) {
|
|
1597
|
+
const liveStates = OPEN_STATES.map(() => '?').join(', ');
|
|
1598
|
+
const rows = this.#statement(`
|
|
1599
|
+
SELECT follow.* FROM work_item_follow AS follow
|
|
1600
|
+
JOIN work_item AS item ON item.id = follow.item_id
|
|
1601
|
+
WHERE follow.state = 'open' AND item.observed = 0 AND item.state IN (${liveStates})
|
|
1602
|
+
ORDER BY follow.opened_at ASC, follow.item_id ASC, follow.position ASC LIMIT ?
|
|
1603
|
+
`).all(...OPEN_STATES, limit);
|
|
1604
|
+
return Object.freeze(rows.map(followFromRow));
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Close one open follow exactly once.
|
|
1608
|
+
*
|
|
1609
|
+
* The CAS on `state = 'open'` is what makes duplicate and out-of-order
|
|
1610
|
+
* closures harmless: a second observation of the same closure changes no row
|
|
1611
|
+
* and reports `undefined`, so no settlement signal can be produced twice.
|
|
1612
|
+
*/
|
|
1613
|
+
closeFollow(itemId, position, closure, now = this.#now()) {
|
|
1614
|
+
if (closure.state !== 'closed')
|
|
1615
|
+
fail('invalid-input', 'only a closed thread observation can close a follow');
|
|
1616
|
+
return this.#transaction(() => {
|
|
1617
|
+
const changes = this.#statement(`
|
|
1618
|
+
UPDATE work_item_follow SET state = 'closed', outcome = ?, digest = ?, closed_at = ?
|
|
1619
|
+
WHERE item_id = ? AND position = ? AND state = 'open'
|
|
1620
|
+
`).run(closure.outcome, closure.digest, now, itemId, position);
|
|
1621
|
+
if (Number(changes.changes) === 0)
|
|
1622
|
+
return undefined;
|
|
1623
|
+
return this.follows(itemId)[position];
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Waiting items that owe a thread-closure settlement.
|
|
1628
|
+
*
|
|
1629
|
+
* `waiting` with a terminal `last_report_status` is produced only by the
|
|
1630
|
+
* deferred report branch, so this is exactly the deferred set. Selecting on
|
|
1631
|
+
* the report status rather than on open follow rows keeps the sweep
|
|
1632
|
+
* crash-safe: a crash after closing the last follow but before appending the
|
|
1633
|
+
* outcome still finds the item on the next pass. A live lease is excluded
|
|
1634
|
+
* (additionally to the state check) so a re-claimed item is never settled
|
|
1635
|
+
* underneath its new worker.
|
|
1636
|
+
*/
|
|
1637
|
+
itemsAwaitingThreadClosure(limit) {
|
|
1638
|
+
const rows = this.#statement(`
|
|
1639
|
+
SELECT ${ITEM_ALIASES} FROM work_item
|
|
1640
|
+
WHERE observed = 0 AND state = 'waiting' AND lease_until IS NULL
|
|
1641
|
+
AND last_report_status IN ('ready', 'blocked', 'failed')
|
|
1642
|
+
ORDER BY updated_at ASC, id ASC LIMIT ?
|
|
1643
|
+
`).all(limit);
|
|
1644
|
+
return Object.freeze(rows.map(itemFromRow));
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Settle one deferred item because every followed thread is closed.
|
|
1648
|
+
*
|
|
1649
|
+
* This appends at most one outcome revision, and a second call is a no-op.
|
|
1650
|
+
* The transition reuses the recorded terminal report status, so the item
|
|
1651
|
+
* still ends in the state the worker reported; the closure itself is never
|
|
1652
|
+
* treated as evidence of objective achievement.
|
|
1653
|
+
*/
|
|
1654
|
+
settleThreadClosure(itemId) {
|
|
1655
|
+
return this.#transaction(() => {
|
|
1656
|
+
const item = this.#requireItem(itemId);
|
|
1657
|
+
if (item.state !== 'waiting')
|
|
1658
|
+
fail('invalid-state', `item is ${item.state}, not awaiting thread closure`);
|
|
1659
|
+
if (item.observed)
|
|
1660
|
+
fail('invalid-state', 'observed items never settle from a thread closure');
|
|
1661
|
+
const status = item.lastReportStatus;
|
|
1662
|
+
if (status === null || !DEFERRED_REPORT_STATUSES.includes(status)) {
|
|
1663
|
+
fail('invalid-state', 'item has no deferred terminal report to settle');
|
|
1664
|
+
}
|
|
1665
|
+
if (this.countOpenFollows(item.id) > 0)
|
|
1666
|
+
fail('invalid-state', 'item still follows an open thread');
|
|
1667
|
+
// Exactly-once: a settlement revision that already exists is authoritative,
|
|
1668
|
+
// so a replay (duplicate closure, or a second sweep) appends nothing.
|
|
1669
|
+
if (this.latestOutcome(item.id) !== undefined)
|
|
1670
|
+
return undefined;
|
|
1671
|
+
const now = this.#now();
|
|
1672
|
+
const nextState = status === 'ready' ? 'ready' : status === 'blocked' ? 'blocked' : 'failed';
|
|
1673
|
+
const reason = status === 'blocked' ? 'reported-blocked' : status === 'failed' ? 'reported-failed' : null;
|
|
1674
|
+
this.#statement('UPDATE work_item SET state = ?, state_reason = ?, lease_until = NULL, updated_at = ? WHERE id = ?')
|
|
1675
|
+
.run(nextState, reason, now, item.id);
|
|
1676
|
+
this.#statement("UPDATE work_item_claim SET state = 'released', updated_at = ? WHERE item_id = ? AND state = 'active'")
|
|
1677
|
+
.run(now, item.id);
|
|
1678
|
+
this.#appendEvent(item.id, 'settled', 'settlement', { reason: 'closed-by-thread', reportStatus: status }, now);
|
|
1679
|
+
const draft = threadClosureOutcome({
|
|
1680
|
+
status: status,
|
|
1681
|
+
provenance: this.#admissionProvenance(item.id),
|
|
1682
|
+
now,
|
|
1683
|
+
});
|
|
1684
|
+
return this.#insertOutcome(item.id, draft, now);
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Ambiguous sends that still describe an actionable gap.
|
|
1689
|
+
*
|
|
1690
|
+
* A failed row is dead weight once its item is terminal or observed (for
|
|
1691
|
+
* example a withdrawal after an ambiguous send): no retry is owed and the
|
|
1692
|
+
* owner already settled the item, so counting it would report a permanent
|
|
1693
|
+
* false gap. The gate mirrors `wakePending()`'s eligibility check.
|
|
1694
|
+
*/
|
|
1695
|
+
countDispatchUnknown() {
|
|
1696
|
+
const row = this.#statement(`
|
|
1697
|
+
SELECT COUNT(*) AS count FROM work_item_dispatch d
|
|
1698
|
+
JOIN work_item i ON i.id = d.item_id
|
|
1699
|
+
WHERE d.state = 'failed' AND d.reason = 'unknown_after_send'
|
|
1700
|
+
AND i.observed = 0 AND i.state IN (${OPEN_STATE_PLACEHOLDERS})
|
|
1701
|
+
`).get(...OPEN_STATES);
|
|
1702
|
+
return Number(row['count']);
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Park one item whose admission mandate failed revalidation.
|
|
1706
|
+
*
|
|
1707
|
+
* Parking is never terminal: the item stays `waiting` with a typed reason, its
|
|
1708
|
+
* pending dispatch row is left intact, and it becomes claimable/wakeable again
|
|
1709
|
+
* as soon as the identical mandate allows. Repeating the same park only
|
|
1710
|
+
* refreshes the audit timestamp, so a sweep that observes the same denial on
|
|
1711
|
+
* every tick does not flood the event log.
|
|
1712
|
+
*/
|
|
1713
|
+
parkAuthority(itemId, reason, detail, now) {
|
|
1714
|
+
return this.#transaction(() => {
|
|
1715
|
+
const item = this.#requireItem(itemId);
|
|
1716
|
+
if (item.state === 'waiting' && item.stateReason === reason) {
|
|
1717
|
+
this.#statement('UPDATE work_item SET authority_checked_at = ?, updated_at = ? WHERE id = ?').run(now, now, itemId);
|
|
1718
|
+
return this.#requireItem(itemId);
|
|
1719
|
+
}
|
|
1720
|
+
this.#statement(`
|
|
1721
|
+
UPDATE work_item
|
|
1722
|
+
SET state = 'waiting', state_reason = ?, authority_checked_at = ?, updated_at = ?
|
|
1723
|
+
WHERE id = ?
|
|
1724
|
+
`).run(reason, now, now, itemId);
|
|
1725
|
+
this.#appendEvent(itemId, reason, 'authority', {
|
|
1726
|
+
...detail,
|
|
1727
|
+
previousState: item.state,
|
|
1728
|
+
previousReason: item.stateReason,
|
|
1729
|
+
}, now);
|
|
1730
|
+
return this.#requireItem(itemId);
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Record a successful revalidation of one item's mandate.
|
|
1735
|
+
*
|
|
1736
|
+
* Only a wait this gate itself created is lifted; a slice-9
|
|
1737
|
+
* `following-thread` wait is a different concern and stays untouched.
|
|
1738
|
+
*/
|
|
1739
|
+
restoreAuthority(itemId, now) {
|
|
1740
|
+
return this.#transaction(() => {
|
|
1741
|
+
const item = this.#requireItem(itemId);
|
|
1742
|
+
this.#statement('UPDATE work_item SET authority_checked_at = ?, updated_at = ? WHERE id = ?').run(now, now, itemId);
|
|
1743
|
+
if (item.state !== 'waiting' || (item.stateReason !== AUTHORITY_REVOKED && item.stateReason !== AUTHORITY_UNAVAILABLE)) {
|
|
1744
|
+
return this.#requireItem(itemId);
|
|
1745
|
+
}
|
|
1746
|
+
this.#statement("UPDATE work_item SET state = 'queued', state_reason = NULL, updated_at = ? WHERE id = ?").run(now, itemId);
|
|
1747
|
+
this.#appendEvent(itemId, 'authority-restored', 'authority', { previousReason: item.stateReason }, now);
|
|
1748
|
+
return this.#requireItem(itemId);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
/** Latest authority denial for bounded health reporting. */
|
|
1752
|
+
lastAuthorityDenial() {
|
|
1753
|
+
const row = this.#statement(`
|
|
1754
|
+
SELECT kind, detail, recorded_at FROM work_item_event
|
|
1755
|
+
WHERE kind IN ('${AUTHORITY_REVOKED}', '${AUTHORITY_UNAVAILABLE}')
|
|
1756
|
+
ORDER BY recorded_at DESC, sequence DESC LIMIT 1
|
|
1757
|
+
`).get();
|
|
1758
|
+
if (row === undefined)
|
|
1759
|
+
return null;
|
|
1760
|
+
const detail = optString(row['detail']);
|
|
1761
|
+
let reason = String(row['kind']);
|
|
1762
|
+
if (detail !== null) {
|
|
1763
|
+
try {
|
|
1764
|
+
const parsed = JSON.parse(detail);
|
|
1765
|
+
if (typeof parsed.reason === 'string' && parsed.reason.length > 0)
|
|
1766
|
+
reason = parsed.reason;
|
|
1767
|
+
}
|
|
1768
|
+
catch {
|
|
1769
|
+
// Keep the event kind as the bounded fallback.
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return Object.freeze({ at: Number(row['recorded_at']), reason });
|
|
1773
|
+
}
|
|
1774
|
+
#continuationRow(row) {
|
|
1775
|
+
const text = (name) => continuationUtf8(row[name]);
|
|
1776
|
+
const record = validateContinuationInput({ itemId: text('itemId'), parentItemId: text('parentItemId'), rootItemId: text('rootItemId'), sourceKey: text('sourceKey'), sourceDigest: text('sourceDigest'),
|
|
1777
|
+
inputText: text('inputText'), inputSha256: text('inputSha256'), parentReportRevision: optNumber(row['parentReportRevision']), parentOutcomeRevision: optNumber(row['parentOutcomeRevision']),
|
|
1778
|
+
planJson: text('planJson'), planDigest: text('planDigest'), createdAt: Number(row['createdAt']) });
|
|
1779
|
+
this.#assertContinuationItemText(record.itemId);
|
|
1780
|
+
return record;
|
|
1781
|
+
}
|
|
1782
|
+
#assertContinuationItemText(itemId) {
|
|
1783
|
+
const row = this.#statement(`SELECT ${CONTINUATION_ITEM_TEXT_COLUMNS.join(',')} FROM work_item WHERE id=?`).get(itemId);
|
|
1784
|
+
if (!row)
|
|
1785
|
+
fail('storage', 'continuation item is unavailable');
|
|
1786
|
+
// Node 24 can truncate TEXT at NUL. Compare full SQLite bytes with the
|
|
1787
|
+
// projected strings before the controller compares its frozen item basis.
|
|
1788
|
+
// Bind authority JSON as stored, preserving legitimate whitespace/key order.
|
|
1789
|
+
const same = this.#statement(`SELECT 1 FROM work_item WHERE id=? AND ${CONTINUATION_ITEM_TEXT_COLUMNS
|
|
1790
|
+
.map(column => `CAST(${column} AS BLOB) IS CAST(? AS BLOB)`).join(' AND ')}`)
|
|
1791
|
+
.get(itemId, ...CONTINUATION_ITEM_TEXT_COLUMNS.map(column => row[column] === null ? null : String(row[column])));
|
|
1792
|
+
if (!same)
|
|
1793
|
+
fail('storage', 'continuation item text differs from its complete SQLite bytes');
|
|
1794
|
+
}
|
|
1795
|
+
#continuationSelect() {
|
|
1796
|
+
return `SELECT CAST(item_id AS BLOB) AS itemId,CAST(parent_item_id AS BLOB) AS parentItemId,CAST(root_item_id AS BLOB) AS rootItemId,
|
|
1797
|
+
CAST(source_key AS BLOB) AS sourceKey,CAST(source_digest AS BLOB) AS sourceDigest,input_text AS inputText,CAST(input_sha256 AS BLOB) AS inputSha256,
|
|
1798
|
+
parent_report_revision AS parentReportRevision,parent_outcome_revision AS parentOutcomeRevision,plan_json AS planJson,CAST(plan_digest AS BLOB) AS planDigest,created_at AS createdAt FROM work_item_continuation`;
|
|
1799
|
+
}
|
|
1800
|
+
getContinuation(itemId) {
|
|
1801
|
+
const row = this.#statement(this.#continuationSelect() + ' WHERE item_id=?').get(itemId);
|
|
1802
|
+
return row ? this.#continuationRow(row) : undefined;
|
|
1803
|
+
}
|
|
1804
|
+
findContinuationBySourceKey(key) {
|
|
1805
|
+
const row = this.#statement(this.#continuationSelect() + ' WHERE source_key=?').get(key);
|
|
1806
|
+
return row ? this.#continuationRow(row) : undefined;
|
|
1807
|
+
}
|
|
1808
|
+
/** Most recently admitted dispatched child, by immutable dispatch rowid; no wall-clock ordering. */
|
|
1809
|
+
latestContinuationDispatch(automationId) {
|
|
1810
|
+
const row = this.#statement(`SELECT CAST(d.item_id AS BLOB) AS bytes FROM work_item_dispatch d JOIN work_item_continuation c ON c.item_id=d.item_id
|
|
1811
|
+
WHERE d.automation_id=? AND d.state='dispatched' ORDER BY d.rowid DESC LIMIT 1`).get(automationId);
|
|
1812
|
+
if (!row)
|
|
1813
|
+
return undefined;
|
|
1814
|
+
return this.#continuationDispatch(continuationUtf8(row['bytes']));
|
|
1815
|
+
}
|
|
1816
|
+
continuationDispatchUncertain(automationId) {
|
|
1817
|
+
return !!this.#statement(`SELECT 1 FROM work_item_dispatch d JOIN work_item_continuation c ON c.item_id=d.item_id
|
|
1818
|
+
WHERE d.automation_id=? AND d.state='failed' AND d.reason='unknown_after_send' LIMIT 1`).get(automationId);
|
|
1819
|
+
}
|
|
1820
|
+
listPendingContinuations(automationId, limit = 10, after) {
|
|
1821
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || after !== undefined && (!Number.isSafeInteger(after.createdAt) || after.createdAt < 0 || typeof after.id !== 'string' || !after.id || /[\p{Cc}]/u.test(after.id)))
|
|
1822
|
+
fail('invalid-input', 'invalid continuation pending limit or cursor');
|
|
1823
|
+
const rows = this.#statement(`SELECT CAST(d.item_id AS BLOB) AS bytes FROM work_item_dispatch d JOIN work_item_continuation c ON c.item_id=d.item_id JOIN work_item i ON i.id=c.item_id
|
|
1824
|
+
WHERE d.automation_id=? AND d.state='pending' AND (? IS NULL OR i.created_at>? OR (i.created_at=? AND i.id>?))
|
|
1825
|
+
ORDER BY i.created_at ASC,i.id ASC LIMIT ?`).all(automationId, after?.createdAt ?? null, after?.createdAt ?? null, after?.createdAt ?? null, after?.id ?? '', limit);
|
|
1826
|
+
return Object.freeze(rows.map(row => this.#continuationDispatch(continuationUtf8(row['bytes']))));
|
|
1827
|
+
}
|
|
1828
|
+
#continuationDispatch(itemId) {
|
|
1829
|
+
const item = this.#requireItem(itemId), dispatch = this.getDispatch(itemId), continuation = this.getContinuation(itemId);
|
|
1830
|
+
if (!dispatch || !continuation)
|
|
1831
|
+
fail('storage', 'continuation dispatch is unavailable');
|
|
1832
|
+
return Object.freeze({ item, dispatch, continuation });
|
|
1833
|
+
}
|
|
1834
|
+
#assertContinuationParent(child, frozen) {
|
|
1835
|
+
const parent = this.#requireItem(frozen.parentItemId);
|
|
1836
|
+
if (OPEN_STATES.includes(parent.state) || parent.observed || child.owner.workspace !== child.scope.workspace || child.owner.agentPreset !== child.scope.preset)
|
|
1837
|
+
fail('storage', 'continuation parent is not terminal or owner scope differs');
|
|
1838
|
+
const matches = (id) => this.#statement(`SELECT 1 FROM work_item WHERE id=? AND principal_id=? AND principal_record_id=? AND principal_version=?
|
|
1839
|
+
AND workspace=? AND agent_preset=? AND owner_route_id=? AND owner_generation<=?`).get(id, child.scope.principalId, child.owner.principalRecordId, child.owner.principalVersion, child.scope.workspace, child.scope.preset, child.scope.ownerRouteId, child.owner.generation);
|
|
1840
|
+
if (!matches(frozen.parentItemId) || !matches(frozen.rootItemId)
|
|
1841
|
+
|| parent.owner.generation === child.owner.generation && !this.#statement('SELECT 1 FROM work_item WHERE id=? AND binding_version=? AND origin_binding_id=?')
|
|
1842
|
+
.get(parent.id, child.owner.bindingVersion, child.originBindingId))
|
|
1843
|
+
fail('storage', 'continuation owner or generation differs');
|
|
1844
|
+
const inherited = this.getContinuation(parent.id);
|
|
1845
|
+
if (frozen.rootItemId !== (inherited?.rootItemId ?? parent.id))
|
|
1846
|
+
fail('storage', 'continuation root differs from parent chain');
|
|
1847
|
+
for (const [table, cutoff] of [['work_item_report', frozen.parentReportRevision], ['work_item_outcome', frozen.parentOutcomeRevision]]) {
|
|
1848
|
+
if (cutoff === null ? this.#statement(`SELECT 1 FROM ${table} WHERE item_id=? LIMIT 1`).get(parent.id)
|
|
1849
|
+
: !this.#statement(`SELECT 1 FROM ${table} WHERE item_id=? AND revision=?`).get(parent.id, cutoff))
|
|
1850
|
+
fail('storage', 'continuation history cutoff is unavailable');
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
#continuationHistory(itemId, afterRevision, limit) {
|
|
1854
|
+
if (!Number.isSafeInteger(afterRevision) || afterRevision < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1855
|
+
fail('invalid-input', 'invalid continuation history page');
|
|
1856
|
+
return this.getContinuation(itemId) ?? fail('not-found', 'continuation is unavailable');
|
|
1857
|
+
}
|
|
1858
|
+
continuationParentBrief(itemId) {
|
|
1859
|
+
const record = this.getContinuation(itemId) ?? fail('not-found', 'continuation is unavailable');
|
|
1860
|
+
const row = this.#statement('SELECT CAST(brief AS BLOB) AS bytes FROM work_item WHERE id=?').get(record.parentItemId);
|
|
1861
|
+
if (!row)
|
|
1862
|
+
fail('storage', 'continuation parent is unavailable');
|
|
1863
|
+
return continuationUtf8(row['bytes']);
|
|
1864
|
+
}
|
|
1865
|
+
continuationParentReports(itemId, afterRevision = 0, limit = 25) {
|
|
1866
|
+
const record = this.#continuationHistory(itemId, afterRevision, limit);
|
|
1867
|
+
if (record.parentReportRevision === null)
|
|
1868
|
+
return Object.freeze([]);
|
|
1869
|
+
const rows = this.#statement(`SELECT *,CAST(summary AS BLOB) AS summary_bytes,CAST(idempotency_key AS BLOB) AS key_bytes FROM work_item_report
|
|
1870
|
+
WHERE item_id=? AND revision>? AND revision<=? ORDER BY revision ASC LIMIT ?`).all(record.parentItemId, afterRevision, record.parentReportRevision, limit);
|
|
1871
|
+
return Object.freeze(rows.map(row => Object.freeze({ ...reportFromRow(row), summary: continuationUtf8(row['summary_bytes']), idempotencyKey: continuationUtf8(row['key_bytes']) })));
|
|
1872
|
+
}
|
|
1873
|
+
continuationParentOutcomes(itemId, afterRevision = 0, limit = 25) {
|
|
1874
|
+
const record = this.#continuationHistory(itemId, afterRevision, limit);
|
|
1875
|
+
if (record.parentOutcomeRevision === null)
|
|
1876
|
+
return Object.freeze([]);
|
|
1877
|
+
return Object.freeze(this.#statement(`SELECT * FROM work_item_outcome WHERE item_id=? AND revision>? AND revision<=? ORDER BY revision ASC LIMIT ?`)
|
|
1878
|
+
.all(record.parentItemId, afterRevision, record.parentOutcomeRevision, limit).map(outcomeFromRow));
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Insert one admitted item, its lifecycle event and its single dispatch row
|
|
1882
|
+
* in one transaction. A crash between these writes is impossible.
|
|
1883
|
+
*/
|
|
1884
|
+
admitItem(record, event, dispatch, follows = [], diagnosisPlan, continuation) {
|
|
1885
|
+
if (continuation && diagnosisPlan)
|
|
1886
|
+
fail('storage', 'continuation and diagnosis plans are exclusive');
|
|
1887
|
+
if (continuation && (event.kind !== 'admitted' || event.actor !== 'admission' || record.observed || record.state !== 'queued' || record.attempt !== 0 || continuation.itemId !== record.id || continuation.createdAt !== record.createdAt))
|
|
1888
|
+
fail('storage', 'continuation admission differs');
|
|
1889
|
+
if (diagnosisPlan && (event.kind !== 'admitted' || event.actor !== 'admission' || record.observed
|
|
1890
|
+
|| !diagnosisPlanMatchesItem(parseDiagnosisPlan(diagnosisPlan), record)))
|
|
1891
|
+
fail('storage', 'diagnosis admission event does not match its item');
|
|
1892
|
+
return this.#transaction(() => {
|
|
1893
|
+
const frozen = continuation === undefined ? undefined : validateContinuationInput(continuation);
|
|
1894
|
+
if (frozen) {
|
|
1895
|
+
this.#assertContinuationParent(record, frozen);
|
|
1896
|
+
const prior = this.findContinuationBySourceKey(frozen.sourceKey);
|
|
1897
|
+
if (prior) {
|
|
1898
|
+
if (prior.parentItemId !== frozen.parentItemId || prior.sourceDigest !== frozen.sourceDigest || prior.inputSha256 !== frozen.inputSha256 || prior.inputText !== frozen.inputText)
|
|
1899
|
+
fail('storage', 'continuation source collision');
|
|
1900
|
+
return this.#requireItem(prior.itemId);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
this.#statement(INSERT_ITEM).run(record.id, record.dedupKey, record.dedupBucket, record.sourceId, record.kind, record.externalId, record.title, record.brief, record.briefTruncated ? 1 : 0, record.originBindingId, record.acceptanceRef, record.scope.workspace, record.scope.preset, record.scope.principalId, record.owner.principalRecordId, record.owner.principalVersion, record.owner.bindingVersion, record.owner.generation, record.scope.ownerRouteId, record.scope.expiresAt, record.workerAutomationId, record.state, record.stateReason, record.attempt, record.maxAttempts, record.leaseUntil, record.leaseRunId, record.occurrenceId, record.workDir, record.lastReportStatus, record.observed ? 1 : 0, record.createdAt, record.updatedAt, record.authority === null ? null : JSON.stringify(record.authority), null);
|
|
1904
|
+
const detail = diagnosisPlan === undefined ? event.detail : { ...event.detail, diagnosisPlan };
|
|
1905
|
+
this.#appendEvent(record.id, event.kind, event.actor, detail, record.createdAt, diagnosisPlan !== undefined);
|
|
1906
|
+
this.#statement(`
|
|
1907
|
+
INSERT INTO work_item_dispatch(item_id, automation_id, event_id, occurred_at, generation, wake_attempts, state, occurrence_id, reason, created_at, updated_at)
|
|
1908
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1909
|
+
`).run(dispatch.itemId, dispatch.automationId, dispatch.eventId, dispatch.occurredAt, dispatch.generation, dispatch.wakeAttempts, dispatch.state, dispatch.occurrenceId, dispatch.reason, dispatch.createdAt, dispatch.updatedAt);
|
|
1910
|
+
if (frozen)
|
|
1911
|
+
this.#statement(`INSERT INTO work_item_continuation
|
|
1912
|
+
(item_id,parent_item_id,root_item_id,source_key,source_digest,input_text,input_sha256,parent_report_revision,parent_outcome_revision,plan_json,plan_digest,created_at)
|
|
1913
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(frozen.itemId, frozen.parentItemId, frozen.rootItemId, frozen.sourceKey, frozen.sourceDigest, Buffer.from(frozen.inputText, 'utf8'), frozen.inputSha256, frozen.parentReportRevision, frozen.parentOutcomeRevision, Buffer.from(frozen.planJson, 'utf8'), frozen.planDigest, frozen.createdAt);
|
|
1914
|
+
// Follows are inserted in the same transaction as the item, so a crash can
|
|
1915
|
+
// never leave an admitted item whose bounded wait is unbounded by accident.
|
|
1916
|
+
follows.forEach((follow, position) => {
|
|
1917
|
+
this.#statement(`
|
|
1918
|
+
INSERT INTO work_item_follow(item_id, position, kind, ref, state, outcome, digest, source, source_generation, opened_at, closed_at)
|
|
1919
|
+
VALUES (?, ?, ?, ?, 'open', NULL, NULL, ?, ?, ?, NULL)
|
|
1920
|
+
`).run(record.id, position, follow.kind, follow.ref, follow.source, follow.sourceGeneration, record.createdAt);
|
|
1921
|
+
});
|
|
1922
|
+
return record;
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Retire every open item a canonical signal produced, because it was withdrawn.
|
|
1927
|
+
*
|
|
1928
|
+
* This is the only lifecycle transition driven by the read side of autonomy
|
|
1929
|
+
* rather than by a worker report. It is deliberately narrow: the items stop
|
|
1930
|
+
* being open, their active claim (if any) is released so the current run
|
|
1931
|
+
* cannot keep reporting, and a still-pending wake-up is failed rather than
|
|
1932
|
+
* re-armed. A wake-up already handed to the automation is left alone —
|
|
1933
|
+
* delivery to a running occurrence cannot be recalled from here.
|
|
1934
|
+
*
|
|
1935
|
+
* Withdrawal targets the whole dedup key, not the newest row, because the
|
|
1936
|
+
* dedup window is a freshness bound: once it rolls, a still-open item can be
|
|
1937
|
+
* re-admitted under the same key with a different bucket. Retiring only the
|
|
1938
|
+
* newest row would leave the older one winnable by `wakePending()` on the next
|
|
1939
|
+
* host start, i.e. cancelled work could still be woken. All rows of the key
|
|
1940
|
+
* therefore settle together in one transaction.
|
|
1941
|
+
*
|
|
1942
|
+
* Idempotent: an already-terminal key is returned unchanged (`settled`) with
|
|
1943
|
+
* no new event, so a repeated withdrawn revision never rewrites history.
|
|
1944
|
+
*/
|
|
1945
|
+
withdrawByDedupKey(dedupKey, attribution) {
|
|
1946
|
+
const now = this.#now();
|
|
1947
|
+
return this.#transaction(() => {
|
|
1948
|
+
const rows = this.#statement(`
|
|
1949
|
+
SELECT ${ITEM_ALIASES} FROM work_item
|
|
1950
|
+
WHERE dedup_key = ? AND state IN (${OPEN_STATE_PLACEHOLDERS})
|
|
1951
|
+
ORDER BY created_at ASC, id ASC
|
|
1952
|
+
`).all(dedupKey, ...OPEN_STATES);
|
|
1953
|
+
if (rows.length === 0) {
|
|
1954
|
+
const latest = this.findLatestItemByDedupKey(dedupKey);
|
|
1955
|
+
return latest === undefined
|
|
1956
|
+
? Object.freeze({ outcome: 'absent', items: Object.freeze([]) })
|
|
1957
|
+
: Object.freeze({ outcome: 'settled', items: Object.freeze([latest]) });
|
|
1958
|
+
}
|
|
1959
|
+
const retired = [];
|
|
1960
|
+
for (const row of rows) {
|
|
1961
|
+
const item = itemFromRow(row);
|
|
1962
|
+
this.#statement(`
|
|
1963
|
+
UPDATE work_item
|
|
1964
|
+
SET state = 'dropped', state_reason = 'signal-withdrawn', lease_until = NULL, lease_run_id = NULL,
|
|
1965
|
+
occurrence_id = NULL, updated_at = ?
|
|
1966
|
+
WHERE id = ?
|
|
1967
|
+
`).run(now, item.id);
|
|
1968
|
+
this.#statement("UPDATE work_item_claim SET state = 'released', updated_at = ? WHERE item_id = ? AND state = 'active'")
|
|
1969
|
+
.run(now, item.id);
|
|
1970
|
+
this.#statement("UPDATE work_item_dispatch SET state = 'failed', reason = 'signal-withdrawn', updated_at = ? WHERE item_id = ? AND state = 'pending'")
|
|
1971
|
+
.run(now, item.id);
|
|
1972
|
+
this.#appendEvent(item.id, 'withdrawn', 'signal-intake', {
|
|
1973
|
+
stateReason: 'signal-withdrawn',
|
|
1974
|
+
previousState: item.state,
|
|
1975
|
+
...(attribution === undefined ? {} : { sourceId: attribution.sourceId, externalId: attribution.externalId, observedAt: attribution.observedAt }),
|
|
1976
|
+
}, now);
|
|
1977
|
+
this.#insertOutcome(item.id, deriveWithdrawalOutcome(now), now);
|
|
1978
|
+
retired.push(this.#requireItem(item.id));
|
|
1979
|
+
}
|
|
1980
|
+
return Object.freeze({ outcome: 'withdrawn', items: Object.freeze(retired) });
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Provenance of the admission that created this item.
|
|
1985
|
+
*
|
|
1986
|
+
* The event detail is the only durable place the signal evidence lives, and
|
|
1987
|
+
* reading it inside the terminal transaction keeps baseline extraction atomic
|
|
1988
|
+
* with the outcome it fences.
|
|
1989
|
+
*/
|
|
1990
|
+
#admissionProvenance(itemId) {
|
|
1991
|
+
const row = this.#statement("SELECT detail FROM work_item_event WHERE item_id = ? AND kind IN ('admitted', 'observed') ORDER BY sequence ASC LIMIT 1")
|
|
1992
|
+
.get(itemId);
|
|
1993
|
+
const detail = row === undefined ? null : optString(row['detail']);
|
|
1994
|
+
if (detail === null)
|
|
1995
|
+
return null;
|
|
1996
|
+
try {
|
|
1997
|
+
const parsed = JSON.parse(detail);
|
|
1998
|
+
return parsed.provenance ?? null;
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
// A malformed event is no baseline, so the item settles as
|
|
2002
|
+
// `not-applicable` instead of opening an unverifiable window.
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/** Append one outcome revision; the revision number is derived, never supplied. */
|
|
2007
|
+
#insertOutcome(itemId, draft, now) {
|
|
2008
|
+
const latest = this.latestOutcome(itemId);
|
|
2009
|
+
const revision = latest === undefined ? 1 : latest.revision + 1;
|
|
2010
|
+
this.#statement(`
|
|
2011
|
+
INSERT INTO work_item_outcome(item_id, revision, settled_by, execution_status, objective_status, trust,
|
|
2012
|
+
evidence_state, evidence_reason, evidence, baseline_sequence, inbox_id, authority_hash, deadline_at, settled_at, recorded_at)
|
|
2013
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2014
|
+
`).run(itemId, revision, draft.settledBy, draft.executionStatus, draft.objectiveStatus, draft.trust, draft.evidenceState, draft.evidenceReason, draft.evidence === null ? null : JSON.stringify(draft.evidence), draft.baselineSequence, draft.inboxId, draft.authorityHash, draft.deadlineAt, draft.settledAt, now);
|
|
2015
|
+
const row = this.#statement('SELECT * FROM work_item_outcome WHERE item_id = ? AND revision = ?').get(itemId, revision);
|
|
2016
|
+
if (row === undefined)
|
|
2017
|
+
fail('storage', 'outcome row disappeared after insert');
|
|
2018
|
+
return outcomeFromRow(row);
|
|
2019
|
+
}
|
|
2020
|
+
latestOutcome(itemId) {
|
|
2021
|
+
const row = this.#statement('SELECT * FROM work_item_outcome WHERE item_id = ? ORDER BY revision DESC LIMIT 1').get(itemId);
|
|
2022
|
+
return row === undefined ? undefined : outcomeFromRow(row);
|
|
2023
|
+
}
|
|
2024
|
+
/** Every outcome revision for one item, oldest first. */
|
|
2025
|
+
outcomes(itemId) {
|
|
2026
|
+
return Object.freeze(this.#statement('SELECT * FROM work_item_outcome WHERE item_id = ? ORDER BY revision ASC').all(itemId).map(outcomeFromRow));
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Latest outcome plus the report delivery state it depends on.
|
|
2030
|
+
*
|
|
2031
|
+
* `reportRevision` is the terminal report that caused a report-derived
|
|
2032
|
+
* transition; observation and expiry revisions keep pointing at it, while
|
|
2033
|
+
* withdrawal and retry exhaustion never had a report.
|
|
2034
|
+
*/
|
|
2035
|
+
outcomeView(itemId) {
|
|
2036
|
+
const outcome = this.latestOutcome(itemId);
|
|
2037
|
+
if (outcome === undefined)
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const reportBacked = outcome.settledBy === 'report-ready'
|
|
2040
|
+
|| outcome.settledBy === 'report-blocked'
|
|
2041
|
+
|| outcome.settledBy === 'report-failed'
|
|
2042
|
+
|| outcome.settledBy === 'observed-evidence'
|
|
2043
|
+
|| outcome.settledBy === 'window-expired'
|
|
2044
|
+
|| outcome.settledBy === 'closed-by-thread';
|
|
2045
|
+
const report = reportBacked ? this.lastReport(itemId) : undefined;
|
|
2046
|
+
return Object.freeze({
|
|
2047
|
+
outcome,
|
|
2048
|
+
deliveryState: report?.deliveryState ?? null,
|
|
2049
|
+
reportRevision: report?.revision ?? null,
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
/** Latest outcomes, newest first, optionally filtered by evidence state. */
|
|
2053
|
+
listOutcomes(options = {}) {
|
|
2054
|
+
const limit = options.limit ?? 20;
|
|
2055
|
+
const conditions = ['1 = 1'];
|
|
2056
|
+
const parameters = [];
|
|
2057
|
+
if (options.evidenceState !== undefined) {
|
|
2058
|
+
conditions.push('o.evidence_state = ?');
|
|
2059
|
+
parameters.push(options.evidenceState);
|
|
2060
|
+
}
|
|
2061
|
+
parameters.push(limit);
|
|
2062
|
+
const rows = this.#statement(`
|
|
2063
|
+
SELECT o.* FROM work_item_outcome o
|
|
2064
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
2065
|
+
WHERE ${conditions.join(' AND ')}
|
|
2066
|
+
ORDER BY o.settled_at DESC, o.item_id ASC LIMIT ?
|
|
2067
|
+
`).all(...parameters);
|
|
2068
|
+
return Object.freeze(rows.map(row => {
|
|
2069
|
+
const view = this.outcomeView(String(row['item_id']));
|
|
2070
|
+
if (view === undefined)
|
|
2071
|
+
fail('storage', 'outcome view disappeared while listing');
|
|
2072
|
+
return view;
|
|
2073
|
+
}));
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Latest outcome revisions still waiting for evidence, soonest deadline first.
|
|
2077
|
+
*
|
|
2078
|
+
* Only the newest revision per item is considered: an older revision that was
|
|
2079
|
+
* superseded still says `pending` in the immutable row, but it is history, not
|
|
2080
|
+
* an open window.
|
|
2081
|
+
*/
|
|
2082
|
+
pendingOutcomes(limit) {
|
|
2083
|
+
const rows = this.#statement(`
|
|
2084
|
+
SELECT o.* FROM work_item_outcome o
|
|
2085
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
2086
|
+
WHERE o.evidence_state = 'pending' AND o.deadline_at IS NOT NULL
|
|
2087
|
+
ORDER BY o.deadline_at ASC, o.item_id ASC LIMIT ?
|
|
2088
|
+
`).all(limit);
|
|
2089
|
+
return Object.freeze(rows.map(outcomeFromRow));
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Append the settling revision for one pending outcome.
|
|
2093
|
+
*
|
|
2094
|
+
* Compare-and-swap on the revision number: a concurrent pass that already
|
|
2095
|
+
* settled the item fails here instead of overwriting its history.
|
|
2096
|
+
*
|
|
2097
|
+
* This intentionally appends no `work_item_event`: the outcome ledger is its
|
|
2098
|
+
* own append-only audit trail, and duplicating every revision into the event
|
|
2099
|
+
* log would mix worker lifecycle with independently sourced evidence.
|
|
2100
|
+
*/
|
|
2101
|
+
settleOutcome(input) {
|
|
2102
|
+
return this.#transaction(() => {
|
|
2103
|
+
const now = this.#now();
|
|
2104
|
+
const latest = this.latestOutcome(input.itemId);
|
|
2105
|
+
if (latest === undefined)
|
|
2106
|
+
fail('invalid-state', 'cannot settle an item without an outcome');
|
|
2107
|
+
if (latest.revision !== input.revision)
|
|
2108
|
+
fail('invalid-state', 'outcome revision moved before settlement');
|
|
2109
|
+
if (latest.evidenceState !== 'pending')
|
|
2110
|
+
fail('invalid-state', `outcome is ${latest.evidenceState}, not pending`);
|
|
2111
|
+
const draft = input.expired
|
|
2112
|
+
? expiredOutcome(latest, now, input.degraded)
|
|
2113
|
+
: observedOutcome(latest, input.evidence, input.reason, now);
|
|
2114
|
+
return this.#insertOutcome(input.itemId, draft, now);
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Latest outcome revisions that still owe exactly one trusted Evaluation
|
|
2119
|
+
* append.
|
|
2120
|
+
*
|
|
2121
|
+
* Only the newest revision per item is exportable, and only once it carries a
|
|
2122
|
+
* settled evidence state: an unresolved `pending` window is still changing, so
|
|
2123
|
+
* exporting it early would publish a claim that later revisions contradict.
|
|
2124
|
+
*/
|
|
2125
|
+
outcomesAwaitingEvaluation(limit, attemptBound = Number.MAX_SAFE_INTEGER) {
|
|
2126
|
+
// A row that exhausted its bounded export attempts stays `pending` for
|
|
2127
|
+
// health, but it must not keep a slot in every later sweep: otherwise an
|
|
2128
|
+
// unreachable sink would permanently starve newer settlements behind the
|
|
2129
|
+
// same handful of oldest rows.
|
|
2130
|
+
const rows = this.#statement(`
|
|
2131
|
+
SELECT o.* FROM work_item_outcome o
|
|
2132
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
2133
|
+
WHERE o.evaluation_state = 'pending' AND o.evidence_state != 'pending'
|
|
2134
|
+
AND o.evaluation_attempts < ?
|
|
2135
|
+
ORDER BY o.settled_at ASC, o.item_id ASC LIMIT ?
|
|
2136
|
+
`).all(attemptBound, limit);
|
|
2137
|
+
return Object.freeze(rows.map(outcomeFromRow));
|
|
2138
|
+
}
|
|
2139
|
+
/** Count settled revisions that used up their bounded export attempts. */
|
|
2140
|
+
countExhaustedEvaluations(attemptBound) {
|
|
2141
|
+
const row = this.#statement(`
|
|
2142
|
+
SELECT COUNT(*) AS count FROM work_item_outcome o
|
|
2143
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
2144
|
+
WHERE o.evaluation_state = 'pending' AND o.evidence_state != 'pending'
|
|
2145
|
+
AND o.evaluation_attempts >= ?
|
|
2146
|
+
`).get(attemptBound);
|
|
2147
|
+
return Number(row['count']);
|
|
2148
|
+
}
|
|
2149
|
+
/** Count latest export-eligible revisions per Evaluation export state. */
|
|
2150
|
+
countEvaluations() {
|
|
2151
|
+
const rows = this.#statement(`
|
|
2152
|
+
SELECT o.evaluation_state AS state, COUNT(*) AS count FROM work_item_outcome o
|
|
2153
|
+
JOIN (${LATEST_OUTCOME}) latest ON latest.item_id = o.item_id AND latest.revision = o.revision
|
|
2154
|
+
WHERE o.evidence_state != 'pending'
|
|
2155
|
+
GROUP BY o.evaluation_state
|
|
2156
|
+
`).all();
|
|
2157
|
+
let pending = 0;
|
|
2158
|
+
let exported = 0;
|
|
2159
|
+
let skipped = 0;
|
|
2160
|
+
for (const row of rows) {
|
|
2161
|
+
const count = Number(row['count']);
|
|
2162
|
+
const state = String(row['state']);
|
|
2163
|
+
if (state === 'exported')
|
|
2164
|
+
exported = count;
|
|
2165
|
+
else if (state === 'skipped')
|
|
2166
|
+
skipped = count;
|
|
2167
|
+
else
|
|
2168
|
+
pending = count;
|
|
2169
|
+
}
|
|
2170
|
+
return { pending, exported, skipped };
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Bounded, read-only execution facts for one item at a settlement instant.
|
|
2174
|
+
*
|
|
2175
|
+
* Every counter is a COUNT over durable rows this ledger already keeps for
|
|
2176
|
+
* the item; nothing is re-derived from live process state. `settledAt` bounds
|
|
2177
|
+
* each read because rows recorded after the settled revision belong to a
|
|
2178
|
+
* later observation and must not inflate an earlier claim. An absent item row
|
|
2179
|
+
* returns `undefined` so the caller can keep the live-record fallback.
|
|
2180
|
+
*
|
|
2181
|
+
* Claims are counted from `claimed` ledger events rather than claim rows:
|
|
2182
|
+
* each committed claim appends exactly one such event in the same transaction
|
|
2183
|
+
* as the claim row and the `item.attempt` increment (the replay branch appends
|
|
2184
|
+
* neither, retry-exhausted appends `blocked` only, and nothing deletes either
|
|
2185
|
+
* record), while `work_item_claim` has no `item_id` index. `work_item_event`
|
|
2186
|
+
* is `WITHOUT ROWID` keyed by `(item_id, sequence)`, so its count is an
|
|
2187
|
+
* indexed prefix search instead of a scan of every claim ever recorded.
|
|
2188
|
+
*/
|
|
2189
|
+
workItemExecutionMetrics(itemId, settledAt) {
|
|
2190
|
+
const item = this.findItemById(itemId);
|
|
2191
|
+
if (item === undefined)
|
|
2192
|
+
return undefined;
|
|
2193
|
+
const row = this.#statement(`
|
|
2194
|
+
SELECT
|
|
2195
|
+
(SELECT COUNT(*) FROM work_item_event WHERE item_id = ? AND kind = 'claimed' AND recorded_at <= ?) AS claims,
|
|
2196
|
+
(SELECT COUNT(*) FROM work_item_report WHERE item_id = ? AND recorded_at <= ?) AS reports,
|
|
2197
|
+
(SELECT COUNT(*) FROM work_item_report WHERE item_id = ? AND recorded_at <= ? AND status = 'waiting') AS waiting_reports,
|
|
2198
|
+
(SELECT COUNT(*) FROM work_item_event WHERE item_id = ? AND kind = 'dispatch-unknown' AND recorded_at <= ?) AS unknown_dispatches,
|
|
2199
|
+
(SELECT COUNT(*) FROM work_item_event WHERE item_id = ? AND kind = 'claim-expired' AND recorded_at <= ?) AS claim_expiries
|
|
2200
|
+
`).get(itemId, settledAt, itemId, settledAt, itemId, settledAt, itemId, settledAt, itemId, settledAt);
|
|
2201
|
+
const count = (value) => {
|
|
2202
|
+
const parsed = Number(value);
|
|
2203
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
2204
|
+
};
|
|
2205
|
+
const elapsed = settledAt - item.createdAt;
|
|
2206
|
+
const latencyMs = Number.isSafeInteger(elapsed) ? Math.max(0, elapsed) : 0;
|
|
2207
|
+
const claims = count(row['claims']);
|
|
2208
|
+
// Each committed claim appends exactly one `claimed` event and increments
|
|
2209
|
+
// `attempt` atomically, so the settlement-bounded event count is the attempt
|
|
2210
|
+
// count at `settledAt` — unlike the mutable `item.attempt`, it cannot absorb
|
|
2211
|
+
// retries that happen after the settled revision.
|
|
2212
|
+
const retries = Math.max(0, claims - 1);
|
|
2213
|
+
const reports = count(row['reports']);
|
|
2214
|
+
const waitingReports = count(row['waiting_reports']);
|
|
2215
|
+
const unknownDispatches = count(row['unknown_dispatches']);
|
|
2216
|
+
const claimExpiries = count(row['claim_expiries']);
|
|
2217
|
+
return Object.freeze({
|
|
2218
|
+
latencyMs,
|
|
2219
|
+
...(retries > 0 ? { retries } : {}),
|
|
2220
|
+
...(claims > 0 ? { claims } : {}),
|
|
2221
|
+
...(reports > 0 ? { reports } : {}),
|
|
2222
|
+
...(waitingReports > 0 ? { waitingReports } : {}),
|
|
2223
|
+
...(unknownDispatches > 0 ? { unknownDispatches } : {}),
|
|
2224
|
+
...(claimExpiries > 0 ? { claimExpiries } : {}),
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Bounded, read-only resume brief for one item at a claim instant.
|
|
2229
|
+
*
|
|
2230
|
+
* Everything is derived from the item's own append-only ledger rows, so a
|
|
2231
|
+
* fresh occurrence can continue without replaying a prior model's memory.
|
|
2232
|
+
* Absent items return `undefined` (mirroring `workItemExecutionMetrics`) so
|
|
2233
|
+
* the caller never invents an empty context for a row that vanished.
|
|
2234
|
+
*
|
|
2235
|
+
* The event read is a bounded newest-first scan of one item's primary-key
|
|
2236
|
+
* prefix; it reconstructs prior attempts from the `claimed` event that
|
|
2237
|
+
* records `{runId, attempt}` and the recovery/report events that end one.
|
|
2238
|
+
* Malformed or absent detail JSON is skipped, never thrown. Reports are read
|
|
2239
|
+
* newest-first through the same primary-key prefix and capped at five, with
|
|
2240
|
+
* each summary cut to a safe UTF-8 byte boundary.
|
|
2241
|
+
*/
|
|
2242
|
+
workItemResumeContext(itemId, now = this.#now()) {
|
|
2243
|
+
const item = this.findItemById(itemId);
|
|
2244
|
+
if (item === undefined)
|
|
2245
|
+
return undefined;
|
|
2246
|
+
const priorAttempts = Math.max(0, item.attempt - 1);
|
|
2247
|
+
const eventRows = this.#statement(`
|
|
2248
|
+
SELECT sequence, kind, detail, recorded_at
|
|
2249
|
+
FROM work_item_event
|
|
2250
|
+
WHERE item_id = ? AND recorded_at <= ? AND kind IN ('claimed', 'claim-expired', 'reclaimed', 'blocked', 'reported', 'settled')
|
|
2251
|
+
ORDER BY sequence DESC
|
|
2252
|
+
LIMIT ${String(RESUME_EVENT_SCAN_LIMIT)}
|
|
2253
|
+
`).all(itemId, now).reverse();
|
|
2254
|
+
const byAttempt = new Map();
|
|
2255
|
+
const openNewest = () => {
|
|
2256
|
+
const drafts = [...byAttempt.values()];
|
|
2257
|
+
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
|
2258
|
+
const draft = drafts[index];
|
|
2259
|
+
if (draft !== undefined && draft.ended === null)
|
|
2260
|
+
return draft;
|
|
2261
|
+
}
|
|
2262
|
+
return undefined;
|
|
2263
|
+
};
|
|
2264
|
+
for (const row of eventRows) {
|
|
2265
|
+
const kind = String(row['kind']);
|
|
2266
|
+
const detail = eventDetail(row['detail']);
|
|
2267
|
+
const recordedAt = Number(row['recorded_at']);
|
|
2268
|
+
if (kind === 'claimed') {
|
|
2269
|
+
const attempt = optCount(detail?.['attempt']);
|
|
2270
|
+
if (attempt === null || attempt >= item.attempt || byAttempt.has(attempt))
|
|
2271
|
+
continue;
|
|
2272
|
+
byAttempt.set(attempt, {
|
|
2273
|
+
attempt,
|
|
2274
|
+
runId: typeof detail?.['runId'] === 'string' ? detail['runId'] : null,
|
|
2275
|
+
startedAt: recordedAt,
|
|
2276
|
+
ended: null,
|
|
2277
|
+
terminalStatus: null,
|
|
2278
|
+
});
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
// An end event names its attempt when it can; otherwise it ends the most
|
|
2282
|
+
// recently started attempt still open. `reclaimExpired()` emits
|
|
2283
|
+
// `claim-expired` then `reclaimed` for the same attempt, so the second
|
|
2284
|
+
// event is ignored once the attempt is already closed.
|
|
2285
|
+
const hinted = optCount(detail?.['attempt']);
|
|
2286
|
+
const target = hinted === null
|
|
2287
|
+
? openNewest()
|
|
2288
|
+
: (byAttempt.get(hinted)?.ended === null ? byAttempt.get(hinted) : undefined);
|
|
2289
|
+
if (target === undefined)
|
|
2290
|
+
continue;
|
|
2291
|
+
if (kind === 'claim-expired')
|
|
2292
|
+
target.ended = 'expired';
|
|
2293
|
+
else if (kind === 'reclaimed')
|
|
2294
|
+
target.ended = 'reclaimed';
|
|
2295
|
+
else if (kind === 'blocked') {
|
|
2296
|
+
if (detail?.['reason'] !== 'retry-exhausted')
|
|
2297
|
+
continue;
|
|
2298
|
+
target.ended = 'attempt-exhausted';
|
|
2299
|
+
}
|
|
2300
|
+
else if (kind === 'reported') {
|
|
2301
|
+
const status = typeof detail?.['status'] === 'string' ? detail['status'] : null;
|
|
2302
|
+
if (status === null || !TERMINAL_REPORT_STATUSES.includes(status))
|
|
2303
|
+
continue;
|
|
2304
|
+
target.ended = 'reported';
|
|
2305
|
+
target.terminalStatus = status;
|
|
2306
|
+
}
|
|
2307
|
+
else if (kind === 'settled')
|
|
2308
|
+
target.ended = 'settled';
|
|
2309
|
+
}
|
|
2310
|
+
const attempts = Object.freeze([...byAttempt.values()]
|
|
2311
|
+
.sort((left, right) => left.attempt - right.attempt)
|
|
2312
|
+
.slice(-RESUME_ATTEMPT_LIMIT)
|
|
2313
|
+
.map(draft => Object.freeze({
|
|
2314
|
+
attempt: draft.attempt,
|
|
2315
|
+
runId: draft.runId,
|
|
2316
|
+
startedAt: draft.startedAt,
|
|
2317
|
+
ended: draft.ended,
|
|
2318
|
+
terminalStatus: draft.terminalStatus,
|
|
2319
|
+
})));
|
|
2320
|
+
const reportRows = this.#statement(`
|
|
2321
|
+
SELECT revision, status, summary, truncated, recorded_at
|
|
2322
|
+
FROM work_item_report
|
|
2323
|
+
WHERE item_id = ? AND recorded_at <= ?
|
|
2324
|
+
ORDER BY revision DESC
|
|
2325
|
+
LIMIT ${String(RESUME_REPORT_LIMIT)}
|
|
2326
|
+
`).all(itemId, now).reverse();
|
|
2327
|
+
const reports = Object.freeze(reportRows.map(row => {
|
|
2328
|
+
const cut = truncateUtf8(String(row['summary']), RESUME_SUMMARY_BYTES);
|
|
2329
|
+
return Object.freeze({
|
|
2330
|
+
revision: Number(row['revision']),
|
|
2331
|
+
status: String(row['status']),
|
|
2332
|
+
summary: cut.value,
|
|
2333
|
+
summaryTruncated: Number(row['truncated']) === 1 || cut.truncated,
|
|
2334
|
+
recordedAt: Number(row['recorded_at']),
|
|
2335
|
+
});
|
|
2336
|
+
}));
|
|
2337
|
+
const reportsTotal = this.reportCount(itemId);
|
|
2338
|
+
const settled = this.latestOutcome(itemId);
|
|
2339
|
+
const latestOutcome = settled === undefined
|
|
2340
|
+
? null
|
|
2341
|
+
: Object.freeze({
|
|
2342
|
+
revision: settled.revision,
|
|
2343
|
+
settledBy: settled.settledBy,
|
|
2344
|
+
executionStatus: settled.executionStatus,
|
|
2345
|
+
objectiveStatus: settled.objectiveStatus,
|
|
2346
|
+
evidenceState: settled.evidenceState,
|
|
2347
|
+
evidenceReason: settled.evidenceReason,
|
|
2348
|
+
settledAt: settled.settledAt,
|
|
2349
|
+
});
|
|
2350
|
+
return Object.freeze({
|
|
2351
|
+
resumed: priorAttempts > 0 || reports.length > 0 || latestOutcome !== null,
|
|
2352
|
+
priorAttempts,
|
|
2353
|
+
attempts,
|
|
2354
|
+
reports,
|
|
2355
|
+
reportsTotal,
|
|
2356
|
+
latestOutcome,
|
|
2357
|
+
truncated: priorAttempts > attempts.length || reportsTotal > reports.length,
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
/** Mark one settled revision exported; CAS on `pending` so a replay is refused. */
|
|
2361
|
+
markOutcomeExported(itemId, revision, digest, recordedAt) {
|
|
2362
|
+
const changes = this.#statement(`
|
|
2363
|
+
UPDATE work_item_outcome SET evaluation_state = 'exported', evaluation_digest = ?, evaluation_recorded_at = ?
|
|
2364
|
+
WHERE item_id = ? AND revision = ? AND evaluation_state = 'pending'
|
|
2365
|
+
`).run(digest, recordedAt, itemId, revision).changes;
|
|
2366
|
+
if (changes !== 1)
|
|
2367
|
+
fail('invalid-state', 'outcome evaluation state moved before export');
|
|
2368
|
+
return this.#requireOutcome(itemId, revision);
|
|
2369
|
+
}
|
|
2370
|
+
/** Mark one settled revision skipped; CAS on `pending`. */
|
|
2371
|
+
markOutcomeSkipped(itemId, revision, recordedAt) {
|
|
2372
|
+
const changes = this.#statement(`
|
|
2373
|
+
UPDATE work_item_outcome SET evaluation_state = 'skipped', evaluation_recorded_at = ?
|
|
2374
|
+
WHERE item_id = ? AND revision = ? AND evaluation_state = 'pending'
|
|
2375
|
+
`).run(recordedAt, itemId, revision).changes;
|
|
2376
|
+
if (changes !== 1)
|
|
2377
|
+
fail('invalid-state', 'outcome evaluation state moved before skip');
|
|
2378
|
+
return this.#requireOutcome(itemId, revision);
|
|
2379
|
+
}
|
|
2380
|
+
/** Count one failed export attempt, saturated at the caller's bound. */
|
|
2381
|
+
recordOutcomeEvaluationAttempt(itemId, revision, maxAttempts) {
|
|
2382
|
+
this.#statement(`
|
|
2383
|
+
UPDATE work_item_outcome SET evaluation_attempts = MIN(evaluation_attempts + 1, ?)
|
|
2384
|
+
WHERE item_id = ? AND revision = ? AND evaluation_state = 'pending'
|
|
2385
|
+
`).run(maxAttempts, itemId, revision);
|
|
2386
|
+
return this.#requireOutcome(itemId, revision);
|
|
2387
|
+
}
|
|
2388
|
+
#requireOutcome(itemId, revision) {
|
|
2389
|
+
const row = this.#statement('SELECT * FROM work_item_outcome WHERE item_id = ? AND revision = ?').get(itemId, revision);
|
|
2390
|
+
if (row === undefined)
|
|
2391
|
+
fail('storage', 'outcome row disappeared after update');
|
|
2392
|
+
return outcomeFromRow(row);
|
|
2393
|
+
}
|
|
2394
|
+
appendEvent(itemId, kind, actor, detail) {
|
|
2395
|
+
const now = this.#now();
|
|
2396
|
+
this.#transaction(() => {
|
|
2397
|
+
this.#requireItem(itemId);
|
|
2398
|
+
this.#appendEvent(itemId, kind, actor, detail, now);
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
events(itemId) {
|
|
2402
|
+
return Object.freeze(this.#statement('SELECT item_id, sequence, kind, actor, detail, recorded_at FROM work_item_event WHERE item_id = ? ORDER BY sequence').all(itemId).map(row => Object.freeze({
|
|
2403
|
+
itemId: String(row['item_id']),
|
|
2404
|
+
sequence: Number(row['sequence']),
|
|
2405
|
+
kind: String(row['kind']),
|
|
2406
|
+
actor: String(row['actor']),
|
|
2407
|
+
detail: optString(row['detail']),
|
|
2408
|
+
recordedAt: Number(row['recorded_at']),
|
|
2409
|
+
})));
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Record Policy-change notices for live items in one transaction, at most
|
|
2413
|
+
* once per (item, policyVersion). A notice is an append-only event: it
|
|
2414
|
+
* changes no state, lease or dispatch, so a Policy change never cancels
|
|
2415
|
+
* work. All or nothing: a failure writes none of the batch, and a retry with
|
|
2416
|
+
* the same version re-derives it. Each entry yields the written notice, or
|
|
2417
|
+
* `undefined` when the item already had one for this version or is no
|
|
2418
|
+
* longer live work.
|
|
2419
|
+
*/
|
|
2420
|
+
recordPolicyNotices(policyVersion, entries) {
|
|
2421
|
+
if (entries.length === 0)
|
|
2422
|
+
return Object.freeze([]);
|
|
2423
|
+
const now = this.#now();
|
|
2424
|
+
return this.#transaction(() => Object.freeze(entries.map(({ itemId, notice }) => {
|
|
2425
|
+
const item = this.#requireItem(itemId);
|
|
2426
|
+
if (item.observed || !OPEN_STATES.includes(item.state))
|
|
2427
|
+
return undefined;
|
|
2428
|
+
// Scoped by the (item_id, sequence) primary key prefix: one item's events only.
|
|
2429
|
+
const prior = this.#statement(`SELECT 1 AS present FROM work_item_event
|
|
2430
|
+
WHERE item_id = ? AND kind = '${POLICY_NOTICE_EVENT}' AND json_extract(detail, '$.policyVersion') = ?`).get(itemId, policyVersion);
|
|
2431
|
+
if (prior !== undefined)
|
|
2432
|
+
return undefined;
|
|
2433
|
+
this.#appendEvent(itemId, POLICY_NOTICE_EVENT, 'host', {
|
|
2434
|
+
policyVersion,
|
|
2435
|
+
reasonCode: notice.reasonCode,
|
|
2436
|
+
frozenRuleId: notice.frozenRuleId,
|
|
2437
|
+
currentRuleId: notice.currentRuleId,
|
|
2438
|
+
decisionId: notice.decisionId,
|
|
2439
|
+
}, now);
|
|
2440
|
+
return Object.freeze({ itemId, policyVersion, ...notice, recordedAt: now });
|
|
2441
|
+
})));
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Live (open, non-observed) items in admission order after `after`, for a
|
|
2445
|
+
* bounded notice pass; also how many live items lie beyond the page.
|
|
2446
|
+
*/
|
|
2447
|
+
listLiveItemsPage(options) {
|
|
2448
|
+
const after = options.after;
|
|
2449
|
+
const where = `observed = 0 AND state IN (${OPEN_STATE_PLACEHOLDERS})${after === undefined ? '' : ' AND (created_at, id) > (?, ?)'}`;
|
|
2450
|
+
const parameters = [...OPEN_STATES, ...(after === undefined ? [] : [after.createdAt, after.id])];
|
|
2451
|
+
const rows = this.#statement(`SELECT ${ITEM_ALIASES} FROM work_item WHERE ${where} ORDER BY created_at, id LIMIT ?`)
|
|
2452
|
+
.all(...parameters, options.limit);
|
|
2453
|
+
const total = this.#statement(`SELECT COUNT(*) AS count FROM work_item WHERE ${where}`).get(...parameters);
|
|
2454
|
+
return Object.freeze({
|
|
2455
|
+
items: Object.freeze(rows.map(itemFromRow)),
|
|
2456
|
+
remaining: Math.max(0, Number(total['count']) - rows.length),
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* The independent verifier's verdict recorded for one item, if any.
|
|
2461
|
+
*
|
|
2462
|
+
* Append-only event, at most one per item: a receipt is final for its
|
|
2463
|
+
* contract, so the first validated read-back is the item's verdict.
|
|
2464
|
+
*/
|
|
2465
|
+
acceptanceVerdict(itemId) {
|
|
2466
|
+
const row = this.#statement(`SELECT detail, recorded_at FROM work_item_event
|
|
2467
|
+
WHERE item_id = ? AND kind = '${ACCEPTANCE_VERIFIED_EVENT}' ORDER BY sequence LIMIT 1`).get(itemId);
|
|
2468
|
+
if (row === undefined)
|
|
2469
|
+
return undefined;
|
|
2470
|
+
const detail = eventDetail(row['detail']);
|
|
2471
|
+
const status = detail?.['objectiveStatus'];
|
|
2472
|
+
if (detail === null || detail['verifier'] !== 'assistant-verifier'
|
|
2473
|
+
|| (status !== 'achieved' && status !== 'not-achieved' && status !== 'unknown')
|
|
2474
|
+
|| typeof detail['contractId'] !== 'string' || typeof detail['contractDigest'] !== 'string'
|
|
2475
|
+
|| typeof detail['receiptId'] !== 'string' || typeof detail['receiptDigest'] !== 'string'
|
|
2476
|
+
|| typeof detail['completedAt'] !== 'number') {
|
|
2477
|
+
fail('storage', 'recorded acceptance verdict is malformed');
|
|
2478
|
+
}
|
|
2479
|
+
const attempt = detail['attempt'], occurrenceId = detail['occurrenceId'];
|
|
2480
|
+
const generation = Object.hasOwn(detail, 'attempt') || Object.hasOwn(detail, 'occurrenceId');
|
|
2481
|
+
if (generation && (!Number.isSafeInteger(attempt) || attempt < 0 || (occurrenceId !== null && typeof occurrenceId !== 'string'))) {
|
|
2482
|
+
fail('storage', 'recorded acceptance verdict is malformed');
|
|
2483
|
+
}
|
|
2484
|
+
return Object.freeze({
|
|
2485
|
+
itemId,
|
|
2486
|
+
verifier: 'assistant-verifier',
|
|
2487
|
+
contractId: detail['contractId'],
|
|
2488
|
+
contractDigest: detail['contractDigest'],
|
|
2489
|
+
receiptId: detail['receiptId'],
|
|
2490
|
+
receiptDigest: detail['receiptDigest'],
|
|
2491
|
+
objectiveStatus: status,
|
|
2492
|
+
completedAt: detail['completedAt'],
|
|
2493
|
+
...(generation ? { attempt: attempt, occurrenceId: occurrenceId } : {}),
|
|
2494
|
+
recordedAt: Number(row['recorded_at']),
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Record the verifier's verdict for one item, at most once. The same
|
|
2499
|
+
* receipt again is idempotent; a different receipt for an item that already
|
|
2500
|
+
* has a verdict is an identity mismatch and writes nothing. The item's state,
|
|
2501
|
+
* lease and outcome revisions are untouched.
|
|
2502
|
+
*/
|
|
2503
|
+
recordAcceptanceVerdict(verdict, gate) {
|
|
2504
|
+
const now = this.#now();
|
|
2505
|
+
return this.#transaction(() => {
|
|
2506
|
+
this.#requireItem(verdict.itemId);
|
|
2507
|
+
const prior = this.acceptanceVerdict(verdict.itemId);
|
|
2508
|
+
if (prior !== undefined) {
|
|
2509
|
+
if (prior.receiptDigest !== verdict.receiptDigest || prior.contractDigest !== verdict.contractDigest) {
|
|
2510
|
+
fail('identity-mismatch', 'item already has a verdict from a different receipt');
|
|
2511
|
+
}
|
|
2512
|
+
// Applying the gate to an existing verdict is idempotent: a blocked item stays put.
|
|
2513
|
+
const blockedReason = gate === undefined ? undefined : this.#gateVerdict(prior, gate, now);
|
|
2514
|
+
return Object.freeze({ verdict: prior, created: false, ...(blockedReason === undefined ? {} : { blockedReason }) });
|
|
2515
|
+
}
|
|
2516
|
+
this.#appendEvent(verdict.itemId, ACCEPTANCE_VERIFIED_EVENT, 'verifier', {
|
|
2517
|
+
verifier: verdict.verifier,
|
|
2518
|
+
contractId: verdict.contractId,
|
|
2519
|
+
contractDigest: verdict.contractDigest,
|
|
2520
|
+
receiptId: verdict.receiptId,
|
|
2521
|
+
receiptDigest: verdict.receiptDigest,
|
|
2522
|
+
objectiveStatus: verdict.objectiveStatus,
|
|
2523
|
+
completedAt: verdict.completedAt,
|
|
2524
|
+
...(verdict.attempt === undefined ? {} : { attempt: verdict.attempt, occurrenceId: verdict.occurrenceId ?? null }),
|
|
2525
|
+
}, now);
|
|
2526
|
+
const recorded = this.acceptanceVerdict(verdict.itemId);
|
|
2527
|
+
if (recorded === undefined)
|
|
2528
|
+
fail('storage', 'acceptance verdict could not be read back');
|
|
2529
|
+
// Same transaction as the verdict: a crash never leaves a failed verdict on a `ready` item.
|
|
2530
|
+
const blockedReason = gate === undefined ? undefined : this.#gateVerdict(recorded, gate, now);
|
|
2531
|
+
return Object.freeze({ verdict: recorded, created: true, ...(blockedReason === undefined ? {} : { blockedReason }) });
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* WP21 slice 7 (`acceptance.verdictGate`): an independent verifier said the
|
|
2536
|
+
* executed item's objective was not achieved, could not tell, or needs
|
|
2537
|
+
* attention. Move it from `ready` to the existing `blocked` state with an
|
|
2538
|
+
* `acceptance-*` reason and one `blocked` event. Compare-and-swap on the
|
|
2539
|
+
* expected state, attempt and occurrence: an item that moved on (re-armed,
|
|
2540
|
+
* withdrawn, already blocked) is left untouched and nothing is written. The
|
|
2541
|
+
* outcome ledger is not rewritten; the self-reported `ready` revision stays
|
|
2542
|
+
* history.
|
|
2543
|
+
*/
|
|
2544
|
+
blockForAcceptance(itemId, expect, reason, detail) {
|
|
2545
|
+
return this.#transaction(() => this.#blockForAcceptance(itemId, expect, reason, detail, this.#now()));
|
|
2546
|
+
}
|
|
2547
|
+
#gateVerdict(verdict, expect, now) {
|
|
2548
|
+
if (verdict.objectiveStatus === 'achieved')
|
|
2549
|
+
return undefined;
|
|
2550
|
+
// Only the generation this verdict was settled for may be moved; a verdict
|
|
2551
|
+
// without one (recorded before slice 7) never moves the item.
|
|
2552
|
+
if (verdict.attempt !== expect.attempt || verdict.occurrenceId !== expect.occurrenceId)
|
|
2553
|
+
return undefined;
|
|
2554
|
+
const reason = verdict.objectiveStatus === 'not-achieved' ? 'acceptance-not-achieved' : 'acceptance-unknown';
|
|
2555
|
+
return this.#blockForAcceptance(verdict.itemId, expect, reason, { contractId: verdict.contractId, receiptDigest: verdict.receiptDigest }, now)
|
|
2556
|
+
? reason : undefined;
|
|
2557
|
+
}
|
|
2558
|
+
#blockForAcceptance(itemId, expect, reason, detail, now) {
|
|
2559
|
+
const item = this.#requireItem(itemId);
|
|
2560
|
+
if (item.observed || item.state !== 'ready' || item.attempt < 1 || item.attempt !== expect.attempt
|
|
2561
|
+
|| item.occurrenceId !== expect.occurrenceId || item.leaseUntil !== null)
|
|
2562
|
+
return false;
|
|
2563
|
+
const changed = this.#statement(`UPDATE work_item SET state = 'blocked', state_reason = ?, updated_at = ?
|
|
2564
|
+
WHERE id = ? AND state = 'ready' AND attempt = ? AND occurrence_id IS ?`).run(reason, now, itemId, expect.attempt, expect.occurrenceId);
|
|
2565
|
+
if (Number(changed.changes) !== 1)
|
|
2566
|
+
return false;
|
|
2567
|
+
this.#appendEvent(itemId, 'blocked', 'verifier', {
|
|
2568
|
+
reason,
|
|
2569
|
+
attempt: item.attempt,
|
|
2570
|
+
contractId: detail.contractId,
|
|
2571
|
+
...(detail.receiptDigest === undefined ? {} : { receiptDigest: detail.receiptDigest }),
|
|
2572
|
+
...(detail.verifierReason === undefined || detail.verifierReason === null ? {} : { verifierReason: truncateUtf8(detail.verifierReason, 256).value }),
|
|
2573
|
+
}, now);
|
|
2574
|
+
return true;
|
|
2575
|
+
}
|
|
2576
|
+
/** Every Policy-change notice recorded for one item, oldest first. */
|
|
2577
|
+
policyNotices(itemId) {
|
|
2578
|
+
const rows = this.#statement(`SELECT detail, recorded_at FROM work_item_event
|
|
2579
|
+
WHERE item_id = ? AND kind = '${POLICY_NOTICE_EVENT}' ORDER BY sequence`).all(itemId);
|
|
2580
|
+
const notices = [];
|
|
2581
|
+
for (const row of rows) {
|
|
2582
|
+
const detail = eventDetail(row['detail']);
|
|
2583
|
+
if (detail === null || typeof detail['policyVersion'] !== 'string' || typeof detail['reasonCode'] !== 'string')
|
|
2584
|
+
continue;
|
|
2585
|
+
notices.push(Object.freeze({
|
|
2586
|
+
itemId,
|
|
2587
|
+
policyVersion: detail['policyVersion'],
|
|
2588
|
+
reasonCode: detail['reasonCode'],
|
|
2589
|
+
frozenRuleId: typeof detail['frozenRuleId'] === 'string' ? detail['frozenRuleId'] : null,
|
|
2590
|
+
currentRuleId: typeof detail['currentRuleId'] === 'string' ? detail['currentRuleId'] : null,
|
|
2591
|
+
decisionId: typeof detail['decisionId'] === 'string' ? detail['decisionId'] : null,
|
|
2592
|
+
recordedAt: Number(row['recorded_at']),
|
|
2593
|
+
}));
|
|
2594
|
+
}
|
|
2595
|
+
return Object.freeze(notices);
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Hand one human follow-up on the item's work thread to that item.
|
|
2599
|
+
*
|
|
2600
|
+
* The message id is owned by at most one item for its whole life; the same
|
|
2601
|
+
* (item, message, digest) is idempotent, anything else under the same id is
|
|
2602
|
+
* an identity mismatch. An item that already ended takes nothing: the
|
|
2603
|
+
* message stays with judgment and no row is written.
|
|
2604
|
+
*/
|
|
2605
|
+
ownFollowUp(input) {
|
|
2606
|
+
const now = this.#now();
|
|
2607
|
+
return this.#transaction(() => {
|
|
2608
|
+
const item = this.#requireItem(input.itemId);
|
|
2609
|
+
if (input.bindingId !== item.originBindingId)
|
|
2610
|
+
fail('identity-mismatch', 'follow-up is not on this item\'s work thread');
|
|
2611
|
+
// A follow-up's identity is (work thread, message id). Only items whose
|
|
2612
|
+
// origin binding is this thread can own it, so the lookup walks those
|
|
2613
|
+
// items' events by primary-key prefix instead of the whole event table.
|
|
2614
|
+
const owners = this.#statement(`SELECT e.item_id AS item_id, e.detail AS detail
|
|
2615
|
+
FROM work_item w JOIN work_item_event e ON e.item_id = w.id
|
|
2616
|
+
WHERE w.origin_binding_id = ? AND e.kind = '${FOLLOW_UP_OWNED_EVENT}'
|
|
2617
|
+
AND json_extract(e.detail, '$.messageId') = ?`).all(input.bindingId, input.messageId);
|
|
2618
|
+
for (const row of owners) {
|
|
2619
|
+
const detail = eventDetail(row['detail']);
|
|
2620
|
+
if (String(row['item_id']) !== item.id || detail?.['sourceDigest'] !== input.sourceDigest) {
|
|
2621
|
+
fail('identity-mismatch', 'follow-up message is already owned under a different identity');
|
|
2622
|
+
}
|
|
2623
|
+
const followUp = this.#followUps(item).find(record => record.messageId === input.messageId);
|
|
2624
|
+
if (followUp === undefined)
|
|
2625
|
+
fail('storage', 'owned follow-up could not be read back');
|
|
2626
|
+
return Object.freeze({ outcome: 'already-owned', followUp });
|
|
2627
|
+
}
|
|
2628
|
+
// Observed (shadow) items never run, so they never own a follow-up.
|
|
2629
|
+
if (item.observed || !OPEN_STATES.includes(item.state)) {
|
|
2630
|
+
return Object.freeze({ outcome: 'item-ended', itemId: item.id, state: item.state });
|
|
2631
|
+
}
|
|
2632
|
+
this.#appendEvent(item.id, FOLLOW_UP_OWNED_EVENT, 'host', {
|
|
2633
|
+
messageId: input.messageId, bindingId: input.bindingId, sourceDigest: input.sourceDigest,
|
|
2634
|
+
}, now);
|
|
2635
|
+
const followUp = this.#followUps(item).find(record => record.messageId === input.messageId);
|
|
2636
|
+
if (followUp === undefined)
|
|
2637
|
+
fail('storage', 'owned follow-up could not be read back');
|
|
2638
|
+
return Object.freeze({ outcome: 'owned', followUp });
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Mark one owned follow-up processed by its live item. Idempotent; once the
|
|
2643
|
+
* item ended an unprocessed follow-up belongs to judgment and cannot be
|
|
2644
|
+
* claimed as processed any more, whether or not the release sweep ran.
|
|
2645
|
+
*/
|
|
2646
|
+
markFollowUpProcessed(itemId, messageId, options = {}) {
|
|
2647
|
+
const now = this.#now();
|
|
2648
|
+
return this.#transaction(() => {
|
|
2649
|
+
const item = this.#requireItem(itemId);
|
|
2650
|
+
const current = this.#followUps(item).find(record => record.messageId === messageId);
|
|
2651
|
+
if (current === undefined)
|
|
2652
|
+
fail('not-found', 'follow-up is not owned by this item');
|
|
2653
|
+
if (current.state === 'processed')
|
|
2654
|
+
return current;
|
|
2655
|
+
if (current.state === 'released' || (!OPEN_STATES.includes(item.state) && options.allowEnded !== true)) {
|
|
2656
|
+
fail('invalid-state', 'follow-up was released to judgment when the item ended');
|
|
2657
|
+
}
|
|
2658
|
+
this.#appendEvent(item.id, FOLLOW_UP_PROCESSED_EVENT, 'host', { messageId }, now);
|
|
2659
|
+
const processed = this.#followUps(item).find(record => record.messageId === messageId);
|
|
2660
|
+
if (processed === undefined)
|
|
2661
|
+
fail('storage', 'processed follow-up could not be read back');
|
|
2662
|
+
return processed;
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
/** Follow-ups owned by one item, in the order they were handed over. */
|
|
2666
|
+
followUps(itemId) {
|
|
2667
|
+
return this.#followUps(this.#requireItem(itemId));
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Materialize the release of every unprocessed follow-up whose item ended.
|
|
2671
|
+
*
|
|
2672
|
+
* Derived from durable state only (terminal item, owned, neither processed
|
|
2673
|
+
* nor released), so it is restart-safe and covers every terminal path
|
|
2674
|
+
* without hooking each one; a second sweep releases nothing new.
|
|
2675
|
+
*/
|
|
2676
|
+
releaseEndedFollowUps(limit = 100) {
|
|
2677
|
+
const bounded = Math.max(1, Math.min(Math.trunc(limit), 1000));
|
|
2678
|
+
const now = this.#now();
|
|
2679
|
+
return this.#transaction(() => {
|
|
2680
|
+
const rows = this.#statement(`SELECT e.item_id AS item_id, json_extract(e.detail, '$.messageId') AS message_id
|
|
2681
|
+
FROM work_item_event e JOIN work_item w ON w.id = e.item_id
|
|
2682
|
+
WHERE e.kind = '${FOLLOW_UP_OWNED_EVENT}' AND w.state IN (${TERMINAL_STATES.map(() => '?').join(', ')})
|
|
2683
|
+
AND NOT EXISTS (SELECT 1 FROM work_item_event s WHERE s.item_id = e.item_id
|
|
2684
|
+
AND s.kind IN ('${FOLLOW_UP_PROCESSED_EVENT}', '${FOLLOW_UP_RELEASED_EVENT}')
|
|
2685
|
+
AND json_extract(s.detail, '$.messageId') = json_extract(e.detail, '$.messageId'))
|
|
2686
|
+
ORDER BY e.recorded_at, e.item_id, e.sequence LIMIT ?`).all(...TERMINAL_STATES, bounded);
|
|
2687
|
+
const released = [];
|
|
2688
|
+
for (const row of rows) {
|
|
2689
|
+
const item = this.#requireItem(String(row['item_id']));
|
|
2690
|
+
const messageId = String(row['message_id']);
|
|
2691
|
+
this.#appendEvent(item.id, FOLLOW_UP_RELEASED_EVENT, 'host', { messageId, releasedFrom: item.state }, now);
|
|
2692
|
+
const record = this.#followUps(item).find(candidate => candidate.messageId === messageId);
|
|
2693
|
+
if (record === undefined || record.state !== 'released')
|
|
2694
|
+
fail('storage', 'released follow-up could not be read back');
|
|
2695
|
+
released.push(record);
|
|
2696
|
+
}
|
|
2697
|
+
return Object.freeze(released);
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Released follow-ups in release order, for judgment to (re)read after a
|
|
2702
|
+
* restart. `after` is the cursor of the last record already seen.
|
|
2703
|
+
*/
|
|
2704
|
+
listReleasedFollowUps(options = {}) {
|
|
2705
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 100), 1000));
|
|
2706
|
+
const after = options.after;
|
|
2707
|
+
const rows = (after === undefined
|
|
2708
|
+
? this.#statement(`SELECT item_id, sequence, recorded_at, json_extract(detail, '$.messageId') AS message_id FROM work_item_event
|
|
2709
|
+
WHERE kind = '${FOLLOW_UP_RELEASED_EVENT}' ORDER BY recorded_at, item_id, sequence LIMIT ?`).all(limit + 1)
|
|
2710
|
+
: this.#statement(`SELECT item_id, sequence, recorded_at, json_extract(detail, '$.messageId') AS message_id FROM work_item_event
|
|
2711
|
+
WHERE kind = '${FOLLOW_UP_RELEASED_EVENT}' AND (recorded_at, item_id, sequence) > (?, ?, ?)
|
|
2712
|
+
ORDER BY recorded_at, item_id, sequence LIMIT ?`).all(after.recordedAt, after.itemId, after.sequence, limit + 1));
|
|
2713
|
+
const page = rows.slice(0, limit);
|
|
2714
|
+
const items = [];
|
|
2715
|
+
for (const row of page) {
|
|
2716
|
+
const record = this.#followUps(this.#requireItem(String(row['item_id'])))
|
|
2717
|
+
.find(candidate => candidate.messageId === String(row['message_id']));
|
|
2718
|
+
if (record !== undefined)
|
|
2719
|
+
items.push(record);
|
|
2720
|
+
}
|
|
2721
|
+
const last = page.at(-1);
|
|
2722
|
+
const next = rows.length > limit && last !== undefined
|
|
2723
|
+
? Object.freeze({ recordedAt: Number(last['recorded_at']), itemId: String(last['item_id']), sequence: Number(last['sequence']) })
|
|
2724
|
+
: null;
|
|
2725
|
+
return Object.freeze({ items: Object.freeze(items), next });
|
|
2726
|
+
}
|
|
2727
|
+
/** Fold one item's follow-up events into per-message records. */
|
|
2728
|
+
#followUps(item) {
|
|
2729
|
+
const rows = this.#statement(`SELECT kind, detail, recorded_at FROM work_item_event
|
|
2730
|
+
WHERE item_id = ? AND kind IN ('${FOLLOW_UP_OWNED_EVENT}', '${FOLLOW_UP_PROCESSED_EVENT}', '${FOLLOW_UP_RELEASED_EVENT}')
|
|
2731
|
+
ORDER BY sequence`).all(item.id);
|
|
2732
|
+
const records = new Map();
|
|
2733
|
+
for (const row of rows) {
|
|
2734
|
+
const detail = eventDetail(row['detail']);
|
|
2735
|
+
const messageId = detail?.['messageId'];
|
|
2736
|
+
if (detail === null || typeof messageId !== 'string')
|
|
2737
|
+
continue;
|
|
2738
|
+
const at = Number(row['recorded_at']);
|
|
2739
|
+
const kind = String(row['kind']);
|
|
2740
|
+
const current = records.get(messageId);
|
|
2741
|
+
if (kind === FOLLOW_UP_OWNED_EVENT) {
|
|
2742
|
+
if (current !== undefined || typeof detail['bindingId'] !== 'string' || typeof detail['sourceDigest'] !== 'string')
|
|
2743
|
+
continue;
|
|
2744
|
+
records.set(messageId, Object.freeze({
|
|
2745
|
+
itemId: item.id, messageId, bindingId: detail['bindingId'], sourceDigest: detail['sourceDigest'],
|
|
2746
|
+
state: 'owned', ownedAt: at, settledAt: null, releasedFrom: null,
|
|
2747
|
+
}));
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
if (current === undefined || current.state !== 'owned')
|
|
2751
|
+
continue;
|
|
2752
|
+
const releasedFrom = kind === FOLLOW_UP_RELEASED_EVENT && TERMINAL_STATES.includes(detail['releasedFrom'])
|
|
2753
|
+
? detail['releasedFrom']
|
|
2754
|
+
: null;
|
|
2755
|
+
records.set(messageId, Object.freeze({
|
|
2756
|
+
...current,
|
|
2757
|
+
state: kind === FOLLOW_UP_PROCESSED_EVENT ? 'processed' : 'released',
|
|
2758
|
+
settledAt: at,
|
|
2759
|
+
releasedFrom,
|
|
2760
|
+
}));
|
|
2761
|
+
}
|
|
2762
|
+
return Object.freeze([...records.values()]);
|
|
2763
|
+
}
|
|
2764
|
+
getDispatch(itemId) {
|
|
2765
|
+
const row = this.#statement('SELECT * FROM work_item_dispatch WHERE item_id = ?').get(itemId);
|
|
2766
|
+
return row === undefined ? undefined : dispatchFromRow(row);
|
|
2767
|
+
}
|
|
2768
|
+
/** Final synchronous wake fence. This method calls no peer or user callback;
|
|
2769
|
+
* full SQLite text equality also closes Node 24's hidden-NUL projection gap. */
|
|
2770
|
+
isWakeSnapshotCurrent(item, dispatch) {
|
|
2771
|
+
if (this.closed || dispatch.itemId !== item.id || dispatch.state !== 'pending')
|
|
2772
|
+
return false;
|
|
2773
|
+
const currentItem = this.findItemById(item.id), currentDispatch = this.getDispatch(item.id);
|
|
2774
|
+
if (!currentItem || !currentDispatch || currentItem.observed || !['queued', 'waiting'].includes(currentItem.state)
|
|
2775
|
+
|| acceptanceCanonicalJson(currentItem) !== acceptanceCanonicalJson(item)
|
|
2776
|
+
|| acceptanceCanonicalJson(currentDispatch) !== acceptanceCanonicalJson(dispatch))
|
|
2777
|
+
return false;
|
|
2778
|
+
this.#assertContinuationItemText(item.id);
|
|
2779
|
+
return this.#statement(`SELECT 1 FROM work_item_dispatch WHERE item_id=?
|
|
2780
|
+
AND CAST(item_id AS BLOB) IS CAST(? AS BLOB) AND CAST(automation_id AS BLOB) IS CAST(? AS BLOB)
|
|
2781
|
+
AND CAST(event_id AS BLOB) IS CAST(? AS BLOB) AND CAST(state AS BLOB) IS CAST(? AS BLOB)
|
|
2782
|
+
AND CAST(occurrence_id AS BLOB) IS CAST(? AS BLOB) AND CAST(reason AS BLOB) IS CAST(? AS BLOB)`)
|
|
2783
|
+
.get(item.id, dispatch.itemId, dispatch.automationId, dispatch.eventId, dispatch.state, dispatch.occurrenceId, dispatch.reason) !== undefined;
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Pending dispatches whose due time has arrived.
|
|
2787
|
+
*
|
|
2788
|
+
* `occurred_at` doubles as the due time: admission sets it to the admit
|
|
2789
|
+
* instant, a lease reclaim re-arms it at the reclaim instant, and a `waiting`
|
|
2790
|
+
* report re-arms it at `now + limits.waitingResumeMs`. Filtering here keeps
|
|
2791
|
+
* recovery lazy and prevents an early wake of a deliberately delayed resume.
|
|
2792
|
+
*/
|
|
2793
|
+
listOpenDispatches(limit) {
|
|
2794
|
+
return Object.freeze(this.#statement(`
|
|
2795
|
+
SELECT * FROM work_item_dispatch
|
|
2796
|
+
WHERE state = 'pending' AND occurred_at <= ?
|
|
2797
|
+
ORDER BY occurred_at ASC, created_at ASC LIMIT ?
|
|
2798
|
+
`).all(this.#now(), limit).map(dispatchFromRow));
|
|
2799
|
+
}
|
|
2800
|
+
/** At most ten due waiting resumes, with durable keyset round-robin fairness. */
|
|
2801
|
+
listDueWaitingResumes(limit = 10) {
|
|
2802
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10)
|
|
2803
|
+
fail('invalid-input', 'waiting resume limit must be between 1 and 10');
|
|
2804
|
+
let cursor = null;
|
|
2805
|
+
const stored = this.meta(WAITING_RESUME_CURSOR);
|
|
2806
|
+
if (stored !== null) {
|
|
2807
|
+
try {
|
|
2808
|
+
const value = JSON.parse(stored);
|
|
2809
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
2810
|
+
|| !Number.isSafeInteger(value['occurredAt']) || Number(value['occurredAt']) < 0
|
|
2811
|
+
|| typeof value['itemId'] !== 'string' || !/^[a-z0-9-]{1,200}$/u.test(value['itemId']))
|
|
2812
|
+
throw new Error('cursor');
|
|
2813
|
+
cursor = { occurredAt: Number(value['occurredAt']), itemId: value['itemId'] };
|
|
2814
|
+
}
|
|
2815
|
+
catch {
|
|
2816
|
+
fail('storage', 'waiting resume cursor is malformed');
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
const select = (comparison, count) => this.#statement(`
|
|
2820
|
+
SELECT d.* FROM work_item_dispatch d INDEXED BY work_item_dispatch_waiting_due_idx
|
|
2821
|
+
WHERE d.state = 'pending' AND d.reason = 'waiting-resume' AND d.occurred_at <= ?
|
|
2822
|
+
AND (d.occurred_at, d.item_id) ${comparison} (?, ?)
|
|
2823
|
+
ORDER BY d.occurred_at ASC, d.item_id ASC LIMIT ?
|
|
2824
|
+
`).all(this.#now(), cursor?.occurredAt ?? 0, cursor?.itemId ?? '', count).map(dispatchFromRow);
|
|
2825
|
+
const rows = select('>', limit);
|
|
2826
|
+
if (cursor !== null && rows.length < limit)
|
|
2827
|
+
rows.push(...select('<=', limit - rows.length));
|
|
2828
|
+
return Object.freeze(rows);
|
|
2829
|
+
}
|
|
2830
|
+
/** Advance even past denied/expired-owner rows so later items are not starved. */
|
|
2831
|
+
advanceWaitingResumeCursor(dispatch) {
|
|
2832
|
+
this.setMeta(WAITING_RESUME_CURSOR, JSON.stringify({ occurredAt: dispatch.occurredAt, itemId: dispatch.itemId }));
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* Failed dispatches whose wake outcome is unknown and that still own one
|
|
2836
|
+
* bounded retry inside the same generation.
|
|
2837
|
+
*/
|
|
2838
|
+
listRetryableUnknownDispatches(limit) {
|
|
2839
|
+
return Object.freeze(this.#statement(`
|
|
2840
|
+
SELECT d.* FROM work_item_dispatch d
|
|
2841
|
+
JOIN work_item i ON i.id = d.item_id
|
|
2842
|
+
WHERE d.state = 'failed' AND d.reason = 'unknown_after_send'
|
|
2843
|
+
AND i.observed = 0 AND i.state IN (${OPEN_STATE_PLACEHOLDERS})
|
|
2844
|
+
AND d.wake_attempts <= ?
|
|
2845
|
+
ORDER BY d.updated_at ASC LIMIT ?
|
|
2846
|
+
`).all(...OPEN_STATES, MAX_UNKNOWN_WAKE_ATTEMPTS, limit).map(dispatchFromRow));
|
|
2847
|
+
}
|
|
2848
|
+
markDispatchDispatched(itemId, occurrenceId) {
|
|
2849
|
+
const now = this.#now();
|
|
2850
|
+
this.#transaction(() => {
|
|
2851
|
+
this.#requireItem(itemId);
|
|
2852
|
+
// Guarded transition: a wake-up that already committed must not be
|
|
2853
|
+
// rewritten by a late bookkeeping write, and a withdrawal that failed the
|
|
2854
|
+
// row in between must stay failed instead of masquerading as dispatched.
|
|
2855
|
+
const changed = this.#statement("UPDATE work_item_dispatch SET state = 'dispatched', occurrence_id = ?, reason = NULL, updated_at = ? WHERE item_id = ? AND state = 'pending'")
|
|
2856
|
+
.run(occurrenceId, now, itemId);
|
|
2857
|
+
if (Number(changed.changes) !== 1)
|
|
2858
|
+
fail('invalid-state', 'work item dispatch is no longer pending');
|
|
2859
|
+
this.#statement('UPDATE work_item SET occurrence_id = ?, updated_at = ? WHERE id = ?').run(occurrenceId, now, itemId);
|
|
2860
|
+
this.#appendEvent(itemId, 'dispatched', 'admission', { occurrenceId }, now);
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
markDispatchFailed(itemId, reason) {
|
|
2864
|
+
const now = this.#now();
|
|
2865
|
+
this.#transaction(() => {
|
|
2866
|
+
this.#requireItem(itemId);
|
|
2867
|
+
this.#statement("UPDATE work_item_dispatch SET state = 'failed', reason = ?, updated_at = ? WHERE item_id = ?").run(reason, now, itemId);
|
|
2868
|
+
this.#appendEvent(itemId, 'dispatch-failed', 'admission', { reason }, now);
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Record an ambiguous wake failure: the peer may or may not have committed
|
|
2873
|
+
* the occurrence, so the row is failed with `unknown_after_send` and the
|
|
2874
|
+
* generation's retry budget is spent once.
|
|
2875
|
+
*
|
|
2876
|
+
* The identity is preserved, so a retry resolves the same occurrence instead
|
|
2877
|
+
* of minting a second run.
|
|
2878
|
+
*/
|
|
2879
|
+
markDispatchUnknown(itemId) {
|
|
2880
|
+
const now = this.#now();
|
|
2881
|
+
return this.#transaction(() => {
|
|
2882
|
+
this.#requireItem(itemId);
|
|
2883
|
+
const current = this.getDispatch(itemId);
|
|
2884
|
+
if (current === undefined)
|
|
2885
|
+
fail('storage', 'work item has no dispatch row');
|
|
2886
|
+
const attempts = current.wakeAttempts + 1;
|
|
2887
|
+
this.#statement("UPDATE work_item_dispatch SET state = 'failed', reason = 'unknown_after_send', wake_attempts = ?, updated_at = ? WHERE item_id = ?")
|
|
2888
|
+
.run(attempts, now, itemId);
|
|
2889
|
+
this.#appendEvent(itemId, 'dispatch-unknown', 'admission', { generation: current.generation, attempts, eventId: current.eventId }, now);
|
|
2890
|
+
return this.getDispatch(itemId);
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Re-arm one failed-with-unknown row inside the *same* generation.
|
|
2895
|
+
*
|
|
2896
|
+
* The persisted event id and occurred-at are reused verbatim, so the retry is
|
|
2897
|
+
* idempotent against the automation intake: if the first attempt did commit,
|
|
2898
|
+
* the retry resolves that occurrence; if it did not, the retry creates it.
|
|
2899
|
+
*/
|
|
2900
|
+
retryUnknownDispatch(itemId) {
|
|
2901
|
+
return this.#transaction(() => {
|
|
2902
|
+
this.#requireItem(itemId);
|
|
2903
|
+
const current = this.getDispatch(itemId);
|
|
2904
|
+
if (current === undefined)
|
|
2905
|
+
fail('storage', 'work item has no dispatch row');
|
|
2906
|
+
if (current.state !== 'failed' || current.reason !== 'unknown_after_send')
|
|
2907
|
+
fail('invalid-state', 'dispatch is not awaiting an unknown-outcome retry');
|
|
2908
|
+
if (current.wakeAttempts > MAX_UNKNOWN_WAKE_ATTEMPTS)
|
|
2909
|
+
fail('invalid-state', 'dispatch unknown retry budget is spent');
|
|
2910
|
+
this.#writeRearm(current, current.generation, current.eventId, current.occurredAt, current.wakeAttempts, 'unknown_after_send');
|
|
2911
|
+
return this.getDispatch(itemId);
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2915
|
+
* Re-arm a fresh wake after a claim-time revalidation denial.
|
|
2916
|
+
*
|
|
2917
|
+
* At claim time the occurrence already exists, so parking alone would leave
|
|
2918
|
+
* the item without any future wake. A new generation is minted because the
|
|
2919
|
+
* denied run never claimed the item; once the mandate allows again, the
|
|
2920
|
+
* ordinary wake path hands the same item to a fresh occurrence.
|
|
2921
|
+
*/
|
|
2922
|
+
rearmAuthority(itemId) {
|
|
2923
|
+
this.#transaction(() => {
|
|
2924
|
+
const current = this.getDispatch(itemId);
|
|
2925
|
+
if (current === undefined || current.state !== 'dispatched')
|
|
2926
|
+
return;
|
|
2927
|
+
const now = this.#now();
|
|
2928
|
+
const generation = current.generation + 1;
|
|
2929
|
+
this.#writeRearm(current, generation, dispatchEventId(itemId, generation), now, 0, 'authority-resume');
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
#writeRearm(current, generation, eventId, occurredAt, wakeAttempts, reason) {
|
|
2933
|
+
const now = this.#now();
|
|
2934
|
+
this.#statement(`
|
|
2935
|
+
UPDATE work_item_dispatch
|
|
2936
|
+
SET event_id = ?, occurred_at = ?, generation = ?, wake_attempts = ?, state = 'pending', occurrence_id = NULL, reason = ?, updated_at = ?
|
|
2937
|
+
WHERE item_id = ?
|
|
2938
|
+
`).run(eventId, occurredAt, generation, wakeAttempts, reason, now, current.itemId);
|
|
2939
|
+
this.#appendEvent(current.itemId, 'dispatch-rearmed', 'recovery', { reason, generation, eventId, occurredAt, wakeAttempts }, now);
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2942
|
+
* Reclaim every claim whose lease elapsed. Lazy by design: no timer owns
|
|
2943
|
+
* recovery, so a host that was down simply reclaims on the next touch.
|
|
2944
|
+
*
|
|
2945
|
+
* The expired claim is retired explicitly, the item returns to `queued` and
|
|
2946
|
+
* its dispatch row is re-armed so the normal wake path can hand the item to a
|
|
2947
|
+
* fresh occurrence. A re-queued item that nobody wakes would otherwise stay
|
|
2948
|
+
* queued forever, counted against `maxOpenItems`.
|
|
2949
|
+
*/
|
|
2950
|
+
reclaimExpired() {
|
|
2951
|
+
const now = this.#now();
|
|
2952
|
+
const rows = this.#statement(`
|
|
2953
|
+
SELECT ${ITEM_ALIASES} FROM work_item
|
|
2954
|
+
WHERE state IN ('claimed', 'working') AND lease_until IS NOT NULL AND lease_until <= ?
|
|
2955
|
+
AND NOT EXISTS (SELECT 1 FROM work_item_continuation c WHERE c.item_id=work_item.id)
|
|
2956
|
+
ORDER BY lease_until ASC
|
|
2957
|
+
`).all(now);
|
|
2958
|
+
const requeued = [];
|
|
2959
|
+
const blocked = [];
|
|
2960
|
+
for (const row of rows) {
|
|
2961
|
+
const item = itemFromRow(row);
|
|
2962
|
+
const tryReclaim = () => this.#transaction(() => {
|
|
2963
|
+
const current = this.#requireItem(item.id);
|
|
2964
|
+
if (this.getContinuation(current.id))
|
|
2965
|
+
return 'skipped';
|
|
2966
|
+
if (current.leaseUntil === null || current.leaseUntil > now)
|
|
2967
|
+
return 'skipped';
|
|
2968
|
+
if (current.state !== 'claimed' && current.state !== 'working')
|
|
2969
|
+
return 'skipped';
|
|
2970
|
+
const exhausted = current.attempt >= current.maxAttempts;
|
|
2971
|
+
const expiredRunIds = this.#statement("SELECT run_id FROM work_item_claim WHERE item_id = ? AND state = 'active'").all(current.id)
|
|
2972
|
+
.map(claim => String(claim['run_id']));
|
|
2973
|
+
this.#statement("UPDATE work_item_claim SET state = 'expired', updated_at = ? WHERE item_id = ? AND state = 'active'").run(now, current.id);
|
|
2974
|
+
this.#appendEvent(current.id, 'claim-expired', 'recovery', { reason: 'lease-expired', runIds: expiredRunIds, attempt: current.attempt }, now);
|
|
2975
|
+
if (exhausted) {
|
|
2976
|
+
this.#statement("UPDATE work_item SET state = 'blocked', state_reason = 'retry-exhausted', lease_until = NULL, lease_run_id = NULL, updated_at = ? WHERE id = ?")
|
|
2977
|
+
.run(now, current.id);
|
|
2978
|
+
this.#appendEvent(current.id, 'blocked', 'recovery', { reason: 'retry-exhausted', attempt: current.attempt }, now);
|
|
2979
|
+
this.#insertOutcome(current.id, deriveRetryExhaustedOutcome(now), now);
|
|
2980
|
+
return 'blocked';
|
|
2981
|
+
}
|
|
2982
|
+
this.#statement("UPDATE work_item SET state = 'queued', state_reason = 'lease-expired', lease_until = NULL, lease_run_id = NULL, updated_at = ? WHERE id = ?")
|
|
2983
|
+
.run(now, current.id);
|
|
2984
|
+
// Put the dispatch row back in the only state `wakePending()` scans, so
|
|
2985
|
+
// recovery is driven by the ordinary wake path rather than a side door.
|
|
2986
|
+
// A new generation is owed here: the previous occurrence completed (or
|
|
2987
|
+
// died), so re-waking the old identity would be a silent no-op.
|
|
2988
|
+
const dispatch = this.getDispatch(current.id);
|
|
2989
|
+
if (dispatch !== undefined) {
|
|
2990
|
+
const generation = dispatch.generation + 1;
|
|
2991
|
+
this.#writeRearm(dispatch, generation, dispatchEventId(current.id, generation), now, 0, 'lease-expired');
|
|
2992
|
+
}
|
|
2993
|
+
this.#appendEvent(current.id, 'reclaimed', 'recovery', { reason: 'lease-expired', attempt: current.attempt }, now);
|
|
2994
|
+
return 'requeued';
|
|
2995
|
+
});
|
|
2996
|
+
const outcome = tryReclaim();
|
|
2997
|
+
if (outcome === 'requeued')
|
|
2998
|
+
requeued.push(item.id);
|
|
2999
|
+
else if (outcome === 'blocked')
|
|
3000
|
+
blocked.push(item.id);
|
|
3001
|
+
}
|
|
3002
|
+
return Object.freeze({ requeued: Object.freeze(requeued), blocked: Object.freeze(blocked) });
|
|
3003
|
+
}
|
|
3004
|
+
/** Latest claim for one run; a re-claim after expiry appends a newer row. */
|
|
3005
|
+
findClaimByRun(runId) {
|
|
3006
|
+
const row = this.#statement('SELECT * FROM work_item_claim WHERE run_id = ? ORDER BY claim_id DESC LIMIT 1').get(runId);
|
|
3007
|
+
return row === undefined ? undefined : claimFromRow(row);
|
|
3008
|
+
}
|
|
3009
|
+
activeClaim(itemId) {
|
|
3010
|
+
const row = this.#statement("SELECT * FROM work_item_claim WHERE item_id = ? AND state = 'active'").get(itemId);
|
|
3011
|
+
return row === undefined ? undefined : claimFromRow(row);
|
|
3012
|
+
}
|
|
3013
|
+
/**
|
|
3014
|
+
* Compare-and-swap claim.
|
|
3015
|
+
*
|
|
3016
|
+
* A repeated claim from the same run replays the existing claim only while
|
|
3017
|
+
* that claim is still active, so a tool retry after transport loss is safe.
|
|
3018
|
+
* Once the lease elapsed the claim is retired and the same run obtains a new
|
|
3019
|
+
* claim row (and a new attempt), instead of replaying a dead lease forever.
|
|
3020
|
+
* A competing run loses with `claim-conflict`.
|
|
3021
|
+
*/
|
|
3022
|
+
claim(input) {
|
|
3023
|
+
if (input.reclaimExpired !== false)
|
|
3024
|
+
this.reclaimExpired();
|
|
3025
|
+
const outcome = this.#transaction(() => {
|
|
3026
|
+
const item = this.#requireItem(input.itemId);
|
|
3027
|
+
if (item.observed)
|
|
3028
|
+
fail('invalid-state', 'observed items cannot be claimed');
|
|
3029
|
+
const now = this.#now();
|
|
3030
|
+
const existing = this.findClaimByRun(input.runId);
|
|
3031
|
+
if (existing !== undefined && existing.itemId !== item.id)
|
|
3032
|
+
fail('claim-conflict', 'run already claimed another item');
|
|
3033
|
+
if (existing !== undefined && existing.state === 'active' && existing.leasedUntil > now) {
|
|
3034
|
+
return { exhausted: false, result: Object.freeze({ item, claim: existing, replayed: true }) };
|
|
3035
|
+
}
|
|
3036
|
+
if (item.attempt > 0 && this.getContinuation(item.id))
|
|
3037
|
+
fail('claim-conflict', 'continuation is a one-shot and cannot reclaim a prior attempt');
|
|
3038
|
+
if (existing !== undefined && existing.state === 'active') {
|
|
3039
|
+
// Retire the dead lease before a new row is appended, so the item never
|
|
3040
|
+
// carries two active claims.
|
|
3041
|
+
this.#statement("UPDATE work_item_claim SET state = 'expired', updated_at = ? WHERE claim_id = ?").run(now, existing.claimId);
|
|
3042
|
+
this.#appendEvent(item.id, 'claim-expired', 'worker', { reason: 'lease-expired', runId: input.runId, claimId: existing.claimId }, now);
|
|
3043
|
+
}
|
|
3044
|
+
if (!CLAIMABLE_STATES.includes(item.state))
|
|
3045
|
+
fail('claim-conflict', `item is ${item.state}, not claimable`);
|
|
3046
|
+
if (item.attempt >= item.maxAttempts) {
|
|
3047
|
+
this.#statement("UPDATE work_item SET state = 'blocked', state_reason = 'retry-exhausted', updated_at = ? WHERE id = ?").run(now, item.id);
|
|
3048
|
+
this.#appendEvent(item.id, 'blocked', 'claim', { reason: 'retry-exhausted' }, now);
|
|
3049
|
+
this.#insertOutcome(item.id, deriveRetryExhaustedOutcome(now), now);
|
|
3050
|
+
// Commit the terminal transition, then fail: throwing inside the
|
|
3051
|
+
// transaction would roll the durable blocked state back.
|
|
3052
|
+
return { exhausted: true };
|
|
3053
|
+
}
|
|
3054
|
+
const attempt = item.attempt + 1;
|
|
3055
|
+
const leasedUntil = now + input.leaseMs;
|
|
3056
|
+
this.#statement(`
|
|
3057
|
+
UPDATE work_item SET state = 'claimed', state_reason = NULL, attempt = ?, lease_until = ?, lease_run_id = ?, updated_at = ?
|
|
3058
|
+
WHERE id = ?
|
|
3059
|
+
`).run(attempt, leasedUntil, input.runId, now, item.id);
|
|
3060
|
+
this.#statement(`
|
|
3061
|
+
INSERT INTO work_item_claim(item_id, run_id, occurrence_id, state, leased_until, created_at, updated_at)
|
|
3062
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?)
|
|
3063
|
+
`).run(item.id, input.runId, input.occurrenceId, leasedUntil, now, now);
|
|
3064
|
+
this.#appendEvent(item.id, 'claimed', 'worker', { runId: input.runId, occurrenceId: input.occurrenceId, attempt }, now);
|
|
3065
|
+
const claim = this.findClaimByRun(input.runId);
|
|
3066
|
+
if (claim === undefined)
|
|
3067
|
+
fail('storage', 'claim row disappeared after insert');
|
|
3068
|
+
return { exhausted: false, result: Object.freeze({ item: this.#requireItem(item.id), claim, replayed: false }) };
|
|
3069
|
+
});
|
|
3070
|
+
if (outcome.exhausted)
|
|
3071
|
+
fail('retry-exhausted', 'work item exhausted its attempt budget');
|
|
3072
|
+
return outcome.result;
|
|
3073
|
+
}
|
|
3074
|
+
reportCount(itemId) {
|
|
3075
|
+
const row = this.#statement('SELECT COUNT(*) AS count FROM work_item_report WHERE item_id = ?').get(itemId);
|
|
3076
|
+
return Number(row['count']);
|
|
3077
|
+
}
|
|
3078
|
+
lastReport(itemId) {
|
|
3079
|
+
const row = this.#statement('SELECT * FROM work_item_report WHERE item_id = ? ORDER BY revision DESC LIMIT 1').get(itemId);
|
|
3080
|
+
return row === undefined ? undefined : reportFromRow(row);
|
|
3081
|
+
}
|
|
3082
|
+
reports(itemId) {
|
|
3083
|
+
return Object.freeze(this.#statement('SELECT * FROM work_item_report WHERE item_id = ? ORDER BY revision ASC').all(itemId).map(reportFromRow));
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Compare the existing scoped key and normalized persisted payload in SQLite.
|
|
3087
|
+
* Node 24.7.0 TEXT projections truncate at NUL; bound SQL comparisons retain
|
|
3088
|
+
* every byte. The existing key format alone also has delimiter collisions.
|
|
3089
|
+
*/
|
|
3090
|
+
reportReplayState(input) {
|
|
3091
|
+
const row = this.#statement(`
|
|
3092
|
+
SELECT CASE WHEN item_id = ? AND status = ? AND summary = ? AND truncated = ? AND artifact_paths = ?
|
|
3093
|
+
THEN 1 ELSE 0 END AS matches
|
|
3094
|
+
FROM work_item_report WHERE idempotency_key = ? LIMIT 1
|
|
3095
|
+
`).get(input.itemId, input.status, input.summary, input.truncated ? 1 : 0, JSON.stringify(input.artifactPaths), input.idempotencyKey);
|
|
3096
|
+
return row === undefined ? 'absent' : Number(row['matches']) === 1 ? 'match' : 'conflict';
|
|
3097
|
+
}
|
|
3098
|
+
/** Exact report and adjacent references, using the existing item/revision key. */
|
|
3099
|
+
reportByRevision(itemId, revision) {
|
|
3100
|
+
const row = this.#statement(`
|
|
3101
|
+
SELECT report.*,
|
|
3102
|
+
(SELECT MAX(revision) FROM work_item_report WHERE item_id = ? AND revision < ?) AS previous_revision,
|
|
3103
|
+
(SELECT MIN(revision) FROM work_item_report WHERE item_id = ? AND revision > ?) AS next_revision
|
|
3104
|
+
FROM work_item_report AS report WHERE item_id = ? AND revision = ?
|
|
3105
|
+
`).get(itemId, revision, itemId, revision, itemId, revision);
|
|
3106
|
+
if (row === undefined)
|
|
3107
|
+
return undefined;
|
|
3108
|
+
return Object.freeze({
|
|
3109
|
+
report: reportFromRow(row),
|
|
3110
|
+
previousRevision: row['previous_revision'] === null ? null : Number(row['previous_revision']),
|
|
3111
|
+
nextRevision: row['next_revision'] === null ? null : Number(row['next_revision']),
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
/** Feedback evidence keeps immutable report TEXT bytes intact on Node 24.
|
|
3115
|
+
* This reads exactly the requested revision and performs no state changes. */
|
|
3116
|
+
feedbackReportByRevision(itemId, revision) {
|
|
3117
|
+
const row = this.#statement(`SELECT *, CAST(summary AS BLOB) AS summary_bytes,
|
|
3118
|
+
CAST(idempotency_key AS BLOB) AS key_bytes, CAST(delivery_key AS BLOB) AS delivery_key_bytes,
|
|
3119
|
+
CAST(artifact_paths AS BLOB) AS artifact_bytes FROM work_item_report WHERE item_id=? AND revision=?
|
|
3120
|
+
AND length(CAST(summary AS BLOB))<=65536 AND length(CAST(idempotency_key AS BLOB))<=4096
|
|
3121
|
+
AND length(CAST(delivery_key AS BLOB))<=4096 AND length(CAST(artifact_paths AS BLOB))<=131072`).get(itemId, revision);
|
|
3122
|
+
if (!row)
|
|
3123
|
+
return undefined;
|
|
3124
|
+
const report = reportFromRow(row), artifacts = JSON.parse(continuationUtf8(row['artifact_bytes']));
|
|
3125
|
+
if (!Array.isArray(artifacts) || artifacts.length > 20 || artifacts.some(path => typeof path !== 'string'))
|
|
3126
|
+
fail('storage', 'feedback artifact references invalid');
|
|
3127
|
+
return Object.freeze({ ...report, summary: continuationUtf8(row['summary_bytes']), idempotencyKey: continuationUtf8(row['key_bytes']),
|
|
3128
|
+
deliveryKey: continuationUtf8(row['delivery_key_bytes']), artifactPaths: Object.freeze(artifacts) });
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Append one report revision under the active claim, then apply the state
|
|
3132
|
+
* transition for that status.
|
|
3133
|
+
*
|
|
3134
|
+
* The caller's idempotency key is scoped to the claim row, so identical text
|
|
3135
|
+
* reported by different claims (or different items) is not mistaken for a
|
|
3136
|
+
* replay, while a retried call on the same claim returns the stored revision.
|
|
3137
|
+
*/
|
|
3138
|
+
report(input) {
|
|
3139
|
+
return this.#transaction(() => {
|
|
3140
|
+
const item = this.#requireItem(input.itemId);
|
|
3141
|
+
const claim = this.findClaimByRun(input.runId);
|
|
3142
|
+
if (claim === undefined || claim.itemId !== item.id) {
|
|
3143
|
+
fail('claim-conflict', 'report requires a claim for this item');
|
|
3144
|
+
}
|
|
3145
|
+
const idempotencyKey = `${input.idempotencyKey}\0${item.id}\0${String(claim.claimId)}`;
|
|
3146
|
+
const replay = this.reportReplayState({ ...input, idempotencyKey });
|
|
3147
|
+
if (replay === 'conflict')
|
|
3148
|
+
fail('claim-conflict', 'idempotency key already belongs to a different persisted report');
|
|
3149
|
+
if (replay === 'match') {
|
|
3150
|
+
const duplicate = this.#statement('SELECT * FROM work_item_report WHERE idempotency_key = ?').get(idempotencyKey);
|
|
3151
|
+
if (duplicate === undefined)
|
|
3152
|
+
fail('storage', 'matched report disappeared before replay');
|
|
3153
|
+
const report = reportFromRow(duplicate);
|
|
3154
|
+
if (report.itemId !== item.id)
|
|
3155
|
+
fail('claim-conflict', 'idempotency key already belongs to another item');
|
|
3156
|
+
return Object.freeze({ item, report, replayed: true });
|
|
3157
|
+
}
|
|
3158
|
+
const now = this.#now();
|
|
3159
|
+
if (claim.state === 'expired')
|
|
3160
|
+
fail('claim-expired', 'claim lease elapsed; reclaim the item before reporting');
|
|
3161
|
+
if (claim.state !== 'active')
|
|
3162
|
+
fail('claim-conflict', 'report requires the active claim for this item');
|
|
3163
|
+
if (item.leaseRunId !== input.runId || item.leaseUntil === null || item.leaseUntil <= now) {
|
|
3164
|
+
fail('claim-expired', 'claim lease elapsed; reclaim the item before reporting');
|
|
3165
|
+
}
|
|
3166
|
+
if (!REPORTABLE_STATES.includes(item.state))
|
|
3167
|
+
fail('invalid-state', `item is ${item.state}, not reportable`);
|
|
3168
|
+
if (this.reportCount(item.id) >= input.maxReports)
|
|
3169
|
+
fail('report-limit', 'work item reached maxReportsPerItem');
|
|
3170
|
+
const next = this.#statement('SELECT COALESCE(MAX(revision), 0) + 1 AS next FROM work_item_report WHERE item_id = ?').get(item.id);
|
|
3171
|
+
const revision = Number(next['next']);
|
|
3172
|
+
const deliveryKey = `work-item-report:${item.id}:${String(revision)}`;
|
|
3173
|
+
this.#statement(`
|
|
3174
|
+
INSERT INTO work_item_report(item_id, revision, status, summary, truncated, artifact_paths, idempotency_key, delivery_key, delivery_state, delivery_id, recorded_at)
|
|
3175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
|
|
3176
|
+
`).run(item.id, revision, input.status, input.summary, input.truncated ? 1 : 0, JSON.stringify(input.artifactPaths), idempotencyKey, deliveryKey, now);
|
|
3177
|
+
let nextState;
|
|
3178
|
+
let reason = null;
|
|
3179
|
+
// A terminal report whose item still follows an open thread is parked in
|
|
3180
|
+
// `waiting` instead of settling: the report is recorded and its status is
|
|
3181
|
+
// remembered, but no outcome revision exists yet and the worker's run is
|
|
3182
|
+
// complete. Settlement is deferred to a later bounded pass, so a closure
|
|
3183
|
+
// that arrived before the report can never end the item early.
|
|
3184
|
+
const followed = this.countOpenFollows(item.id) > 0;
|
|
3185
|
+
const deferred = followed && DEFERRED_REPORT_STATUSES.includes(input.status) && !item.observed;
|
|
3186
|
+
switch (input.status) {
|
|
3187
|
+
case 'progress':
|
|
3188
|
+
nextState = 'working';
|
|
3189
|
+
break;
|
|
3190
|
+
case 'waiting':
|
|
3191
|
+
nextState = 'waiting';
|
|
3192
|
+
break;
|
|
3193
|
+
case 'ready':
|
|
3194
|
+
nextState = 'ready';
|
|
3195
|
+
break;
|
|
3196
|
+
case 'blocked':
|
|
3197
|
+
nextState = 'blocked';
|
|
3198
|
+
reason = 'reported-blocked';
|
|
3199
|
+
break;
|
|
3200
|
+
case 'failed':
|
|
3201
|
+
nextState = 'failed';
|
|
3202
|
+
reason = 'reported-failed';
|
|
3203
|
+
break;
|
|
3204
|
+
}
|
|
3205
|
+
if (nextState === 'working') {
|
|
3206
|
+
const lease = now + input.leaseMs;
|
|
3207
|
+
this.#statement('UPDATE work_item SET state = ?, state_reason = NULL, lease_until = ?, last_report_status = ?, updated_at = ? WHERE id = ?')
|
|
3208
|
+
.run(nextState, lease, input.status, now, item.id);
|
|
3209
|
+
this.#statement('UPDATE work_item_claim SET leased_until = ?, updated_at = ? WHERE claim_id = ?')
|
|
3210
|
+
.run(lease, now, claim.claimId);
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
const storedState = deferred ? 'waiting' : nextState;
|
|
3214
|
+
const storedReason = deferred ? 'following-thread' : reason;
|
|
3215
|
+
this.#statement('UPDATE work_item SET state = ?, state_reason = ?, lease_until = NULL, last_report_status = ?, updated_at = ? WHERE id = ?')
|
|
3216
|
+
.run(storedState, storedReason, input.status, now, item.id);
|
|
3217
|
+
this.#statement('UPDATE work_item_claim SET state = ?, updated_at = ? WHERE claim_id = ?')
|
|
3218
|
+
.run(input.status === 'waiting' && !deferred ? 'released' : 'completed', now, claim.claimId);
|
|
3219
|
+
}
|
|
3220
|
+
this.#appendEvent(item.id, 'reported', 'worker', { revision, status: input.status }, now);
|
|
3221
|
+
// `waiting` is not terminal: the item stays claimable, but nobody holds
|
|
3222
|
+
// the lease and no event is owed, so a waiting item would never resume
|
|
3223
|
+
// unless the report itself schedules the next generation. Resume is
|
|
3224
|
+
// delayed by `waitingResumeMs` and bounded by the attempt budget, so a
|
|
3225
|
+
// worker cannot spin occurrences by reporting `waiting` repeatedly.
|
|
3226
|
+
const resume = input.status === 'waiting'
|
|
3227
|
+
&& !item.observed
|
|
3228
|
+
&& item.attempt < item.maxAttempts
|
|
3229
|
+
&& this.getDispatch(item.id) !== undefined;
|
|
3230
|
+
if (resume) {
|
|
3231
|
+
const dispatch = this.getDispatch(item.id);
|
|
3232
|
+
const generation = dispatch.generation + 1;
|
|
3233
|
+
this.#writeRearm(dispatch, generation, dispatchEventId(item.id, generation), now + input.waitingResumeMs, 0, 'waiting-resume');
|
|
3234
|
+
}
|
|
3235
|
+
// The outcome revision is derived in the same transaction as the terminal
|
|
3236
|
+
// state change, so a crash can never leave a `ready` item with no outcome.
|
|
3237
|
+
const outcome = deriveReportOutcome({
|
|
3238
|
+
status: input.status,
|
|
3239
|
+
provenance: this.#admissionProvenance(item.id),
|
|
3240
|
+
now,
|
|
3241
|
+
windowMs: this.#settleWindowMs,
|
|
3242
|
+
});
|
|
3243
|
+
// Deferred items deliberately get no outcome row here: the single outcome
|
|
3244
|
+
// is appended by `settleThreadClosure()` once every follow is closed.
|
|
3245
|
+
if (outcome !== null && !deferred)
|
|
3246
|
+
this.#insertOutcome(item.id, outcome, now);
|
|
3247
|
+
const stored = this.#statement('SELECT * FROM work_item_report WHERE item_id = ? AND revision = ?').get(item.id, revision);
|
|
3248
|
+
return Object.freeze({ item: this.#requireItem(item.id), report: reportFromRow(stored), replayed: false });
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
setReportDelivery(itemId, revision, state, deliveryId) {
|
|
3252
|
+
this.#transaction(() => {
|
|
3253
|
+
this.#statement('UPDATE work_item_report SET delivery_state = ?, delivery_id = ? WHERE item_id = ? AND revision = ?')
|
|
3254
|
+
.run(state, deliveryId, itemId, revision);
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
/** Terminal item states map to terminal claim states. */
|
|
3258
|
+
static claimStateFor(report) {
|
|
3259
|
+
return report === 'waiting' ? 'released' : report === 'progress' ? 'active' : 'completed';
|
|
3260
|
+
}
|
|
3261
|
+
/** Open states are exposed for admission accounting and tests. */
|
|
3262
|
+
static openStates() {
|
|
3263
|
+
return OPEN_STATES;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
//# sourceMappingURL=store.js.map
|