@unson/brainbase-mcp 0.44.0 → 0.44.1
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/contracts/canonical-task/operation-schema.sql +58 -0
- package/contracts/canonical-task/search-indexes.sql +10 -0
- package/contracts/canonical-task/store-schema.sql +77 -0
- package/dist/automation-run.d.ts +213 -0
- package/dist/automation-run.js +479 -0
- package/dist/canonical-task-operations.d.ts +205 -0
- package/dist/canonical-task-operations.js +700 -0
- package/dist/canonical-task-postgres.d.ts +139 -0
- package/dist/canonical-task-postgres.js +554 -0
- package/dist/canonical-task-service.d.ts +13 -0
- package/dist/canonical-task-service.js +55 -3
- package/dist/canonical-task-store-config.d.ts +31 -0
- package/dist/canonical-task-store-config.js +53 -0
- package/dist/config-parser.d.ts +298 -0
- package/dist/config-parser.js +593 -0
- package/dist/connected-onboarding.d.ts +57 -0
- package/dist/connected-onboarding.js +37 -0
- package/dist/durable-wait-http.d.ts +3 -1
- package/dist/durable-wait-http.js +73 -6
- package/dist/durable-waits.d.ts +93 -3
- package/dist/durable-waits.js +320 -8
- package/dist/honcho.d.ts +25 -0
- package/dist/honcho.js +81 -0
- package/dist/judgment-event-adapter.d.ts +72 -0
- package/dist/judgment-event-adapter.js +114 -0
- package/dist/judgment-event-outbox.d.ts +182 -0
- package/dist/judgment-event-outbox.js +568 -0
- package/dist/judgment-execution-outcome.d.ts +46 -0
- package/dist/judgment-execution-outcome.js +149 -0
- package/dist/judgment-host.d.ts +18 -7
- package/dist/judgment-host.js +82 -34
- package/dist/judgment-lifecycle-core.d.ts +198 -0
- package/dist/judgment-lifecycle-core.js +464 -0
- package/dist/judgment-node-evidence.d.ts +57 -0
- package/dist/judgment-node-evidence.js +297 -0
- package/dist/judgment-runtime-resolution.d.ts +264 -0
- package/dist/judgment-runtime-resolution.js +1248 -0
- package/dist/judgment-runtime-resolution.public.d.ts +255 -0
- package/dist/knowledge-event.d.ts +28 -0
- package/dist/knowledge-event.js +114 -0
- package/dist/mcp-runtime.d.ts +75 -0
- package/dist/mcp-runtime.js +188 -0
- package/dist/meeting-automation-runtime.d.ts +102 -0
- package/dist/meeting-automation-runtime.js +249 -0
- package/dist/meeting-automation.d.ts +155 -0
- package/dist/meeting-automation.js +341 -0
- package/dist/meeting-minutes-context-receipt.d.ts +91 -0
- package/dist/meeting-minutes-context-receipt.js +280 -0
- package/dist/meeting-source-sync.d.ts +89 -0
- package/dist/meeting-source-sync.js +230 -0
- package/dist/mesh.d.ts +58 -0
- package/dist/mesh.js +134 -0
- package/dist/onboarding-runtime.d.ts +47 -0
- package/dist/onboarding-runtime.js +4719 -0
- package/dist/routine-cycle.d.ts +51 -0
- package/dist/routine-cycle.js +153 -0
- package/dist/routine-liveness.d.ts +49 -0
- package/dist/routine-liveness.js +227 -0
- package/dist/routine-runtime.d.ts +101 -0
- package/dist/routine-runtime.js +237 -0
- package/dist/routines.d.ts +3 -0
- package/dist/routines.js +3 -0
- package/dist/run-receipt.d.ts +40 -0
- package/dist/run-receipt.js +635 -0
- package/dist/runtime-host.d.ts +64 -0
- package/dist/runtime-host.js +70 -0
- package/dist/world-work.d.ts +16 -0
- package/package.json +115 -3
- package/ui/objective-editor.js +47 -11
- package/ui/world/world-district.js +18 -3
- package/ui/world/world-placement.js +13 -0
- package/ui/world/world-work-rail.js +7 -1
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
export function canonicalTaskOperationError(code, message, status = 503, details = {}) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.name = 'CanonicalTaskOperationError';
|
|
6
|
+
error.code = code;
|
|
7
|
+
error.status = status;
|
|
8
|
+
error.details = details;
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
function writerDisabled() {
|
|
12
|
+
return canonicalTaskOperationError('canonical_task_writer_disabled', 'Canonical Task writer is disabled for this process');
|
|
13
|
+
}
|
|
14
|
+
function isUnboundProcessIdentity(value) {
|
|
15
|
+
return !value || typeof value !== 'object' || Object.keys(value).length === 0;
|
|
16
|
+
}
|
|
17
|
+
function sameProcessIdentity(left, right) {
|
|
18
|
+
const normalize = (value) => Object.fromEntries(Object.entries(value || {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
19
|
+
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
|
|
20
|
+
}
|
|
21
|
+
function authorizationSnapshotRecord(value) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
return null;
|
|
24
|
+
const record = value;
|
|
25
|
+
return record.task && typeof record.task === 'object' && !Array.isArray(record.task)
|
|
26
|
+
? record
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
const READINESS_COLUMNS = `ready, writer_token, manifest_hash, schema_version, source_head,
|
|
30
|
+
evidence_hash, evidence_path, reason, updated_at`;
|
|
31
|
+
/**
|
|
32
|
+
* Single-writer coordination for Canonical Task mutations
|
|
33
|
+
* (`contracts/canonical-task/operation-schema.sql`). One process owns the
|
|
34
|
+
* writer token; every mutation is recorded by scope and operation key so a
|
|
35
|
+
* retry with the same input replays and a different input conflicts.
|
|
36
|
+
*/
|
|
37
|
+
export class CanonicalTaskOperationRepository {
|
|
38
|
+
/**
|
|
39
|
+
* A PostgreSQL coordinator is a durable mutation boundary. Callers must
|
|
40
|
+
* bind it to the release readiness gate before exposing mutations.
|
|
41
|
+
*/
|
|
42
|
+
requiresReadiness = true;
|
|
43
|
+
pool;
|
|
44
|
+
writerToken;
|
|
45
|
+
processIdentity;
|
|
46
|
+
writerEnabled;
|
|
47
|
+
operationWaitTimeoutMs;
|
|
48
|
+
operationPollIntervalMs;
|
|
49
|
+
writerClaimed = false;
|
|
50
|
+
activeOperations = 0;
|
|
51
|
+
constructor({ pool = null, writerToken = null, processIdentity = null, writerEnabled = false, operationWaitTimeoutMs = 5000, operationPollIntervalMs = 10, } = {}) {
|
|
52
|
+
this.pool = pool;
|
|
53
|
+
this.writerToken = writerToken;
|
|
54
|
+
this.processIdentity = processIdentity;
|
|
55
|
+
this.writerEnabled = writerEnabled === true;
|
|
56
|
+
this.operationWaitTimeoutMs = operationWaitTimeoutMs;
|
|
57
|
+
this.operationPollIntervalMs = operationPollIntervalMs;
|
|
58
|
+
}
|
|
59
|
+
identityJson() {
|
|
60
|
+
return JSON.stringify(this.processIdentity || {});
|
|
61
|
+
}
|
|
62
|
+
async connect() {
|
|
63
|
+
if (typeof this.pool?.connect !== 'function') {
|
|
64
|
+
throw canonicalTaskOperationError('canonical_task_coordination_unavailable', 'Canonical Task coordination database is not configured');
|
|
65
|
+
}
|
|
66
|
+
return this.pool.connect();
|
|
67
|
+
}
|
|
68
|
+
async waitForCompletedOperation({ scope, operationKey, fingerprint }) {
|
|
69
|
+
const deadline = Date.now() + this.operationWaitTimeoutMs;
|
|
70
|
+
while (Date.now() <= deadline) {
|
|
71
|
+
await this.assertWriter();
|
|
72
|
+
const existing = await this.pool.query(`SELECT fingerprint, state, result_json, error_json, writer_token
|
|
73
|
+
FROM canonical_task_operations
|
|
74
|
+
WHERE scope = $1 AND operation_key = $2`, [scope, operationKey]);
|
|
75
|
+
const row = existing.rows[0];
|
|
76
|
+
if (!row || row.fingerprint !== fingerprint) {
|
|
77
|
+
throw canonicalTaskOperationError('idempotency_conflict', 'Idempotency key was reused with different input', 409);
|
|
78
|
+
}
|
|
79
|
+
if (row.state === 'completed')
|
|
80
|
+
return row.result_json;
|
|
81
|
+
if (row.state === 'failed') {
|
|
82
|
+
throw canonicalTaskOperationError('canonical_task_operation_failed', 'Concurrent Canonical Task operation failed', 503, {
|
|
83
|
+
cause: row.error_json || null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, this.operationPollIntervalMs));
|
|
87
|
+
}
|
|
88
|
+
throw canonicalTaskOperationError('canonical_task_operation_timeout', 'Timed out waiting for the concurrent Canonical Task operation', 503);
|
|
89
|
+
}
|
|
90
|
+
assertConfigured() {
|
|
91
|
+
if (!this.pool || !this.writerToken) {
|
|
92
|
+
throw canonicalTaskOperationError('canonical_task_coordination_unavailable', 'Canonical Task coordination database is not configured');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
assertWriterEnabled() {
|
|
96
|
+
if (this.writerEnabled !== true)
|
|
97
|
+
throw writerDisabled();
|
|
98
|
+
}
|
|
99
|
+
async claimWriter({ sourceHead } = {}) {
|
|
100
|
+
this.assertWriterEnabled();
|
|
101
|
+
this.assertConfigured();
|
|
102
|
+
const client = await this.connect();
|
|
103
|
+
try {
|
|
104
|
+
await client.query('BEGIN');
|
|
105
|
+
await client.query(`INSERT INTO canonical_task_writer (
|
|
106
|
+
singleton_id, writer_token, process_identity, source_head, claimed_at, updated_at
|
|
107
|
+
) VALUES (TRUE, $1, $2::jsonb, $3, NOW(), NOW())
|
|
108
|
+
ON CONFLICT (singleton_id) DO NOTHING`, [this.writerToken, this.identityJson(), sourceHead || null]);
|
|
109
|
+
const current = await client.query(`SELECT writer_token, process_identity, source_head
|
|
110
|
+
FROM canonical_task_writer
|
|
111
|
+
WHERE singleton_id = TRUE
|
|
112
|
+
FOR UPDATE`);
|
|
113
|
+
const row = current.rows[0];
|
|
114
|
+
const canBindProcess = row
|
|
115
|
+
&& row.writer_token === this.writerToken
|
|
116
|
+
&& (isUnboundProcessIdentity(row.process_identity) || sameProcessIdentity(row.process_identity, this.processIdentity));
|
|
117
|
+
if (!canBindProcess) {
|
|
118
|
+
throw canonicalTaskOperationError('canonical_task_writer_unavailable', 'Canonical Task writer is owned by another process', 503, {
|
|
119
|
+
active_writer: row?.process_identity || null,
|
|
120
|
+
source_head: row?.source_head || null,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const claimed = await client.query(`UPDATE canonical_task_writer
|
|
124
|
+
SET process_identity = $2::jsonb, source_head = $3, updated_at = NOW()
|
|
125
|
+
WHERE singleton_id = TRUE AND writer_token = $1
|
|
126
|
+
AND (process_identity = '{}'::jsonb OR process_identity = $2::jsonb)
|
|
127
|
+
RETURNING writer_token, process_identity, source_head`, [this.writerToken, this.identityJson(), sourceHead || null]);
|
|
128
|
+
if (claimed.rowCount !== 1) {
|
|
129
|
+
throw canonicalTaskOperationError('canonical_task_writer_unavailable', 'Canonical Task writer process binding changed during claim');
|
|
130
|
+
}
|
|
131
|
+
await client.query('COMMIT');
|
|
132
|
+
this.writerClaimed = true;
|
|
133
|
+
return claimed.rows[0] || row;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await client.query('ROLLBACK');
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
client.release?.();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async assertWriter(client = this.pool) {
|
|
144
|
+
this.assertWriterEnabled();
|
|
145
|
+
this.assertConfigured();
|
|
146
|
+
const result = await client.query(`SELECT 1 FROM canonical_task_writer
|
|
147
|
+
WHERE singleton_id = TRUE AND writer_token = $1
|
|
148
|
+
AND process_identity = $2::jsonb`, [this.writerToken, this.identityJson()]);
|
|
149
|
+
if (!result.rowCount) {
|
|
150
|
+
this.writerClaimed = false;
|
|
151
|
+
throw canonicalTaskOperationError('canonical_task_writer_unavailable', 'This process does not own the Canonical Task writer token');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async readReadiness() {
|
|
155
|
+
this.assertConfigured();
|
|
156
|
+
const result = await this.pool.query(`SELECT ${READINESS_COLUMNS}
|
|
157
|
+
FROM canonical_task_readiness
|
|
158
|
+
WHERE singleton_id = TRUE`);
|
|
159
|
+
return result.rows[0] || null;
|
|
160
|
+
}
|
|
161
|
+
async reconcileReadiness({ manifestHash, schemaVersion, sourceHead, allowWriterRebind = false } = {}) {
|
|
162
|
+
this.assertWriterEnabled();
|
|
163
|
+
this.assertConfigured();
|
|
164
|
+
const client = await this.connect();
|
|
165
|
+
try {
|
|
166
|
+
await client.query('BEGIN');
|
|
167
|
+
const writer = await client.query(`SELECT writer_token, process_identity FROM canonical_task_writer
|
|
168
|
+
WHERE singleton_id = TRUE FOR UPDATE`);
|
|
169
|
+
if (!writer.rowCount
|
|
170
|
+
|| writer.rows[0].writer_token !== this.writerToken
|
|
171
|
+
|| !sameProcessIdentity(writer.rows[0].process_identity, this.processIdentity)) {
|
|
172
|
+
throw canonicalTaskOperationError('canonical_task_writer_unavailable', 'This process does not own the Canonical Task writer token');
|
|
173
|
+
}
|
|
174
|
+
const readiness = await client.query(`SELECT ${READINESS_COLUMNS}
|
|
175
|
+
FROM canonical_task_readiness
|
|
176
|
+
WHERE singleton_id = TRUE
|
|
177
|
+
FOR UPDATE`);
|
|
178
|
+
let row = readiness.rows[0] || null;
|
|
179
|
+
const releaseMatches = Boolean(row?.ready
|
|
180
|
+
&& row.manifest_hash === manifestHash
|
|
181
|
+
&& row.schema_version === schemaVersion
|
|
182
|
+
&& row.source_head === sourceHead
|
|
183
|
+
&& row.evidence_hash);
|
|
184
|
+
if (allowWriterRebind && releaseMatches && row.writer_token !== this.writerToken) {
|
|
185
|
+
const rebound = await client.query(`UPDATE canonical_task_readiness
|
|
186
|
+
SET writer_token = $1, updated_at = NOW()
|
|
187
|
+
WHERE singleton_id = TRUE
|
|
188
|
+
RETURNING ${READINESS_COLUMNS}`, [this.writerToken]);
|
|
189
|
+
row = rebound.rows[0] || row;
|
|
190
|
+
}
|
|
191
|
+
await client.query('COMMIT');
|
|
192
|
+
return row;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
await client.query('ROLLBACK');
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
client.release?.();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async rebindReadinessSourceHead({ manifestHash, schemaVersion, fromHead, toHead } = {}) {
|
|
203
|
+
this.assertWriterEnabled();
|
|
204
|
+
this.assertConfigured();
|
|
205
|
+
const client = await this.connect();
|
|
206
|
+
try {
|
|
207
|
+
await client.query('BEGIN');
|
|
208
|
+
await this.assertWriter(client);
|
|
209
|
+
const rebound = await client.query(`UPDATE canonical_task_readiness
|
|
210
|
+
SET source_head = $4, writer_token = $5, updated_at = NOW()
|
|
211
|
+
WHERE singleton_id = TRUE
|
|
212
|
+
AND ready = TRUE
|
|
213
|
+
AND manifest_hash = $1
|
|
214
|
+
AND schema_version = $2
|
|
215
|
+
AND source_head = $3
|
|
216
|
+
AND evidence_hash IS NOT NULL
|
|
217
|
+
RETURNING ${READINESS_COLUMNS}`, [manifestHash, schemaVersion, fromHead, toHead, this.writerToken]);
|
|
218
|
+
if (rebound.rowCount !== 1) {
|
|
219
|
+
throw canonicalTaskOperationError('canonical_task_readiness_rebind_conflict', 'Persisted readiness changed while rebinding source head');
|
|
220
|
+
}
|
|
221
|
+
await client.query('COMMIT');
|
|
222
|
+
return rebound.rows[0];
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
await client.query('ROLLBACK');
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
client.release?.();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async releaseWriter() {
|
|
233
|
+
if (!this.pool || !this.writerToken || !this.writerClaimed || this.activeOperations > 0)
|
|
234
|
+
return false;
|
|
235
|
+
const result = await this.pool.query(`DELETE FROM canonical_task_writer
|
|
236
|
+
WHERE singleton_id = TRUE AND writer_token = $1
|
|
237
|
+
AND process_identity = $2::jsonb`, [this.writerToken, this.identityJson()]);
|
|
238
|
+
this.writerClaimed = false;
|
|
239
|
+
return result.rowCount === 1;
|
|
240
|
+
}
|
|
241
|
+
async execute(args) {
|
|
242
|
+
this.activeOperations += 1;
|
|
243
|
+
try {
|
|
244
|
+
return await this.executeCoordinated(args);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
this.activeOperations -= 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async executeCoordinated({ scope, operationKey, fingerprint, recover = null, projectResult = (value) => value, run }) {
|
|
251
|
+
this.assertWriterEnabled();
|
|
252
|
+
this.assertConfigured();
|
|
253
|
+
await this.assertWriter();
|
|
254
|
+
let shouldRecover = false;
|
|
255
|
+
let completedNeedsRehydrate = false;
|
|
256
|
+
let waitForExisting = false;
|
|
257
|
+
const client = await this.connect();
|
|
258
|
+
try {
|
|
259
|
+
await client.query('BEGIN');
|
|
260
|
+
await this.assertWriter(client);
|
|
261
|
+
const inserted = await client.query(`INSERT INTO canonical_task_operations (scope, operation_key, fingerprint, state, writer_token)
|
|
262
|
+
VALUES ($1, $2, $3, 'running', $4)
|
|
263
|
+
ON CONFLICT (scope, operation_key) DO NOTHING
|
|
264
|
+
RETURNING id`, [scope, operationKey, fingerprint, this.writerToken]);
|
|
265
|
+
if (!inserted.rowCount) {
|
|
266
|
+
const existing = await client.query(`SELECT fingerprint, state, result_json, writer_token FROM canonical_task_operations
|
|
267
|
+
WHERE scope = $1 AND operation_key = $2 FOR UPDATE`, [scope, operationKey]);
|
|
268
|
+
const row = existing.rows[0];
|
|
269
|
+
if (row?.state === 'failed' && (row.fingerprint === fingerprint || scope === 'task-version')) {
|
|
270
|
+
const reclaimed = await client.query(`UPDATE canonical_task_operations
|
|
271
|
+
SET fingerprint = $3, state = 'running', writer_token = $4,
|
|
272
|
+
result_json = NULL, error_json = NULL, updated_at = NOW()
|
|
273
|
+
WHERE scope = $1 AND operation_key = $2 AND state = 'failed'`, [scope, operationKey, fingerprint, this.writerToken]);
|
|
274
|
+
if (reclaimed.rowCount !== 1) {
|
|
275
|
+
throw canonicalTaskOperationError('canonical_task_operation_in_progress', 'Canonical Task operation changed while it was being retried', 409);
|
|
276
|
+
}
|
|
277
|
+
shouldRecover = typeof recover === 'function';
|
|
278
|
+
await client.query('COMMIT');
|
|
279
|
+
}
|
|
280
|
+
else if (row?.fingerprint !== fingerprint) {
|
|
281
|
+
throw canonicalTaskOperationError('idempotency_conflict', 'Idempotency key was reused with different input', 409);
|
|
282
|
+
}
|
|
283
|
+
else if (row?.state === 'completed') {
|
|
284
|
+
await client.query('COMMIT');
|
|
285
|
+
if (typeof recover !== 'function')
|
|
286
|
+
return row.result_json;
|
|
287
|
+
completedNeedsRehydrate = true;
|
|
288
|
+
}
|
|
289
|
+
else if (row?.state === 'running' && row.writer_token && row.writer_token !== this.writerToken && typeof recover === 'function') {
|
|
290
|
+
const reclaimed = await client.query(`UPDATE canonical_task_operations
|
|
291
|
+
SET writer_token = $3, updated_at = NOW()
|
|
292
|
+
WHERE scope = $1 AND operation_key = $2
|
|
293
|
+
AND state = 'running' AND writer_token = $4`, [scope, operationKey, this.writerToken, row.writer_token]);
|
|
294
|
+
if (reclaimed.rowCount !== 1) {
|
|
295
|
+
throw canonicalTaskOperationError('canonical_task_operation_in_progress', 'Canonical Task operation is already in progress', 409);
|
|
296
|
+
}
|
|
297
|
+
shouldRecover = true;
|
|
298
|
+
await client.query('COMMIT');
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
waitForExisting = true;
|
|
302
|
+
await client.query('COMMIT');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
await client.query('COMMIT');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
await client.query('ROLLBACK');
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
client.release?.();
|
|
315
|
+
}
|
|
316
|
+
if (completedNeedsRehydrate) {
|
|
317
|
+
const recovery = await recover();
|
|
318
|
+
if (recovery?.recovered)
|
|
319
|
+
return recovery.result;
|
|
320
|
+
throw canonicalTaskOperationError('canonical_task_operation_result_unavailable', 'Completed Canonical Task operation could not be rehydrated from the canonical store');
|
|
321
|
+
}
|
|
322
|
+
if (waitForExisting) {
|
|
323
|
+
const persistedResult = await this.waitForCompletedOperation({ scope, operationKey, fingerprint });
|
|
324
|
+
if (typeof recover === 'function') {
|
|
325
|
+
const recovery = await recover();
|
|
326
|
+
if (recovery?.recovered)
|
|
327
|
+
return recovery.result;
|
|
328
|
+
throw canonicalTaskOperationError('canonical_task_operation_result_unavailable', 'Completed Canonical Task operation could not be rehydrated from the canonical store');
|
|
329
|
+
}
|
|
330
|
+
return persistedResult;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const recovery = shouldRecover ? await recover() : null;
|
|
334
|
+
const result = recovery?.recovered ? recovery.result : await run();
|
|
335
|
+
await this.assertWriter();
|
|
336
|
+
await this.pool.query(`UPDATE canonical_task_operations
|
|
337
|
+
SET state = 'completed', result_json = $3::jsonb, updated_at = NOW()
|
|
338
|
+
WHERE scope = $1 AND operation_key = $2 AND writer_token = $4`, [scope, operationKey, JSON.stringify(projectResult(result)), this.writerToken]);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
await this.pool.query(`UPDATE canonical_task_operations
|
|
343
|
+
SET state = 'failed', error_json = $3::jsonb, updated_at = NOW()
|
|
344
|
+
WHERE scope = $1 AND operation_key = $2 AND writer_token = $4`, [scope, operationKey, JSON.stringify({ code: error?.code || null, message: error?.message || String(error) }), this.writerToken]).catch(() => { });
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async executePreparedDelete(args) {
|
|
349
|
+
this.activeOperations += 1;
|
|
350
|
+
try {
|
|
351
|
+
return await this.executePreparedDeleteCoordinated(args);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
this.activeOperations -= 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async executePreparedDeleteCoordinated({ operationKey, versionClaimKey, fingerprint, versionFingerprint, principalNamespace, prepare, findTask, removeTask, authorizeReplay, }) {
|
|
358
|
+
this.assertWriterEnabled();
|
|
359
|
+
this.assertConfigured();
|
|
360
|
+
await this.assertWriter();
|
|
361
|
+
const existing = await this.pool.query(`SELECT fingerprint, state, result_json, authorization_snapshot
|
|
362
|
+
FROM canonical_task_operations
|
|
363
|
+
WHERE scope = 'task-delete' AND operation_key = $1`, [operationKey]);
|
|
364
|
+
if (existing.rowCount) {
|
|
365
|
+
const row = existing.rows[0];
|
|
366
|
+
if (row.fingerprint !== fingerprint) {
|
|
367
|
+
throw canonicalTaskOperationError('idempotency_conflict', 'Idempotency key was reused with different input', 409);
|
|
368
|
+
}
|
|
369
|
+
if (row.state === 'completed') {
|
|
370
|
+
if (typeof authorizeReplay !== 'function') {
|
|
371
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Completed Canonical Task delete operation cannot be replayed without authorization revalidation', 503);
|
|
372
|
+
}
|
|
373
|
+
const authorizationSnapshot = authorizationSnapshotRecord(row.authorization_snapshot);
|
|
374
|
+
if (!authorizationSnapshot) {
|
|
375
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Completed Canonical Task delete operation cannot be replayed without authorization revalidation', 503);
|
|
376
|
+
}
|
|
377
|
+
await authorizeReplay(authorizationSnapshot, row.result_json);
|
|
378
|
+
return row.result_json;
|
|
379
|
+
}
|
|
380
|
+
if (row.state !== 'prepared') {
|
|
381
|
+
throw canonicalTaskOperationError('canonical_task_operation_in_progress', 'Canonical Task delete operation is already in progress', 409);
|
|
382
|
+
}
|
|
383
|
+
return this.completePreparedDelete({ operationKey, versionClaimKey, row, findTask, removeTask, authorizeReplay });
|
|
384
|
+
}
|
|
385
|
+
const versionClaim = await this.pool.query(`SELECT authorization_snapshot
|
|
386
|
+
FROM canonical_task_operations
|
|
387
|
+
WHERE scope = 'task-version' AND operation_key = $1`, [versionClaimKey]);
|
|
388
|
+
if (versionClaim.rowCount && versionClaim.rows[0]?.authorization_snapshot?.principal_namespace === principalNamespace) {
|
|
389
|
+
throw canonicalTaskOperationError('version_conflict', 'Task version was already claimed by another operation', 409);
|
|
390
|
+
}
|
|
391
|
+
const prepared = await prepare();
|
|
392
|
+
const authorizationSnapshot = { ...prepared.authorizationSnapshot, principal_namespace: principalNamespace };
|
|
393
|
+
const client = await this.connect();
|
|
394
|
+
try {
|
|
395
|
+
await client.query('BEGIN');
|
|
396
|
+
await this.assertWriter(client);
|
|
397
|
+
const claimed = await client.query(`INSERT INTO canonical_task_operations (
|
|
398
|
+
scope, operation_key, fingerprint, state, writer_token,
|
|
399
|
+
result_json, authorization_snapshot, recovery_checkpoint
|
|
400
|
+
) VALUES ('task-version', $1, $2, 'prepared', $3, $4::jsonb, $5::jsonb, $6::jsonb)
|
|
401
|
+
ON CONFLICT (scope, operation_key) DO NOTHING
|
|
402
|
+
RETURNING id`, [
|
|
403
|
+
versionClaimKey,
|
|
404
|
+
versionFingerprint,
|
|
405
|
+
this.writerToken,
|
|
406
|
+
JSON.stringify(prepared.result),
|
|
407
|
+
JSON.stringify(authorizationSnapshot),
|
|
408
|
+
JSON.stringify({ phase: 'prepared', task_present: true }),
|
|
409
|
+
]);
|
|
410
|
+
if (!claimed.rowCount) {
|
|
411
|
+
throw canonicalTaskOperationError('version_conflict', 'Task version was already claimed by another operation', 409);
|
|
412
|
+
}
|
|
413
|
+
const intent = await client.query(`INSERT INTO canonical_task_operations (
|
|
414
|
+
scope, operation_key, fingerprint, state, writer_token,
|
|
415
|
+
result_json, authorization_snapshot, recovery_checkpoint
|
|
416
|
+
) VALUES ('task-delete', $1, $2, 'prepared', $3, $4::jsonb, $5::jsonb, $6::jsonb)
|
|
417
|
+
ON CONFLICT (scope, operation_key) DO NOTHING
|
|
418
|
+
RETURNING id`, [
|
|
419
|
+
operationKey,
|
|
420
|
+
fingerprint,
|
|
421
|
+
this.writerToken,
|
|
422
|
+
JSON.stringify(prepared.result),
|
|
423
|
+
JSON.stringify(authorizationSnapshot),
|
|
424
|
+
JSON.stringify({ phase: 'prepared', task_present: true, version_claim_key: versionClaimKey }),
|
|
425
|
+
]);
|
|
426
|
+
if (!intent.rowCount) {
|
|
427
|
+
throw canonicalTaskOperationError('canonical_task_operation_in_progress', 'Canonical Task delete operation is already in progress', 409);
|
|
428
|
+
}
|
|
429
|
+
await client.query('COMMIT');
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
await client.query('ROLLBACK');
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
client.release?.();
|
|
437
|
+
}
|
|
438
|
+
return this.completePreparedDelete({
|
|
439
|
+
operationKey,
|
|
440
|
+
versionClaimKey,
|
|
441
|
+
row: { fingerprint, result_json: prepared.result, authorization_snapshot: authorizationSnapshot },
|
|
442
|
+
findTask,
|
|
443
|
+
removeTask,
|
|
444
|
+
authorizeReplay,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
async completePreparedDelete({ operationKey, versionClaimKey, row, findTask, removeTask, authorizeReplay }) {
|
|
448
|
+
await this.assertWriter();
|
|
449
|
+
const snapshotRecord = authorizationSnapshotRecord(row.authorization_snapshot);
|
|
450
|
+
if (!snapshotRecord) {
|
|
451
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Prepared Canonical Task delete operation has no authorization snapshot', 503);
|
|
452
|
+
}
|
|
453
|
+
const task = await findTask();
|
|
454
|
+
if (task) {
|
|
455
|
+
await removeTask(task);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
if (typeof authorizeReplay !== 'function') {
|
|
459
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Prepared Canonical Task delete operation cannot be resumed without authorization revalidation', 503);
|
|
460
|
+
}
|
|
461
|
+
await authorizeReplay(snapshotRecord, row.result_json);
|
|
462
|
+
}
|
|
463
|
+
const client = await this.connect();
|
|
464
|
+
try {
|
|
465
|
+
await client.query('BEGIN');
|
|
466
|
+
await this.assertWriter(client);
|
|
467
|
+
const completed = await client.query(`UPDATE canonical_task_operations
|
|
468
|
+
SET state = 'completed', writer_token = $3,
|
|
469
|
+
recovery_checkpoint = $4::jsonb, error_json = NULL, updated_at = NOW()
|
|
470
|
+
WHERE scope = 'task-delete' AND operation_key = $1
|
|
471
|
+
AND fingerprint = $2 AND state = 'prepared'`, [
|
|
472
|
+
operationKey,
|
|
473
|
+
row.fingerprint,
|
|
474
|
+
this.writerToken,
|
|
475
|
+
JSON.stringify({ phase: 'completed', task_present: false, version_claim_key: versionClaimKey }),
|
|
476
|
+
]);
|
|
477
|
+
if (!completed.rowCount) {
|
|
478
|
+
const replay = await client.query(`SELECT fingerprint, state, result_json, authorization_snapshot
|
|
479
|
+
FROM canonical_task_operations
|
|
480
|
+
WHERE scope = 'task-delete' AND operation_key = $1 FOR UPDATE`, [operationKey]);
|
|
481
|
+
if (replay.rows[0]?.fingerprint !== row.fingerprint || replay.rows[0]?.state !== 'completed') {
|
|
482
|
+
throw canonicalTaskOperationError('canonical_task_operation_in_progress', 'Canonical Task delete completion could not be claimed', 409);
|
|
483
|
+
}
|
|
484
|
+
if (typeof authorizeReplay !== 'function') {
|
|
485
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Completed Canonical Task delete operation cannot be replayed without authorization revalidation', 503);
|
|
486
|
+
}
|
|
487
|
+
const replaySnapshot = authorizationSnapshotRecord(replay.rows[0].authorization_snapshot);
|
|
488
|
+
if (!replaySnapshot) {
|
|
489
|
+
throw canonicalTaskOperationError('canonical_task_authorization_snapshot_unavailable', 'Completed Canonical Task delete operation cannot be replayed without authorization revalidation', 503);
|
|
490
|
+
}
|
|
491
|
+
await authorizeReplay(replaySnapshot, replay.rows[0].result_json);
|
|
492
|
+
await client.query('COMMIT');
|
|
493
|
+
return replay.rows[0].result_json;
|
|
494
|
+
}
|
|
495
|
+
await client.query(`UPDATE canonical_task_operations
|
|
496
|
+
SET state = 'completed', writer_token = $2,
|
|
497
|
+
recovery_checkpoint = $3::jsonb, error_json = NULL, updated_at = NOW()
|
|
498
|
+
WHERE scope = 'task-version' AND operation_key = $1 AND state = 'prepared'`, [
|
|
499
|
+
versionClaimKey,
|
|
500
|
+
this.writerToken,
|
|
501
|
+
JSON.stringify({ phase: 'completed', task_present: false, delete_operation_key: operationKey }),
|
|
502
|
+
]);
|
|
503
|
+
await client.query('COMMIT');
|
|
504
|
+
return row.result_json;
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
await client.query('ROLLBACK');
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
client.release?.();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
export class CanonicalTaskReadinessError extends Error {
|
|
516
|
+
code;
|
|
517
|
+
status = 503;
|
|
518
|
+
details;
|
|
519
|
+
constructor(code = 'canonical_task_mutation_not_ready', message = 'Canonical Task mutation is not ready') {
|
|
520
|
+
super(message);
|
|
521
|
+
this.name = 'CanonicalTaskReadinessError';
|
|
522
|
+
this.code = code;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Opens Canonical Task mutation only while the persisted readiness row was
|
|
527
|
+
* blessed for this release: the same writer, manifest, schema and source head.
|
|
528
|
+
*/
|
|
529
|
+
export class CanonicalTaskReadiness {
|
|
530
|
+
operationRepository;
|
|
531
|
+
expected;
|
|
532
|
+
sourceHeadRebindGuard;
|
|
533
|
+
logger;
|
|
534
|
+
ready = false;
|
|
535
|
+
reason = 'readiness_not_verified';
|
|
536
|
+
evidence = null;
|
|
537
|
+
constructor({ operationRepository = null, manifestHash = null, schemaVersion = null, sourceHead = null, sourceHeadRebindGuard = null, logger = console, } = {}) {
|
|
538
|
+
this.operationRepository = operationRepository;
|
|
539
|
+
this.expected = { manifestHash, schemaVersion, sourceHead };
|
|
540
|
+
this.sourceHeadRebindGuard = sourceHeadRebindGuard;
|
|
541
|
+
this.logger = logger;
|
|
542
|
+
}
|
|
543
|
+
open(evidence = null) {
|
|
544
|
+
this.ready = true;
|
|
545
|
+
this.reason = null;
|
|
546
|
+
this.evidence = evidence;
|
|
547
|
+
}
|
|
548
|
+
close(reason = 'readiness_disabled') {
|
|
549
|
+
this.ready = false;
|
|
550
|
+
this.reason = reason;
|
|
551
|
+
this.evidence = null;
|
|
552
|
+
}
|
|
553
|
+
matches(row) {
|
|
554
|
+
return Boolean(row?.ready
|
|
555
|
+
&& row.writer_token === this.operationRepository?.writerToken
|
|
556
|
+
&& row.manifest_hash === this.expected.manifestHash
|
|
557
|
+
&& row.schema_version === this.expected.schemaVersion
|
|
558
|
+
&& row.source_head === this.expected.sourceHead
|
|
559
|
+
&& row.evidence_hash);
|
|
560
|
+
}
|
|
561
|
+
async refresh({ allowWriterRebind = false } = {}) {
|
|
562
|
+
const row = await this.operationRepository.reconcileReadiness({
|
|
563
|
+
manifestHash: this.expected.manifestHash,
|
|
564
|
+
schemaVersion: this.expected.schemaVersion,
|
|
565
|
+
sourceHead: this.expected.sourceHead,
|
|
566
|
+
allowWriterRebind,
|
|
567
|
+
});
|
|
568
|
+
if (!this.matches(row)) {
|
|
569
|
+
this.close(row?.ready ? 'persisted_readiness_mismatch' : (row?.reason || 'persisted_readiness_missing'));
|
|
570
|
+
return { ready: false, reason: this.reason };
|
|
571
|
+
}
|
|
572
|
+
this.open({ evidence_hash: row.evidence_hash, evidence_path: row.evidence_path, updated_at: row.updated_at });
|
|
573
|
+
return { ready: true, evidence: this.evidence };
|
|
574
|
+
}
|
|
575
|
+
// Readiness rows are blessed for one source_head. When a deploy restarts the
|
|
576
|
+
// process on a newer HEAD, mutation stays fail-closed unless the guard proves
|
|
577
|
+
// that nothing Canonical-Task-relevant changed between the two heads.
|
|
578
|
+
async attemptSourceHeadRebind() {
|
|
579
|
+
if (!this.sourceHeadRebindGuard || typeof this.operationRepository?.rebindReadinessSourceHead !== 'function')
|
|
580
|
+
return false;
|
|
581
|
+
const row = await this.operationRepository.readReadiness();
|
|
582
|
+
const rebindCandidate = Boolean(row?.ready
|
|
583
|
+
&& row.manifest_hash === this.expected.manifestHash
|
|
584
|
+
&& row.schema_version === this.expected.schemaVersion
|
|
585
|
+
&& row.evidence_hash
|
|
586
|
+
&& row.source_head
|
|
587
|
+
&& row.source_head !== this.expected.sourceHead);
|
|
588
|
+
if (!rebindCandidate)
|
|
589
|
+
return false;
|
|
590
|
+
const verdict = await this.sourceHeadRebindGuard({ fromHead: row.source_head, toHead: this.expected.sourceHead });
|
|
591
|
+
if (!verdict?.allowed) {
|
|
592
|
+
const changed = [...(verdict?.changedPaths ?? []), ...(verdict?.changedPackages ?? [])];
|
|
593
|
+
this.logger?.warn?.(`[canonical-task] source_head rebind refused (${verdict?.reason || 'unknown'}): `
|
|
594
|
+
+ `${row.source_head} -> ${this.expected.sourceHead}`
|
|
595
|
+
+ (changed.length ? ` changed=${changed.join(',')}` : ''));
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
await this.operationRepository.rebindReadinessSourceHead({
|
|
599
|
+
manifestHash: this.expected.manifestHash,
|
|
600
|
+
schemaVersion: this.expected.schemaVersion,
|
|
601
|
+
fromHead: row.source_head,
|
|
602
|
+
toHead: this.expected.sourceHead,
|
|
603
|
+
});
|
|
604
|
+
this.logger?.warn?.(`[canonical-task] readiness source_head rebound after guarded diff check: ${row.source_head} -> ${this.expected.sourceHead}`);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
async initialize() {
|
|
608
|
+
this.close('readiness_not_verified');
|
|
609
|
+
if (!this.operationRepository) {
|
|
610
|
+
this.close('coordination_repository_unavailable');
|
|
611
|
+
return { ready: false, reason: this.reason };
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
await this.operationRepository.claimWriter({ sourceHead: this.expected.sourceHead });
|
|
615
|
+
const result = await this.refresh({ allowWriterRebind: true });
|
|
616
|
+
if (result.ready || this.reason !== 'persisted_readiness_mismatch')
|
|
617
|
+
return result;
|
|
618
|
+
if (await this.attemptSourceHeadRebind())
|
|
619
|
+
return await this.refresh({ allowWriterRebind: true });
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
this.close(error?.code || 'readiness_reconcile_failed');
|
|
624
|
+
return { ready: false, reason: this.reason, error };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async assertMutationReady() {
|
|
628
|
+
try {
|
|
629
|
+
await this.refresh({ allowWriterRebind: false });
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
this.close(error?.code || 'readiness_reconcile_failed');
|
|
633
|
+
}
|
|
634
|
+
if (!this.ready) {
|
|
635
|
+
const error = new CanonicalTaskReadinessError();
|
|
636
|
+
error.details = { reason: this.reason };
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const execFileAsync = promisify(execFileCallback);
|
|
642
|
+
/**
|
|
643
|
+
* Decides whether readiness blessed for one source head may follow a deploy
|
|
644
|
+
* to another head. It allows the rebind only when git proves that no guarded
|
|
645
|
+
* path and no guarded package changed between them; anything unknown stays
|
|
646
|
+
* fail-closed.
|
|
647
|
+
*/
|
|
648
|
+
export function createCanonicalTaskSourceHeadGuard({ repoDir, guardedPaths, guardedPackages = [], lockfilePath = 'package-lock.json', execFile = execFileAsync, }) {
|
|
649
|
+
if (!Array.isArray(guardedPaths) || guardedPaths.length === 0 || guardedPaths.some((path) => typeof path !== 'string' || !path)) {
|
|
650
|
+
throw new Error('Canonical Task source head guard requires guarded paths');
|
|
651
|
+
}
|
|
652
|
+
const lockedEntry = async (head, name) => {
|
|
653
|
+
try {
|
|
654
|
+
const text = String((await execFile('git', ['-C', repoDir, 'show', `${head}:${lockfilePath}`])).stdout);
|
|
655
|
+
const lock = JSON.parse(text);
|
|
656
|
+
const entry = lock?.packages?.[`node_modules/${name}`] ?? lock?.dependencies?.[name] ?? null;
|
|
657
|
+
return entry ? { version: entry.version ?? null, resolved: entry.resolved ?? null, integrity: entry.integrity ?? null } : null;
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
return async function canRebindSourceHead({ fromHead, toHead } = {}) {
|
|
664
|
+
if (!repoDir)
|
|
665
|
+
return { allowed: false, reason: 'source_head_guard_repo_missing' };
|
|
666
|
+
if (!fromHead || !toHead)
|
|
667
|
+
return { allowed: false, reason: 'source_head_guard_head_missing' };
|
|
668
|
+
if (fromHead === toHead)
|
|
669
|
+
return { allowed: true, changedPaths: [] };
|
|
670
|
+
try {
|
|
671
|
+
const { stdout } = await execFile('git', ['-C', repoDir, 'diff', '--name-only', `${fromHead}..${toHead}`, '--', ...guardedPaths]);
|
|
672
|
+
const changedPaths = String(stdout).split('\n').map((line) => line.trim()).filter(Boolean);
|
|
673
|
+
if (changedPaths.length > 0)
|
|
674
|
+
return { allowed: false, reason: 'canonical_task_paths_changed', changedPaths };
|
|
675
|
+
const changedPackages = [];
|
|
676
|
+
for (const name of guardedPackages) {
|
|
677
|
+
const [before, after] = [await lockedEntry(fromHead, name), await lockedEntry(toHead, name)];
|
|
678
|
+
if (!before || !after) {
|
|
679
|
+
return {
|
|
680
|
+
allowed: false,
|
|
681
|
+
reason: 'canonical_task_package_lock_unreadable',
|
|
682
|
+
changedPaths: [],
|
|
683
|
+
changedPackages: [name],
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
687
|
+
changedPackages.push(name);
|
|
688
|
+
}
|
|
689
|
+
if (changedPackages.length > 0) {
|
|
690
|
+
return { allowed: false, reason: 'canonical_task_packages_changed', changedPaths: [], changedPackages };
|
|
691
|
+
}
|
|
692
|
+
return { allowed: true, changedPaths: [] };
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
// Unknown commits (shallow clone, rewritten history) or an unreadable
|
|
696
|
+
// lockfile must stay fail-closed.
|
|
697
|
+
return { allowed: false, reason: 'source_head_diff_failed', error: error?.message || String(error) };
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
}
|