ultra-builder-pro-cli 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +10 -0
- package/adapters/claude.js +104 -0
- package/adapters/codex.js +363 -40
- package/adapters/grok.js +1254 -58
- package/adapters/kimi.js +682 -13
- package/adapters/opencode.js +132 -11
- package/agents/code-reviewer.md +4 -5
- package/agents/debugger.md +8 -8
- package/docs/AGENT-CONTEXT.md +30 -4
- package/docs/ARCHITECTURE.md +24 -5
- package/docs/ARTIFACT-AUTHORITY.md +13 -0
- package/docs/COMMIT-HASH-BACKFILL.md +25 -18
- package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
- package/docs/STATE-DB-ACCESS-POLICY.md +8 -1
- package/mcp-server/lib/artifact-registry.cjs +188 -107
- package/mcp-server/lib/baseline-workflow.cjs +160 -23
- package/mcp-server/lib/change-packet.cjs +4 -32
- package/mcp-server/lib/change-workflow.cjs +36 -2755
- package/mcp-server/lib/context-envelope.cjs +124 -51
- package/mcp-server/lib/decision-records.cjs +164 -78
- package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
- package/mcp-server/lib/legacy-change-workflow.cjs +3032 -0
- package/mcp-server/lib/managed-file-write.cjs +56 -0
- package/mcp-server/lib/prd-parser.cjs +2 -2
- package/mcp-server/lib/stage-checkpoints.cjs +163 -9
- package/mcp-server/lib/state-db.cjs +186 -8
- package/mcp-server/lib/state-ops.cjs +214 -31
- package/mcp-server/lib/task-contract.cjs +404 -0
- package/mcp-server/lib/task-expander.cjs +8 -3
- package/mcp-server/lib/task-ledger.cjs +175 -62
- package/mcp-server/lib/ultra-facade.cjs +1895 -172
- package/mcp-server/lib/worker-packet.cjs +2 -2
- package/mcp-server/server.cjs +24 -5
- package/orchestrator/parallel-orchestrator.cjs +114 -27
- package/orchestrator/session-runner.cjs +25 -136
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +6 -1
- package/skills/ultra-dev/SKILL.md +9 -4
- package/skills/ultra-plan/SKILL.md +13 -4
- package/skills/ultra-plan/references/semantic-preflight.md +6 -4
- package/spec/mcp-tools.yaml +114 -9
- package/spec/schemas/context-file.v4.5.schema.json +2 -2
- package/spec/schemas/state-db.sql +10 -6
- package/spec/schemas/task-ledger.v2.schema.json +23 -8
- package/spec/schemas/tasks.v4.5.schema.json +34 -12
|
@@ -2,19 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
5
6
|
|
|
6
7
|
const artifactRegistry = require('./artifact-registry.cjs');
|
|
7
8
|
const baselines = require('./baseline-workflow.cjs');
|
|
9
|
+
const canonical = require('./canonical-json.cjs');
|
|
8
10
|
const changes = require('./change-workflow.cjs');
|
|
9
11
|
const contextEnvelope = require('./context-envelope.cjs');
|
|
10
12
|
const decisionRecords = require('./decision-records.cjs');
|
|
13
|
+
const deliveryTransaction = require('./delivery-transaction.cjs');
|
|
11
14
|
const doctor = require('./doctor.cjs');
|
|
12
15
|
const gitBootstrap = require('./git-bootstrap.cjs');
|
|
16
|
+
const runtimePaths = require('./runtime-paths.cjs');
|
|
13
17
|
const { initProject } = require('./init-project.cjs');
|
|
14
18
|
const ops = require('./state-ops.cjs');
|
|
15
19
|
const planCheckpoint = require('./plan-checkpoint.cjs');
|
|
16
20
|
const { readStableProjectFile } = require('./safe-project-file.cjs');
|
|
17
21
|
const stageCheckpoints = require('./stage-checkpoints.cjs');
|
|
22
|
+
const taskContract = require('./task-contract.cjs');
|
|
18
23
|
const taskLedger = require('./task-ledger.cjs');
|
|
19
24
|
const workerPacket = require('./worker-packet.cjs');
|
|
20
25
|
const sessionRunner = require('../../orchestrator/session-runner.cjs');
|
|
@@ -59,13 +64,16 @@ const HARD_ERROR_CODES = new Set([
|
|
|
59
64
|
'OUTPUT_SCHEMA_DRIFT',
|
|
60
65
|
'PATH_AUTHORITY_VIOLATION',
|
|
61
66
|
'PLAN_RECOVERY_REQUIRED',
|
|
67
|
+
'RECORD_FILE_ROLLBACK_REQUIRED',
|
|
62
68
|
'RUNTIME_ABI_MISMATCH',
|
|
63
69
|
'RUNTIME_NATIVE_MISSING',
|
|
64
70
|
'SCHEMA_VERSION_MISMATCH',
|
|
71
|
+
'SESSION_RECEIPT_RECOVERY_REQUIRED',
|
|
65
72
|
'SESSION_STATUS_CONFLICT',
|
|
66
73
|
'STATE_CORRUPT',
|
|
67
74
|
'STATE_DB_ERROR',
|
|
68
75
|
'STATE_DB_MISSING',
|
|
76
|
+
'STATE_PERSISTENCE_FAILED',
|
|
69
77
|
'TASK_LEDGER_BASELINE_CONFLICT',
|
|
70
78
|
'TASK_LEDGER_CHANGE_CONFLICT',
|
|
71
79
|
'TASK_LEDGER_HISTORY_CONFLICT',
|
|
@@ -96,45 +104,111 @@ function parseJson(value, fallback = null) {
|
|
|
96
104
|
catch { return fallback; }
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
function
|
|
100
|
-
|
|
107
|
+
function requestDigest(operation, request) {
|
|
108
|
+
return canonical.digest({ operation, request });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function priorResult(db, {
|
|
112
|
+
idempotencyKey,
|
|
113
|
+
operation,
|
|
114
|
+
requestDigest: expectedDigest,
|
|
115
|
+
}) {
|
|
116
|
+
if (!db || !idempotencyKey || !expectedDigest) return { found: false };
|
|
101
117
|
const rows = db.prepare(
|
|
102
118
|
`SELECT payload_json FROM events
|
|
103
119
|
WHERE type = 'ultra_kernel_call'
|
|
104
|
-
ORDER BY id DESC
|
|
120
|
+
ORDER BY id DESC`,
|
|
105
121
|
).all();
|
|
106
122
|
for (const row of rows) {
|
|
107
123
|
const payload = parseJson(row.payload_json, {});
|
|
108
|
-
if (payload.idempotency_key
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
if (payload.idempotency_key !== idempotencyKey || payload.accepted !== true) continue;
|
|
125
|
+
if (payload.operation === operation
|
|
126
|
+
&& (payload.request_digest === expectedDigest
|
|
127
|
+
|| typeof payload.request_digest !== 'string')) {
|
|
128
|
+
return { found: true, result: payload.result };
|
|
112
129
|
}
|
|
130
|
+
throw Object.assign(
|
|
131
|
+
new Error(`idempotency key ${idempotencyKey} was already used for another request`),
|
|
132
|
+
{
|
|
133
|
+
code: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
134
|
+
details: {
|
|
135
|
+
idempotency_key: idempotencyKey,
|
|
136
|
+
prior_operation: payload.operation || null,
|
|
137
|
+
requested_operation: operation,
|
|
138
|
+
prior_request_digest: payload.request_digest,
|
|
139
|
+
requested_request_digest: expectedDigest,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
);
|
|
113
143
|
}
|
|
114
|
-
return
|
|
144
|
+
return { found: false };
|
|
115
145
|
}
|
|
116
146
|
|
|
117
|
-
function
|
|
147
|
+
function rememberResultInTx(db, {
|
|
118
148
|
idempotencyKey,
|
|
119
149
|
operation,
|
|
150
|
+
requestDigest: acceptedRequestDigest,
|
|
120
151
|
result,
|
|
121
152
|
changeId = null,
|
|
122
153
|
taskId = null,
|
|
123
154
|
}) {
|
|
124
155
|
if (!db || !idempotencyKey) return;
|
|
125
|
-
ops.
|
|
156
|
+
ops.appendEventInTx(db, {
|
|
126
157
|
type: 'ultra_kernel_call',
|
|
127
158
|
change_id: changeId,
|
|
128
159
|
task_id: taskId,
|
|
129
160
|
payload: {
|
|
130
161
|
idempotency_key: idempotencyKey,
|
|
131
162
|
operation,
|
|
163
|
+
request_digest: acceptedRequestDigest,
|
|
132
164
|
accepted: true,
|
|
133
165
|
result,
|
|
134
166
|
},
|
|
135
167
|
});
|
|
136
168
|
}
|
|
137
169
|
|
|
170
|
+
function rememberRejectedAttempt(db, tool, input, result) {
|
|
171
|
+
if (!db || result?.accepted !== false) return result;
|
|
172
|
+
const nested = Array.isArray(result.results)
|
|
173
|
+
? result.results.flatMap((item) => item.diagnostics || [])
|
|
174
|
+
: [];
|
|
175
|
+
const diagnostics = [...(result.diagnostics || []), ...nested]
|
|
176
|
+
.map((item) => ({
|
|
177
|
+
code: item.code || 'NEEDS_ATTENTION',
|
|
178
|
+
severity: item.severity || 'needs_attention',
|
|
179
|
+
message: item.message || null,
|
|
180
|
+
...(item.details === undefined ? {} : { details: item.details }),
|
|
181
|
+
}));
|
|
182
|
+
const entries = Array.isArray(input.entries) ? input.entries : [];
|
|
183
|
+
const changeIds = [...new Set(entries.flatMap((entry) => [
|
|
184
|
+
entry.data?.change_id,
|
|
185
|
+
entry.kind === 'change_contract' ? entry.data?.id : null,
|
|
186
|
+
]).filter(Boolean))];
|
|
187
|
+
const taskIds = [...new Set(entries.flatMap((entry) => [
|
|
188
|
+
entry.kind.startsWith('task_') ? entry.data?.id : null,
|
|
189
|
+
input.scope?.task_id,
|
|
190
|
+
]).filter(Boolean))];
|
|
191
|
+
const scopeChangeId = input.scope?.change_id || input.change_id || null;
|
|
192
|
+
ops.appendEvent(db, {
|
|
193
|
+
type: 'ultra_kernel_attempt',
|
|
194
|
+
change_id: changeIds.length === 1 ? changeIds[0] : scopeChangeId,
|
|
195
|
+
task_id: taskIds.length === 1 ? taskIds[0] : null,
|
|
196
|
+
payload: {
|
|
197
|
+
tool,
|
|
198
|
+
accepted: false,
|
|
199
|
+
mutable: result.mutable !== false,
|
|
200
|
+
operations: entries.map((entry) => `${entry.kind}:${entry.action}`),
|
|
201
|
+
idempotency_keys: entries.length > 0
|
|
202
|
+
? entries.map((entry) => entry.idempotency_key).filter(Boolean)
|
|
203
|
+
: [input.idempotency_key].filter(Boolean),
|
|
204
|
+
scope: input.scope || (input.change_id ? { change_id: input.change_id } : {}),
|
|
205
|
+
blockers: result.blockers || [],
|
|
206
|
+
diagnostics,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
138
212
|
function diagnostic(error, severity = 'needs_attention') {
|
|
139
213
|
return {
|
|
140
214
|
code: errorCode(error),
|
|
@@ -144,6 +218,799 @@ function diagnostic(error, severity = 'needs_attention') {
|
|
|
144
218
|
};
|
|
145
219
|
}
|
|
146
220
|
|
|
221
|
+
function validationError(message, details) {
|
|
222
|
+
return Object.assign(new Error(message), {
|
|
223
|
+
code: 'VALIDATION_ERROR',
|
|
224
|
+
...(details === undefined ? {} : { details }),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stablePersistenceError(error, operation) {
|
|
229
|
+
const code = errorCode(error);
|
|
230
|
+
if (!code.startsWith('SQLITE_') && code !== 'STATE_DB_ERROR') return error;
|
|
231
|
+
return Object.assign(
|
|
232
|
+
new Error(`${operation} could not persist its atomic receipt`),
|
|
233
|
+
{
|
|
234
|
+
code: 'STATE_PERSISTENCE_FAILED',
|
|
235
|
+
cause: error,
|
|
236
|
+
details: { cause: code },
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function assertExactFields(value, allowed, field) {
|
|
242
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
243
|
+
throw validationError(`${field} must be an object`);
|
|
244
|
+
}
|
|
245
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
246
|
+
if (unknown.length > 0) {
|
|
247
|
+
throw validationError(`${field}.${unknown[0]} is not allowed`, {
|
|
248
|
+
field: `${field}.${unknown[0]}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const RECORD_DATA_FIELDS = Object.freeze({
|
|
254
|
+
'baseline:initialize': [
|
|
255
|
+
'target_dir', 'project_name', 'project_type', 'stack', 'mode', 'scope',
|
|
256
|
+
'resume', 'overwrite', 'git_mode', 'source_template',
|
|
257
|
+
],
|
|
258
|
+
'baseline:start': [
|
|
259
|
+
'id', 'project_name', 'project_type', 'stack', 'mode', 'repository_revision',
|
|
260
|
+
'replace_migrated', 'replace_ready', 'replacement_authorization', 'scope',
|
|
261
|
+
'provider_refs', 'classification',
|
|
262
|
+
],
|
|
263
|
+
'baseline:observe': [
|
|
264
|
+
'id', 'repository_revision', 'scope', 'spec_refs', 'evidence', 'verification',
|
|
265
|
+
'unknowns', 'gaps', 'classification', 'provider_refs',
|
|
266
|
+
],
|
|
267
|
+
'baseline:revise': [
|
|
268
|
+
'id', 'repository_revision', 'scope', 'spec_refs', 'evidence', 'verification',
|
|
269
|
+
'unknowns', 'gaps', 'classification', 'provider_refs',
|
|
270
|
+
],
|
|
271
|
+
'baseline:accept': [
|
|
272
|
+
'id', 'expected_revision', 'approved_by', 'approval_note',
|
|
273
|
+
'accept_dirty_worktree', 'accept_known_red',
|
|
274
|
+
],
|
|
275
|
+
'change_contract:open': [
|
|
276
|
+
'id', 'title', 'kind', 'intent', 'docs_impact', 'provider_refs',
|
|
277
|
+
'baseline_bypass', 'contract', 'classification', 'research_disposition',
|
|
278
|
+
'base_commit',
|
|
279
|
+
],
|
|
280
|
+
'change_contract:revise': ['id', 'patch'],
|
|
281
|
+
'change_contract:cancel': ['id'],
|
|
282
|
+
'change_contract:supersede': ['id', 'successor_id', 'title', 'kind', 'intent'],
|
|
283
|
+
'decision:accept': [
|
|
284
|
+
'id', 'scope', 'question', 'recommendation', 'selection', 'effects',
|
|
285
|
+
'non_goals', 'owner', 'source', 'provenance', 'applied_refs', 'supersedes_id',
|
|
286
|
+
],
|
|
287
|
+
'task_contract:define': taskContract.TASK_CONTRACT_DEFINE_FIELDS,
|
|
288
|
+
'task_contract:revise': ['id', 'patch'],
|
|
289
|
+
'task_contract:remove': ['id'],
|
|
290
|
+
'task_outcome:start': ['id'],
|
|
291
|
+
'task_outcome:complete': ['id', 'packet_digest'],
|
|
292
|
+
'task_outcome:block': ['id'],
|
|
293
|
+
'task_outcome:reopen': ['id'],
|
|
294
|
+
'task_outcome:attest_commit': ['id', 'completion_commit'],
|
|
295
|
+
'artifact:bind': [
|
|
296
|
+
'id', 'owner_type', 'owner_id', 'change_id', 'task_id', 'kind', 'path',
|
|
297
|
+
'status', 'source_refs', 'consumer_refs', 'provenance', 'metadata',
|
|
298
|
+
'content_digest', 'expected_before_digest',
|
|
299
|
+
],
|
|
300
|
+
'event:append': ['type', 'task_id', 'change_id', 'session_id', 'runtime', 'payload'],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
function requireText(value, field, { nullable = false } = {}) {
|
|
304
|
+
if (value === null && nullable) return;
|
|
305
|
+
if (typeof value !== 'string') {
|
|
306
|
+
throw validationError(`${field} must be a string${nullable ? ' or null' : ''}`, { field });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function optionalText(value, key, field, options) {
|
|
311
|
+
if (Object.hasOwn(value, key)) requireText(value[key], `${field}.${key}`, options);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function optionalBoolean(value, key, field) {
|
|
315
|
+
if (Object.hasOwn(value, key) && typeof value[key] !== 'boolean') {
|
|
316
|
+
throw validationError(`${field}.${key} must be a boolean`, { field: `${field}.${key}` });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function optionalObject(value, key, field) {
|
|
321
|
+
if (!Object.hasOwn(value, key)) return;
|
|
322
|
+
const candidate = value[key];
|
|
323
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
324
|
+
throw validationError(`${field}.${key} must be an object`, { field: `${field}.${key}` });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function optionalArray(value, key, field) {
|
|
329
|
+
if (Object.hasOwn(value, key) && !Array.isArray(value[key])) {
|
|
330
|
+
throw validationError(`${field}.${key} must be an array`, { field: `${field}.${key}` });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateStringArray(value, field) {
|
|
335
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
336
|
+
throw validationError(`${field} must be an array of strings`, { field });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validateObjectArray(value, field, allowed = null) {
|
|
341
|
+
if (!Array.isArray(value)) {
|
|
342
|
+
throw validationError(`${field} must be an array`, { field });
|
|
343
|
+
}
|
|
344
|
+
value.forEach((item, index) => {
|
|
345
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
346
|
+
throw validationError(`${field}[${index}] must be an object`, {
|
|
347
|
+
field: `${field}[${index}]`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (allowed) assertExactFields(item, allowed, `${field}[${index}]`);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function validateDocsImpact(value, field) {
|
|
355
|
+
assertExactFields(value, ['status', 'files', 'rationale'], field);
|
|
356
|
+
optionalText(value, 'status', field);
|
|
357
|
+
if (Object.hasOwn(value, 'files')) validateStringArray(value.files, `${field}.files`);
|
|
358
|
+
optionalText(value, 'rationale', field, { nullable: true });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validateChangeAuthority(value, field) {
|
|
362
|
+
for (const key of ['title', 'intent', 'status', 'base_commit']) {
|
|
363
|
+
optionalText(value, key, field);
|
|
364
|
+
}
|
|
365
|
+
for (const key of [
|
|
366
|
+
'docs_impact', 'provider_refs', 'baseline_bypass', 'contract',
|
|
367
|
+
'classification', 'research_disposition',
|
|
368
|
+
]) {
|
|
369
|
+
optionalObject(value, key, field);
|
|
370
|
+
}
|
|
371
|
+
if (Object.hasOwn(value, 'docs_impact')) {
|
|
372
|
+
validateDocsImpact(value.docs_impact, `${field}.docs_impact`);
|
|
373
|
+
}
|
|
374
|
+
if (Object.hasOwn(value, 'contract')) {
|
|
375
|
+
const contractField = `${field}.contract`;
|
|
376
|
+
assertExactFields(
|
|
377
|
+
value.contract,
|
|
378
|
+
['outcome', 'acceptance', 'non_goals', 'public_seams', 'recovery', 'unresolved_decisions'],
|
|
379
|
+
contractField,
|
|
380
|
+
);
|
|
381
|
+
optionalText(value.contract, 'outcome', contractField);
|
|
382
|
+
for (const key of ['acceptance', 'non_goals', 'public_seams', 'unresolved_decisions']) {
|
|
383
|
+
optionalArray(value.contract, key, contractField);
|
|
384
|
+
}
|
|
385
|
+
for (const key of ['non_goals', 'public_seams']) {
|
|
386
|
+
if (Object.hasOwn(value.contract, key)) {
|
|
387
|
+
validateStringArray(value.contract[key], `${contractField}.${key}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (Object.hasOwn(value.contract, 'acceptance')) {
|
|
391
|
+
validateObjectArray(
|
|
392
|
+
value.contract.acceptance,
|
|
393
|
+
`${contractField}.acceptance`,
|
|
394
|
+
['id', 'criterion', 'verification'],
|
|
395
|
+
);
|
|
396
|
+
value.contract.acceptance.forEach((item, index) => {
|
|
397
|
+
for (const key of ['id', 'criterion', 'verification']) {
|
|
398
|
+
optionalText(item, key, `${contractField}.acceptance[${index}]`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (Object.hasOwn(value.contract, 'recovery')) {
|
|
403
|
+
assertExactFields(
|
|
404
|
+
value.contract.recovery,
|
|
405
|
+
['strategy', 'verification'],
|
|
406
|
+
`${contractField}.recovery`,
|
|
407
|
+
);
|
|
408
|
+
for (const key of ['strategy', 'verification']) {
|
|
409
|
+
optionalText(value.contract.recovery, key, `${contractField}.recovery`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (Object.hasOwn(value.contract, 'unresolved_decisions')) {
|
|
413
|
+
validateObjectArray(
|
|
414
|
+
value.contract.unresolved_decisions,
|
|
415
|
+
`${contractField}.unresolved_decisions`,
|
|
416
|
+
['id', 'summary', 'blocking', 'owner'],
|
|
417
|
+
);
|
|
418
|
+
value.contract.unresolved_decisions.forEach((item, index) => {
|
|
419
|
+
for (const key of ['id', 'summary']) {
|
|
420
|
+
optionalText(item, key, `${contractField}.unresolved_decisions[${index}]`);
|
|
421
|
+
}
|
|
422
|
+
optionalBoolean(
|
|
423
|
+
item,
|
|
424
|
+
'blocking',
|
|
425
|
+
`${contractField}.unresolved_decisions[${index}]`,
|
|
426
|
+
);
|
|
427
|
+
optionalText(
|
|
428
|
+
item,
|
|
429
|
+
'owner',
|
|
430
|
+
`${contractField}.unresolved_decisions[${index}]`,
|
|
431
|
+
{ nullable: true },
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (Object.hasOwn(value, 'classification')) {
|
|
437
|
+
const classificationField = `${field}.classification`;
|
|
438
|
+
assertExactFields(value.classification, ['rationale', 'risk_flags'], classificationField);
|
|
439
|
+
optionalText(value.classification, 'rationale', classificationField);
|
|
440
|
+
if (Object.hasOwn(value.classification, 'risk_flags')) {
|
|
441
|
+
validateStringArray(value.classification.risk_flags, `${classificationField}.risk_flags`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (Object.hasOwn(value, 'research_disposition')) {
|
|
445
|
+
const researchField = `${field}.research_disposition`;
|
|
446
|
+
assertExactFields(
|
|
447
|
+
value.research_disposition,
|
|
448
|
+
['status', 'mode', 'selected_steps', 'rationale'],
|
|
449
|
+
researchField,
|
|
450
|
+
);
|
|
451
|
+
optionalText(value.research_disposition, 'status', researchField);
|
|
452
|
+
optionalText(value.research_disposition, 'mode', researchField, { nullable: true });
|
|
453
|
+
optionalText(value.research_disposition, 'rationale', researchField);
|
|
454
|
+
if (Object.hasOwn(value.research_disposition, 'selected_steps')) {
|
|
455
|
+
validateStringArray(
|
|
456
|
+
value.research_disposition.selected_steps,
|
|
457
|
+
`${researchField}.selected_steps`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (Object.hasOwn(value, 'baseline_bypass')) {
|
|
462
|
+
const bypassField = `${field}.baseline_bypass`;
|
|
463
|
+
assertExactFields(value.baseline_bypass, ['reason', 'approved_by'], bypassField);
|
|
464
|
+
optionalText(value.baseline_bypass, 'reason', bypassField);
|
|
465
|
+
optionalText(value.baseline_bypass, 'approved_by', bypassField);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function validateBaselineData(action, data, field) {
|
|
470
|
+
const textFields = action === 'initialize'
|
|
471
|
+
? ['target_dir', 'project_name', 'project_type', 'stack', 'mode', 'git_mode', 'source_template']
|
|
472
|
+
: action === 'start'
|
|
473
|
+
? ['id', 'project_name', 'project_type', 'stack', 'mode', 'repository_revision']
|
|
474
|
+
: action === 'accept'
|
|
475
|
+
? ['id', 'expected_revision', 'approved_by', 'approval_note']
|
|
476
|
+
: ['id', 'repository_revision'];
|
|
477
|
+
textFields.forEach((key) => optionalText(data, key, field));
|
|
478
|
+
if (action === 'initialize') {
|
|
479
|
+
optionalBoolean(data, 'resume', field);
|
|
480
|
+
optionalBoolean(data, 'overwrite', field);
|
|
481
|
+
}
|
|
482
|
+
if (action === 'start') {
|
|
483
|
+
optionalBoolean(data, 'replace_migrated', field);
|
|
484
|
+
optionalBoolean(data, 'replace_ready', field);
|
|
485
|
+
}
|
|
486
|
+
if (action === 'accept') {
|
|
487
|
+
optionalBoolean(data, 'accept_dirty_worktree', field);
|
|
488
|
+
optionalBoolean(data, 'accept_known_red', field);
|
|
489
|
+
}
|
|
490
|
+
for (const key of ['classification', 'provider_refs', 'replacement_authorization']) {
|
|
491
|
+
optionalObject(data, key, field);
|
|
492
|
+
}
|
|
493
|
+
if (Object.hasOwn(data, 'replacement_authorization')) {
|
|
494
|
+
const authorizationField = `${field}.replacement_authorization`;
|
|
495
|
+
assertExactFields(
|
|
496
|
+
data.replacement_authorization,
|
|
497
|
+
['approved_by', 'reason'],
|
|
498
|
+
authorizationField,
|
|
499
|
+
);
|
|
500
|
+
optionalText(data.replacement_authorization, 'approved_by', authorizationField);
|
|
501
|
+
optionalText(data.replacement_authorization, 'reason', authorizationField);
|
|
502
|
+
}
|
|
503
|
+
for (const key of [
|
|
504
|
+
'scope', 'spec_refs', 'evidence', 'verification', 'unknowns', 'gaps',
|
|
505
|
+
]) {
|
|
506
|
+
optionalArray(data, key, field);
|
|
507
|
+
}
|
|
508
|
+
if (Object.hasOwn(data, 'scope')) validateStringArray(data.scope, `${field}.scope`);
|
|
509
|
+
if (Object.hasOwn(data, 'spec_refs')) {
|
|
510
|
+
validateObjectArray(data.spec_refs, `${field}.spec_refs`, ['kind', 'path']);
|
|
511
|
+
data.spec_refs.forEach((item, index) => {
|
|
512
|
+
optionalText(item, 'kind', `${field}.spec_refs[${index}]`);
|
|
513
|
+
optionalText(item, 'path', `${field}.spec_refs[${index}]`);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (Object.hasOwn(data, 'evidence')) {
|
|
517
|
+
validateObjectArray(data.evidence, `${field}.evidence`, ['kind', 'ref', 'summary']);
|
|
518
|
+
data.evidence.forEach((item, index) => {
|
|
519
|
+
for (const key of ['kind', 'ref', 'summary']) {
|
|
520
|
+
optionalText(item, key, `${field}.evidence[${index}]`);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (Object.hasOwn(data, 'verification')) {
|
|
525
|
+
validateObjectArray(
|
|
526
|
+
data.verification,
|
|
527
|
+
`${field}.verification`,
|
|
528
|
+
['name', 'command', 'status', 'evidence', 'rationale'],
|
|
529
|
+
);
|
|
530
|
+
data.verification.forEach((item, index) => {
|
|
531
|
+
for (const key of ['name', 'command', 'status', 'evidence', 'rationale']) {
|
|
532
|
+
optionalText(item, key, `${field}.verification[${index}]`);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (Object.hasOwn(data, 'unknowns')) {
|
|
537
|
+
validateObjectArray(
|
|
538
|
+
data.unknowns,
|
|
539
|
+
`${field}.unknowns`,
|
|
540
|
+
['summary', 'blocking', 'owner'],
|
|
541
|
+
);
|
|
542
|
+
data.unknowns.forEach((item, index) => {
|
|
543
|
+
optionalText(item, 'summary', `${field}.unknowns[${index}]`);
|
|
544
|
+
optionalBoolean(item, 'blocking', `${field}.unknowns[${index}]`);
|
|
545
|
+
optionalText(item, 'owner', `${field}.unknowns[${index}]`);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (Object.hasOwn(data, 'gaps')) {
|
|
549
|
+
validateObjectArray(
|
|
550
|
+
data.gaps,
|
|
551
|
+
`${field}.gaps`,
|
|
552
|
+
['id', 'category', 'status', 'blocking', 'summary', 'evidence_refs', 'owner', 'resolution'],
|
|
553
|
+
);
|
|
554
|
+
data.gaps.forEach((item, index) => {
|
|
555
|
+
for (const key of ['id', 'category', 'status', 'summary']) {
|
|
556
|
+
optionalText(item, key, `${field}.gaps[${index}]`);
|
|
557
|
+
}
|
|
558
|
+
optionalBoolean(item, 'blocking', `${field}.gaps[${index}]`);
|
|
559
|
+
optionalText(item, 'owner', `${field}.gaps[${index}]`, { nullable: true });
|
|
560
|
+
optionalText(item, 'resolution', `${field}.gaps[${index}]`, { nullable: true });
|
|
561
|
+
if (Object.hasOwn(item, 'evidence_refs')) {
|
|
562
|
+
validateStringArray(item.evidence_refs, `${field}.gaps[${index}].evidence_refs`);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function validateDecisionData(data, field) {
|
|
569
|
+
for (const key of ['id', 'question', 'recommendation', 'selection', 'owner', 'source']) {
|
|
570
|
+
optionalText(data, key, field);
|
|
571
|
+
}
|
|
572
|
+
optionalText(data, 'supersedes_id', field, { nullable: true });
|
|
573
|
+
optionalObject(data, 'scope', field);
|
|
574
|
+
optionalObject(data, 'effects', field);
|
|
575
|
+
optionalObject(data, 'provenance', field);
|
|
576
|
+
optionalArray(data, 'non_goals', field);
|
|
577
|
+
optionalArray(data, 'applied_refs', field);
|
|
578
|
+
if (Object.hasOwn(data, 'non_goals')) {
|
|
579
|
+
validateStringArray(data.non_goals, `${field}.non_goals`);
|
|
580
|
+
}
|
|
581
|
+
if (Object.hasOwn(data, 'scope')) {
|
|
582
|
+
assertExactFields(data.scope, ['baseline_id', 'change_id'], `${field}.scope`);
|
|
583
|
+
optionalText(data.scope, 'baseline_id', `${field}.scope`);
|
|
584
|
+
optionalText(data.scope, 'change_id', `${field}.scope`);
|
|
585
|
+
}
|
|
586
|
+
if (Object.hasOwn(data, 'applied_refs')) {
|
|
587
|
+
validateObjectArray(
|
|
588
|
+
data.applied_refs,
|
|
589
|
+
`${field}.applied_refs`,
|
|
590
|
+
['ref', 'field', 'digest'],
|
|
591
|
+
);
|
|
592
|
+
data.applied_refs.forEach((item, index) => {
|
|
593
|
+
for (const key of ['ref', 'field', 'digest']) {
|
|
594
|
+
optionalText(item, key, `${field}.applied_refs[${index}]`);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function validateArtifactData(data, field) {
|
|
601
|
+
for (const key of ['id', 'owner_type', 'owner_id', 'kind', 'path', 'status', 'content_digest']) {
|
|
602
|
+
optionalText(data, key, field);
|
|
603
|
+
}
|
|
604
|
+
for (const key of ['change_id', 'task_id', 'expected_before_digest']) {
|
|
605
|
+
optionalText(data, key, field, { nullable: true });
|
|
606
|
+
}
|
|
607
|
+
for (const key of ['source_refs', 'consumer_refs']) {
|
|
608
|
+
optionalArray(data, key, field);
|
|
609
|
+
if (!Object.hasOwn(data, key)) continue;
|
|
610
|
+
validateObjectArray(data[key], `${field}.${key}`, ['type', 'id', 'relation']);
|
|
611
|
+
data[key].forEach((item, index) => {
|
|
612
|
+
for (const referenceField of ['type', 'id', 'relation']) {
|
|
613
|
+
optionalText(item, referenceField, `${field}.${key}[${index}]`);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
optionalObject(data, 'provenance', field);
|
|
618
|
+
optionalObject(data, 'metadata', field);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function validateRecordEntry(entry) {
|
|
622
|
+
const operation = `${entry.kind}:${entry.action}`;
|
|
623
|
+
const allowed = RECORD_DATA_FIELDS[operation];
|
|
624
|
+
if (!allowed) {
|
|
625
|
+
throw validationError(`unsupported ${entry.kind} action: ${entry.action}`);
|
|
626
|
+
}
|
|
627
|
+
const field = `${operation}.data`;
|
|
628
|
+
assertExactFields(entry.data, allowed, field);
|
|
629
|
+
if (entry.kind === 'baseline') validateBaselineData(entry.action, entry.data, field);
|
|
630
|
+
else if (entry.kind === 'change_contract') {
|
|
631
|
+
optionalText(entry.data, 'id', field);
|
|
632
|
+
if (entry.action === 'open') {
|
|
633
|
+
for (const key of ['title', 'kind', 'intent']) optionalText(entry.data, key, field);
|
|
634
|
+
validateChangeAuthority(entry.data, field);
|
|
635
|
+
} else if (entry.action === 'revise') {
|
|
636
|
+
assertExactFields(
|
|
637
|
+
entry.data.patch,
|
|
638
|
+
[
|
|
639
|
+
'title', 'intent', 'status', 'docs_impact', 'provider_refs',
|
|
640
|
+
'contract', 'classification', 'research_disposition',
|
|
641
|
+
],
|
|
642
|
+
`${field}.patch`,
|
|
643
|
+
);
|
|
644
|
+
validateChangeAuthority(entry.data.patch, `${field}.patch`);
|
|
645
|
+
} else if (entry.action === 'supersede') {
|
|
646
|
+
requireNonEmptyText(entry.data.id, `${field}.id`);
|
|
647
|
+
requireNonEmptyText(entry.data.successor_id, `${field}.successor_id`);
|
|
648
|
+
for (const key of ['title', 'kind', 'intent']) {
|
|
649
|
+
optionalText(entry.data, key, field);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} else if (entry.kind === 'decision') validateDecisionData(entry.data, field);
|
|
653
|
+
else if (entry.kind === 'artifact') validateArtifactData(entry.data, field);
|
|
654
|
+
else if (entry.kind === 'event') {
|
|
655
|
+
optionalText(entry.data, 'type', field);
|
|
656
|
+
for (const key of ['task_id', 'change_id', 'session_id', 'runtime']) {
|
|
657
|
+
optionalText(entry.data, key, field, { nullable: true });
|
|
658
|
+
}
|
|
659
|
+
optionalObject(entry.data, 'payload', field);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function snapshotRecordFile(file) {
|
|
664
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
665
|
+
if (!stat) return { existed: false, bytes: null, mode: null };
|
|
666
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
667
|
+
throw Object.assign(
|
|
668
|
+
new Error(`record file target is not a regular file: ${file}`),
|
|
669
|
+
{ code: 'PATH_AUTHORITY_VIOLATION' },
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
return { existed: true, bytes: fs.readFileSync(file), mode: stat.mode };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function restoreRecordFile(file, snapshot) {
|
|
676
|
+
const current = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
677
|
+
if (!snapshot.existed) {
|
|
678
|
+
if (!current) return;
|
|
679
|
+
if (!current.isFile() && !current.isSymbolicLink()) {
|
|
680
|
+
throw new Error(`record rollback target changed type: ${file}`);
|
|
681
|
+
}
|
|
682
|
+
fs.rmSync(file, { force: true });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (current?.isFile() && !current.isSymbolicLink()) {
|
|
686
|
+
if (fs.readFileSync(file).equals(snapshot.bytes)) return;
|
|
687
|
+
} else if (current) {
|
|
688
|
+
throw new Error(`record rollback target changed type: ${file}`);
|
|
689
|
+
}
|
|
690
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
691
|
+
fs.writeFileSync(file, snapshot.bytes, { mode: snapshot.mode });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function prepareRecordFileRollback(entry, rootDir) {
|
|
695
|
+
const id = entry.data?.id;
|
|
696
|
+
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(id)) return null;
|
|
697
|
+
if (entry.kind === 'change_contract' && entry.action === 'open') {
|
|
698
|
+
const directory = path.resolve(rootDir, '.ultra', 'changes', 'active', id);
|
|
699
|
+
const existed = fs.existsSync(directory);
|
|
700
|
+
return () => {
|
|
701
|
+
if (!existed && fs.existsSync(directory)) {
|
|
702
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
if (entry.kind === 'change_contract' && entry.action === 'revise') {
|
|
707
|
+
const file = path.resolve(
|
|
708
|
+
rootDir,
|
|
709
|
+
'.ultra',
|
|
710
|
+
'changes',
|
|
711
|
+
'active',
|
|
712
|
+
id,
|
|
713
|
+
'intent.md',
|
|
714
|
+
);
|
|
715
|
+
const snapshot = snapshotRecordFile(file);
|
|
716
|
+
return () => restoreRecordFile(file, snapshot);
|
|
717
|
+
}
|
|
718
|
+
if (entry.kind === 'decision' && entry.action === 'accept') {
|
|
719
|
+
const scope = entry.data.scope;
|
|
720
|
+
let relative = null;
|
|
721
|
+
if (typeof scope?.baseline_id === 'string') {
|
|
722
|
+
relative = path.join('.ultra', 'decisions', 'baseline', `${id}.json`);
|
|
723
|
+
} else if (typeof scope?.change_id === 'string'
|
|
724
|
+
&& /^[a-zA-Z0-9_-]+$/.test(scope.change_id)) {
|
|
725
|
+
relative = path.join(
|
|
726
|
+
'.ultra',
|
|
727
|
+
'changes',
|
|
728
|
+
'active',
|
|
729
|
+
scope.change_id,
|
|
730
|
+
'decisions',
|
|
731
|
+
`${id}.json`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (!relative) return null;
|
|
735
|
+
const file = path.resolve(rootDir, relative);
|
|
736
|
+
const snapshot = snapshotRecordFile(file);
|
|
737
|
+
return () => restoreRecordFile(file, snapshot);
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function rollbackRecordFiles(rollback, error) {
|
|
743
|
+
if (!rollback) return;
|
|
744
|
+
try {
|
|
745
|
+
rollback();
|
|
746
|
+
} catch (cause) {
|
|
747
|
+
throw Object.assign(
|
|
748
|
+
new Error(
|
|
749
|
+
`record failed and managed file rollback requires recovery: `
|
|
750
|
+
+ `${error.message}; ${cause.message}`,
|
|
751
|
+
),
|
|
752
|
+
{
|
|
753
|
+
code: 'RECORD_FILE_ROLLBACK_REQUIRED',
|
|
754
|
+
cause: error,
|
|
755
|
+
details: {
|
|
756
|
+
original_error: errorCode(error),
|
|
757
|
+
rollback_error: cause.message,
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function rejectedAttemptAudit(db) {
|
|
765
|
+
return db.prepare(
|
|
766
|
+
`SELECT id, ts, task_id, change_id, payload_json
|
|
767
|
+
FROM events
|
|
768
|
+
WHERE type = 'ultra_kernel_attempt'
|
|
769
|
+
ORDER BY id DESC
|
|
770
|
+
LIMIT 5`,
|
|
771
|
+
).all().map((row) => {
|
|
772
|
+
const payload = parseJson(row.payload_json, {});
|
|
773
|
+
return {
|
|
774
|
+
event_id: row.id,
|
|
775
|
+
recorded_at: row.ts,
|
|
776
|
+
tool: payload.tool || null,
|
|
777
|
+
task_id: row.task_id || null,
|
|
778
|
+
change_id: row.change_id || null,
|
|
779
|
+
operations: (payload.operations || []).slice(0, 12),
|
|
780
|
+
blockers: (payload.blockers || []).slice(0, 12),
|
|
781
|
+
diagnostics: (payload.diagnostics || []).slice(0, 8).map((item) => ({
|
|
782
|
+
code: item.code || 'NEEDS_ATTENTION',
|
|
783
|
+
severity: item.severity || 'needs_attention',
|
|
784
|
+
message: typeof item.message === 'string'
|
|
785
|
+
? item.message.slice(0, 500)
|
|
786
|
+
: null,
|
|
787
|
+
})),
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function requireNonEmptyText(value, field, { minLength = 1 } = {}) {
|
|
793
|
+
requireText(value, field);
|
|
794
|
+
if (value.trim().length < minLength) {
|
|
795
|
+
throw validationError(`${field} must contain at least ${minLength} characters`, { field });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function validateContextInput(input) {
|
|
800
|
+
assertExactFields(input, ['stage', 'scope', 'detail'], 'context');
|
|
801
|
+
if (Object.hasOwn(input, 'stage')) {
|
|
802
|
+
requireText(input.stage, 'context.stage');
|
|
803
|
+
if (!['project', 'research', 'plan', 'dev', 'test', 'review', 'deliver'].includes(input.stage)) {
|
|
804
|
+
throw validationError(`unsupported context stage: ${input.stage}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (Object.hasOwn(input, 'detail')) {
|
|
808
|
+
requireText(input.detail, 'context.detail');
|
|
809
|
+
if (!['summary', 'full'].includes(input.detail)) {
|
|
810
|
+
throw validationError(`unsupported context detail: ${input.detail}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (Object.hasOwn(input, 'scope')) {
|
|
814
|
+
assertExactFields(input.scope, ['change_id', 'task_id'], 'context.scope');
|
|
815
|
+
for (const key of ['change_id', 'task_id']) {
|
|
816
|
+
if (Object.hasOwn(input.scope, key)) {
|
|
817
|
+
requireNonEmptyText(input.scope[key], `context.scope.${key}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function validateCheckpointInput(input) {
|
|
824
|
+
assertExactFields(
|
|
825
|
+
input,
|
|
826
|
+
['stage', 'scope', 'payload', 'idempotency_key'],
|
|
827
|
+
'checkpoint',
|
|
828
|
+
);
|
|
829
|
+
requireText(input.stage, 'checkpoint.stage');
|
|
830
|
+
if (!['research', 'plan', 'dev', 'test', 'review', 'deliver'].includes(input.stage)) {
|
|
831
|
+
throw validationError(`unsupported checkpoint stage: ${input.stage}`);
|
|
832
|
+
}
|
|
833
|
+
assertExactFields(input.scope, ['change_id', 'task_id'], 'checkpoint.scope');
|
|
834
|
+
for (const key of ['change_id', 'task_id']) {
|
|
835
|
+
if (Object.hasOwn(input.scope, key)) {
|
|
836
|
+
requireNonEmptyText(input.scope[key], `checkpoint.scope.${key}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
assertExactFields(
|
|
840
|
+
input.payload,
|
|
841
|
+
Object.keys(input.payload || {}),
|
|
842
|
+
'checkpoint.payload',
|
|
843
|
+
);
|
|
844
|
+
requireNonEmptyText(input.idempotency_key, 'checkpoint.idempotency_key', {
|
|
845
|
+
minLength: 3,
|
|
846
|
+
});
|
|
847
|
+
if (Object.hasOwn(input.payload, 'evidence')) {
|
|
848
|
+
validateObjectArray(
|
|
849
|
+
input.payload.evidence,
|
|
850
|
+
'checkpoint.payload.evidence',
|
|
851
|
+
['kind', 'ref', 'summary', 'digest', 'artifact_id'],
|
|
852
|
+
);
|
|
853
|
+
input.payload.evidence.forEach((item, index) => {
|
|
854
|
+
for (const key of ['kind', 'ref', 'summary', 'digest', 'artifact_id']) {
|
|
855
|
+
optionalText(item, key, `checkpoint.payload.evidence[${index}]`);
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
if (Object.hasOwn(input.payload, 'diagnostics')) {
|
|
860
|
+
validateObjectArray(
|
|
861
|
+
input.payload.diagnostics,
|
|
862
|
+
'checkpoint.payload.diagnostics',
|
|
863
|
+
['code', 'severity', 'message', 'details'],
|
|
864
|
+
);
|
|
865
|
+
input.payload.diagnostics.forEach((item, index) => {
|
|
866
|
+
requireNonEmptyText(item.code, `checkpoint.payload.diagnostics[${index}].code`);
|
|
867
|
+
optionalText(item, 'severity', `checkpoint.payload.diagnostics[${index}]`);
|
|
868
|
+
optionalText(item, 'message', `checkpoint.payload.diagnostics[${index}]`);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function validateSyncInput(input) {
|
|
874
|
+
requireText(input.action, 'sync.action');
|
|
875
|
+
const allowed = {
|
|
876
|
+
inspect: ['action'],
|
|
877
|
+
import: ['action'],
|
|
878
|
+
migrate: ['action'],
|
|
879
|
+
publish: ['action', 'reason', 'idempotency_key'],
|
|
880
|
+
}[input.action];
|
|
881
|
+
if (!allowed) throw validationError(`unsupported sync action: ${input.action}`);
|
|
882
|
+
assertExactFields(input, allowed, `sync:${input.action}`);
|
|
883
|
+
if (input.action === 'publish') {
|
|
884
|
+
optionalText(input, 'reason', 'sync:publish');
|
|
885
|
+
requireNonEmptyText(input.idempotency_key, 'sync:publish.idempotency_key', {
|
|
886
|
+
minLength: 3,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const SESSION_PAYLOAD_FIELDS = Object.freeze({
|
|
892
|
+
admission: [],
|
|
893
|
+
acquire: [
|
|
894
|
+
'role', 'runtime', 'output_path', 'output_schema', 'evidence_refs',
|
|
895
|
+
'diff_range', 'changed_files', 'takeover', 'worktree_base',
|
|
896
|
+
],
|
|
897
|
+
get: [],
|
|
898
|
+
list: [],
|
|
899
|
+
heartbeat: [],
|
|
900
|
+
release: ['status', 'remove_worktree'],
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
function validateSessionInput(input) {
|
|
904
|
+
requireText(input.action, 'session.action');
|
|
905
|
+
const payloadFields = SESSION_PAYLOAD_FIELDS[input.action];
|
|
906
|
+
if (!payloadFields) throw validationError(`unsupported session action: ${input.action}`);
|
|
907
|
+
const mutating = ['acquire', 'heartbeat', 'release'].includes(input.action);
|
|
908
|
+
assertExactFields(
|
|
909
|
+
input,
|
|
910
|
+
mutating
|
|
911
|
+
? ['action', 'scope', 'payload', 'idempotency_key']
|
|
912
|
+
: ['action', 'scope', 'payload'],
|
|
913
|
+
`session:${input.action}`,
|
|
914
|
+
);
|
|
915
|
+
const scope = input.scope || {};
|
|
916
|
+
const payload = input.payload || {};
|
|
917
|
+
const scopeFields = ['get', 'heartbeat', 'release'].includes(input.action)
|
|
918
|
+
? ['sid']
|
|
919
|
+
: ['task_id'];
|
|
920
|
+
assertExactFields(scope, scopeFields, `session:${input.action}.scope`);
|
|
921
|
+
assertExactFields(payload, payloadFields, `session:${input.action}.payload`);
|
|
922
|
+
if (input.action !== 'list' || Object.hasOwn(scope, 'task_id')) {
|
|
923
|
+
const key = scopeFields[0];
|
|
924
|
+
requireNonEmptyText(scope[key], `session:${input.action}.scope.${key}`);
|
|
925
|
+
}
|
|
926
|
+
if (mutating) {
|
|
927
|
+
requireNonEmptyText(
|
|
928
|
+
input.idempotency_key,
|
|
929
|
+
`session:${input.action}.idempotency_key`,
|
|
930
|
+
{ minLength: 3 },
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (input.action === 'release') {
|
|
934
|
+
if (Object.hasOwn(payload, 'status')) {
|
|
935
|
+
requireText(payload.status, 'session:release.payload.status');
|
|
936
|
+
if (!['completed', 'crashed'].includes(payload.status)) {
|
|
937
|
+
throw validationError(`unsupported session release status: ${payload.status}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
optionalBoolean(payload, 'remove_worktree', 'session:release.payload');
|
|
941
|
+
}
|
|
942
|
+
if (input.action === 'acquire') {
|
|
943
|
+
for (const key of ['role', 'runtime', 'output_path', 'diff_range', 'worktree_base']) {
|
|
944
|
+
optionalText(payload, key, 'session:acquire.payload');
|
|
945
|
+
}
|
|
946
|
+
optionalObject(payload, 'output_schema', 'session:acquire.payload');
|
|
947
|
+
optionalArray(payload, 'evidence_refs', 'session:acquire.payload');
|
|
948
|
+
optionalArray(payload, 'changed_files', 'session:acquire.payload');
|
|
949
|
+
optionalBoolean(payload, 'takeover', 'session:acquire.payload');
|
|
950
|
+
if (Object.hasOwn(payload, 'changed_files')) {
|
|
951
|
+
validateStringArray(payload.changed_files, 'session:acquire.payload.changed_files');
|
|
952
|
+
}
|
|
953
|
+
if (Object.hasOwn(payload, 'evidence_refs')) {
|
|
954
|
+
payload.evidence_refs.forEach((item, index) => {
|
|
955
|
+
if (typeof item === 'string') return;
|
|
956
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
957
|
+
throw validationError(
|
|
958
|
+
`session:acquire.payload.evidence_refs[${index}] must be a string or object`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function validateArchiveInput(input) {
|
|
967
|
+
assertExactFields(input, ['change_id', 'payload', 'idempotency_key'], 'archive');
|
|
968
|
+
requireNonEmptyText(input.change_id, 'archive.change_id');
|
|
969
|
+
requireNonEmptyText(input.idempotency_key, 'archive.idempotency_key', { minLength: 3 });
|
|
970
|
+
assertExactFields(
|
|
971
|
+
input.payload,
|
|
972
|
+
[
|
|
973
|
+
'summary', 'baseline_updates', 'no_baseline_change_reason',
|
|
974
|
+
'reconciliation_path',
|
|
975
|
+
],
|
|
976
|
+
'archive.payload',
|
|
977
|
+
);
|
|
978
|
+
for (const key of ['summary', 'no_baseline_change_reason', 'reconciliation_path']) {
|
|
979
|
+
optionalText(input.payload, key, 'archive.payload');
|
|
980
|
+
}
|
|
981
|
+
if (Object.hasOwn(input.payload, 'baseline_updates')) {
|
|
982
|
+
validateStringArray(input.payload.baseline_updates, 'archive.payload.baseline_updates');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function validateRecordInput(input) {
|
|
987
|
+
assertExactFields(input, ['entries'], 'record');
|
|
988
|
+
if (!Array.isArray(input.entries) || input.entries.length === 0) {
|
|
989
|
+
throw validationError('record.entries must be a non-empty array', {
|
|
990
|
+
field: 'record.entries',
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
input.entries.forEach((entry, index) => {
|
|
994
|
+
const field = `record.entries[${index}]`;
|
|
995
|
+
assertExactFields(entry, ['kind', 'action', 'data', 'idempotency_key'], field);
|
|
996
|
+
requireNonEmptyText(entry.kind, `${field}.kind`);
|
|
997
|
+
requireNonEmptyText(entry.action, `${field}.action`);
|
|
998
|
+
assertExactFields(
|
|
999
|
+
entry.data,
|
|
1000
|
+
Object.keys(entry.data || {}),
|
|
1001
|
+
`${field}.data`,
|
|
1002
|
+
);
|
|
1003
|
+
requireNonEmptyText(entry.idempotency_key, `${field}.idempotency_key`, {
|
|
1004
|
+
minLength: 3,
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function validateDoctorInput(input) {
|
|
1010
|
+
assertExactFields(input, ['repair'], 'doctor');
|
|
1011
|
+
optionalBoolean(input, 'repair', 'doctor');
|
|
1012
|
+
}
|
|
1013
|
+
|
|
147
1014
|
function selectedScope(db, input = {}) {
|
|
148
1015
|
const scope = input.scope || {};
|
|
149
1016
|
const task = scope.task_id ? ops.readTask(db, scope.task_id) : null;
|
|
@@ -197,7 +1064,29 @@ function readContext(db, input = {}, {
|
|
|
197
1064
|
rootDir = process.cwd(),
|
|
198
1065
|
runtime = 'unknown',
|
|
199
1066
|
} = {}) {
|
|
200
|
-
|
|
1067
|
+
validateContextInput(input);
|
|
1068
|
+
const result = contextEnvelope.buildEnvelope(db, input, { rootDir, runtime });
|
|
1069
|
+
const rejectedAttempts = rejectedAttemptAudit(db);
|
|
1070
|
+
let visible = {
|
|
1071
|
+
...result,
|
|
1072
|
+
audit: {
|
|
1073
|
+
rejected_attempts: rejectedAttempts,
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
delete visible.bytes;
|
|
1077
|
+
const limit = input.detail === 'full'
|
|
1078
|
+
? contextEnvelope.FULL_LIMIT
|
|
1079
|
+
: contextEnvelope.SUMMARY_LIMIT;
|
|
1080
|
+
while (Buffer.byteLength(JSON.stringify(visible)) > limit
|
|
1081
|
+
&& rejectedAttempts.length > 0) {
|
|
1082
|
+
rejectedAttempts.pop();
|
|
1083
|
+
}
|
|
1084
|
+
if (Buffer.byteLength(JSON.stringify(visible)) > limit) {
|
|
1085
|
+
visible = { ...result };
|
|
1086
|
+
delete visible.bytes;
|
|
1087
|
+
}
|
|
1088
|
+
visible.bytes = Buffer.byteLength(JSON.stringify(visible));
|
|
1089
|
+
return visible;
|
|
201
1090
|
}
|
|
202
1091
|
|
|
203
1092
|
function initializeProject(data = {}) {
|
|
@@ -241,15 +1130,15 @@ function baselineRecord(db, action, data, rootDir) {
|
|
|
241
1130
|
|
|
242
1131
|
function changeContractRecord(db, action, data, rootDir) {
|
|
243
1132
|
if (action === 'open') {
|
|
244
|
-
return changes.createChange(db, data, { rootDir
|
|
1133
|
+
return changes.createChange(db, data, { rootDir });
|
|
245
1134
|
}
|
|
246
1135
|
if (action === 'revise') {
|
|
247
1136
|
return {
|
|
248
1137
|
change: changes.updateChange(
|
|
249
1138
|
db,
|
|
250
1139
|
data.id,
|
|
251
|
-
data.patch
|
|
252
|
-
{ rootDir
|
|
1140
|
+
data.patch,
|
|
1141
|
+
{ rootDir },
|
|
253
1142
|
),
|
|
254
1143
|
};
|
|
255
1144
|
}
|
|
@@ -259,25 +1148,128 @@ function changeContractRecord(db, action, data, rootDir) {
|
|
|
259
1148
|
db,
|
|
260
1149
|
data.id,
|
|
261
1150
|
{ status: 'cancelled' },
|
|
262
|
-
{ rootDir
|
|
1151
|
+
{ rootDir },
|
|
263
1152
|
),
|
|
264
1153
|
};
|
|
265
1154
|
}
|
|
1155
|
+
if (action === 'supersede') {
|
|
1156
|
+
return changes.supersedeChange(db, data, { rootDir });
|
|
1157
|
+
}
|
|
266
1158
|
throw Object.assign(new Error(`unsupported change_contract action: ${action}`), {
|
|
267
1159
|
code: 'VALIDATION_ERROR',
|
|
268
1160
|
});
|
|
269
1161
|
}
|
|
270
1162
|
|
|
271
|
-
function taskContractRecord(db, action, data) {
|
|
272
|
-
if (action === 'define')
|
|
273
|
-
|
|
274
|
-
|
|
1163
|
+
function taskContractRecord(db, action, data, rootDir) {
|
|
1164
|
+
if (action === 'define') {
|
|
1165
|
+
return { task: ops.createTask(db, taskContract.normalizeTaskDefinition(data)) };
|
|
1166
|
+
}
|
|
1167
|
+
if (action === 'revise') {
|
|
1168
|
+
assertExactFields(data, ['id', 'patch'], 'task_contract.revise.data');
|
|
1169
|
+
const id = taskContract.normalizeTaskId(data.id);
|
|
1170
|
+
return {
|
|
1171
|
+
task: ops.patchTask(db, id, taskContract.normalizeTaskPatch(data.patch)),
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
if (action === 'remove') {
|
|
1175
|
+
assertExactFields(data, ['id'], 'task_contract.remove.data');
|
|
1176
|
+
return ops.deleteTask(
|
|
1177
|
+
db,
|
|
1178
|
+
taskContract.normalizeTaskId(data.id),
|
|
1179
|
+
{ rootDir },
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
275
1182
|
throw Object.assign(new Error(`unsupported task_contract action: ${action}`), {
|
|
276
1183
|
code: 'VALIDATION_ERROR',
|
|
277
1184
|
});
|
|
278
1185
|
}
|
|
279
1186
|
|
|
1187
|
+
function resolveIntegratedCommit(rootDir, completionCommit) {
|
|
1188
|
+
if (typeof completionCommit !== 'string'
|
|
1189
|
+
|| !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(completionCommit)) {
|
|
1190
|
+
throw Object.assign(
|
|
1191
|
+
new Error('completion_commit must be a lowercase Git commit SHA'),
|
|
1192
|
+
{ code: 'COMPLETION_COMMIT_INVALID' },
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const resolved = spawnSync(
|
|
1196
|
+
'git',
|
|
1197
|
+
['rev-parse', '--verify', `${completionCommit}^{commit}`],
|
|
1198
|
+
{
|
|
1199
|
+
cwd: rootDir,
|
|
1200
|
+
encoding: 'utf8',
|
|
1201
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1202
|
+
},
|
|
1203
|
+
);
|
|
1204
|
+
if (resolved.status !== 0) {
|
|
1205
|
+
throw Object.assign(
|
|
1206
|
+
new Error(`completion commit ${completionCommit} was not found in this repository`),
|
|
1207
|
+
{ code: 'COMPLETION_COMMIT_NOT_FOUND' },
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
const commit = String(resolved.stdout || '').trim();
|
|
1211
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
|
1212
|
+
throw Object.assign(
|
|
1213
|
+
new Error(`completion commit ${completionCommit} could not be resolved`),
|
|
1214
|
+
{ code: 'COMPLETION_COMMIT_NOT_FOUND' },
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
const integrated = spawnSync(
|
|
1218
|
+
'git',
|
|
1219
|
+
['merge-base', '--is-ancestor', commit, 'HEAD'],
|
|
1220
|
+
{
|
|
1221
|
+
cwd: rootDir,
|
|
1222
|
+
encoding: 'utf8',
|
|
1223
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1224
|
+
},
|
|
1225
|
+
);
|
|
1226
|
+
if (integrated.status !== 0) {
|
|
1227
|
+
throw Object.assign(
|
|
1228
|
+
new Error(`completion commit ${commit} is not an ancestor of the current HEAD`),
|
|
1229
|
+
{ code: 'COMPLETION_COMMIT_NOT_INTEGRATED' },
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
return commit;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function attestTaskCompletionCommit(db, data, rootDir) {
|
|
1236
|
+
assertExactFields(data, ['id', 'completion_commit'], 'task_outcome.attest_commit.data');
|
|
1237
|
+
const id = taskContract.normalizeTaskId(data.id);
|
|
1238
|
+
const task = ops.readTask(db, id);
|
|
1239
|
+
if (!task) {
|
|
1240
|
+
throw Object.assign(new Error(`task ${id} does not exist`), { code: 'TASK_NOT_FOUND' });
|
|
1241
|
+
}
|
|
1242
|
+
if (task.status !== 'completed') {
|
|
1243
|
+
throw Object.assign(
|
|
1244
|
+
new Error(`task ${id} must be completed before its commit can be attested`),
|
|
1245
|
+
{
|
|
1246
|
+
code: 'TASK_NOT_COMPLETED',
|
|
1247
|
+
details: { task_id: id, status: task.status },
|
|
1248
|
+
},
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
const commit = resolveIntegratedCommit(rootDir, data.completion_commit);
|
|
1252
|
+
if (task.completion_commit) {
|
|
1253
|
+
if (task.completion_commit === commit) return { task };
|
|
1254
|
+
throw Object.assign(
|
|
1255
|
+
new Error(`task ${id} already attests completion commit ${task.completion_commit}`),
|
|
1256
|
+
{
|
|
1257
|
+
code: 'COMPLETION_COMMIT_CONFLICT',
|
|
1258
|
+
details: {
|
|
1259
|
+
task_id: id,
|
|
1260
|
+
existing_commit: task.completion_commit,
|
|
1261
|
+
requested_commit: commit,
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
return { task: ops.patchTask(db, id, { completion_commit: commit }) };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
280
1269
|
function taskOutcomeRecord(db, action, data, rootDir) {
|
|
1270
|
+
if (action === 'attest_commit') {
|
|
1271
|
+
return attestTaskCompletionCommit(db, data, rootDir);
|
|
1272
|
+
}
|
|
281
1273
|
const status = {
|
|
282
1274
|
start: 'in_progress',
|
|
283
1275
|
complete: 'completed',
|
|
@@ -290,6 +1282,11 @@ function taskOutcomeRecord(db, action, data, rootDir) {
|
|
|
290
1282
|
});
|
|
291
1283
|
}
|
|
292
1284
|
if (action === 'complete') {
|
|
1285
|
+
assertExactFields(data, ['id', 'packet_digest'], 'task_outcome.complete.data');
|
|
1286
|
+
taskContract.normalizeTaskId(data.id);
|
|
1287
|
+
if (typeof data.packet_digest !== 'string' || !data.packet_digest) {
|
|
1288
|
+
throw validationError('task_outcome.complete.data.packet_digest must be a string');
|
|
1289
|
+
}
|
|
293
1290
|
const packet = db.prepare(
|
|
294
1291
|
`SELECT id, packet_digest, packet_path, output_path
|
|
295
1292
|
FROM worker_packets
|
|
@@ -352,9 +1349,9 @@ function taskOutcomeRecord(db, action, data, rootDir) {
|
|
|
352
1349
|
packet_path: packet.packet_path,
|
|
353
1350
|
},
|
|
354
1351
|
}, { rootDir });
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
1352
|
+
} else {
|
|
1353
|
+
assertExactFields(data, ['id'], `task_outcome.${action}.data`);
|
|
1354
|
+
taskContract.normalizeTaskId(data.id);
|
|
358
1355
|
}
|
|
359
1356
|
return { task: ops.updateTaskStatus(db, data.id, status) };
|
|
360
1357
|
}
|
|
@@ -366,14 +1363,26 @@ function applyRecord(db, entry, { rootDir }) {
|
|
|
366
1363
|
code: 'VALIDATION_ERROR',
|
|
367
1364
|
});
|
|
368
1365
|
}
|
|
1366
|
+
validateRecordEntry({ kind, action, data });
|
|
369
1367
|
if (kind === 'baseline') return baselineRecord(db, action, data, rootDir);
|
|
370
1368
|
if (kind === 'change_contract') return changeContractRecord(db, action, data, rootDir);
|
|
371
1369
|
if (kind === 'decision' && action === 'accept') {
|
|
372
1370
|
return { decision: decisionRecords.acceptDecision(db, data, { rootDir }) };
|
|
373
1371
|
}
|
|
374
|
-
if (kind === 'task_contract') return taskContractRecord(db, action, data);
|
|
1372
|
+
if (kind === 'task_contract') return taskContractRecord(db, action, data, rootDir);
|
|
375
1373
|
if (kind === 'task_outcome') return taskOutcomeRecord(db, action, data, rootDir);
|
|
376
1374
|
if (kind === 'artifact' && action === 'bind') {
|
|
1375
|
+
const legacyWorkflowReference = data.owner_type === 'workflow'
|
|
1376
|
+
|| [...(data.source_refs || []), ...(data.consumer_refs || [])]
|
|
1377
|
+
.some((reference) => reference?.type === 'workflow');
|
|
1378
|
+
if (legacyWorkflowReference) {
|
|
1379
|
+
throw Object.assign(
|
|
1380
|
+
new Error(
|
|
1381
|
+
'workflow-owned artifact authority is retired and remains read-only history',
|
|
1382
|
+
),
|
|
1383
|
+
{ code: 'LEGACY_AUTHORITY_READ_ONLY' },
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
377
1386
|
return artifactRegistry.recordArtifact(db, data, { rootDir });
|
|
378
1387
|
}
|
|
379
1388
|
if (kind === 'event' && action === 'append') {
|
|
@@ -388,52 +1397,102 @@ function applyRecord(db, entry, { rootDir }) {
|
|
|
388
1397
|
async function record(db, input = {}, {
|
|
389
1398
|
rootDir = process.cwd(),
|
|
390
1399
|
} = {}) {
|
|
391
|
-
|
|
392
|
-
|
|
1400
|
+
validateRecordInput(input);
|
|
1401
|
+
const entries = input.entries;
|
|
1402
|
+
const runEntry = (entry) => {
|
|
1403
|
+
const data = entry.data === undefined ? {} : entry.data;
|
|
1404
|
+
const normalizedEntry = { ...entry, data };
|
|
1405
|
+
validateRecordEntry(normalizedEntry);
|
|
393
1406
|
const operation = `${entry.kind}:${entry.action}`;
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
1407
|
+
const acceptedRequestDigest = requestDigest(operation, {
|
|
1408
|
+
kind: entry.kind,
|
|
1409
|
+
action: entry.action,
|
|
1410
|
+
data,
|
|
1411
|
+
});
|
|
1412
|
+
const cached = priorResult(db, {
|
|
1413
|
+
idempotencyKey: entry.idempotency_key,
|
|
1414
|
+
operation,
|
|
1415
|
+
requestDigest: acceptedRequestDigest,
|
|
1416
|
+
});
|
|
1417
|
+
if (cached.found) {
|
|
1418
|
+
return {
|
|
397
1419
|
kind: entry.kind,
|
|
398
1420
|
action: entry.action,
|
|
399
1421
|
accepted: true,
|
|
400
1422
|
idempotent: true,
|
|
401
|
-
result: cached,
|
|
402
|
-
}
|
|
403
|
-
continue;
|
|
1423
|
+
result: cached.result,
|
|
1424
|
+
};
|
|
404
1425
|
}
|
|
1426
|
+
const rollbackFiles = prepareRecordFileRollback(normalizedEntry, rootDir);
|
|
405
1427
|
try {
|
|
406
|
-
const result = applyRecord(db,
|
|
407
|
-
|
|
1428
|
+
const result = applyRecord(db, normalizedEntry, { rootDir });
|
|
1429
|
+
rememberResultInTx(db, {
|
|
408
1430
|
idempotencyKey: entry.idempotency_key,
|
|
409
1431
|
operation,
|
|
1432
|
+
requestDigest: acceptedRequestDigest,
|
|
410
1433
|
result,
|
|
411
|
-
changeId:
|
|
412
|
-
taskId: entry.kind.startsWith('task_') ?
|
|
1434
|
+
changeId: data.change_id || data.id || null,
|
|
1435
|
+
taskId: entry.kind.startsWith('task_') ? data.id || null : null,
|
|
413
1436
|
});
|
|
414
|
-
|
|
1437
|
+
return {
|
|
415
1438
|
kind: entry.kind,
|
|
416
1439
|
action: entry.action,
|
|
417
1440
|
accepted: true,
|
|
418
1441
|
idempotent: false,
|
|
419
1442
|
result,
|
|
420
1443
|
diagnostics: result?.diagnostics || [],
|
|
421
|
-
}
|
|
1444
|
+
};
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
rollbackRecordFiles(rollbackFiles, error);
|
|
1447
|
+
throw error;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
if (!db) {
|
|
1452
|
+
const results = entries.map((entry) => {
|
|
1453
|
+
try {
|
|
1454
|
+
return runEntry(entry);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
if (isHardError(error)) throw error;
|
|
1457
|
+
return {
|
|
1458
|
+
kind: entry.kind,
|
|
1459
|
+
action: entry.action,
|
|
1460
|
+
accepted: false,
|
|
1461
|
+
mutable: true,
|
|
1462
|
+
diagnostics: [diagnostic(error)],
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
return {
|
|
1467
|
+
accepted: results.every((item) => item.accepted),
|
|
1468
|
+
mutable: true,
|
|
1469
|
+
results,
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const attempted = entries.map((entry) => {
|
|
1474
|
+
try {
|
|
1475
|
+
return ops.tx(db, () => runEntry(entry));
|
|
422
1476
|
} catch (error) {
|
|
1477
|
+
const persistenceError = stablePersistenceError(
|
|
1478
|
+
error,
|
|
1479
|
+
`${entry.kind}:${entry.action} record`,
|
|
1480
|
+
);
|
|
1481
|
+
if (persistenceError !== error) throw persistenceError;
|
|
423
1482
|
if (isHardError(error)) throw error;
|
|
424
|
-
|
|
1483
|
+
return {
|
|
425
1484
|
kind: entry.kind,
|
|
426
1485
|
action: entry.action,
|
|
427
1486
|
accepted: false,
|
|
428
1487
|
mutable: true,
|
|
429
1488
|
diagnostics: [diagnostic(error)],
|
|
430
|
-
}
|
|
1489
|
+
};
|
|
431
1490
|
}
|
|
432
|
-
}
|
|
1491
|
+
});
|
|
433
1492
|
return {
|
|
434
|
-
accepted:
|
|
1493
|
+
accepted: attempted.every((item) => item.accepted),
|
|
435
1494
|
mutable: true,
|
|
436
|
-
results,
|
|
1495
|
+
results: attempted,
|
|
437
1496
|
};
|
|
438
1497
|
}
|
|
439
1498
|
|
|
@@ -621,12 +1680,60 @@ function normalizeCheckpointEvidence(db, input, rootDir) {
|
|
|
621
1680
|
});
|
|
622
1681
|
}
|
|
623
1682
|
|
|
624
|
-
|
|
1683
|
+
function checkpointMutation(db, input = {}, {
|
|
625
1684
|
rootDir = process.cwd(),
|
|
626
1685
|
runtime = 'unknown',
|
|
627
1686
|
markPlanRecoveryRequired = null,
|
|
628
1687
|
} = {}) {
|
|
629
1688
|
const scope = selectedScope(db, input);
|
|
1689
|
+
const checkpointScope = scope.task_id
|
|
1690
|
+
? { task_id: scope.task_id }
|
|
1691
|
+
: scope.change_id
|
|
1692
|
+
? { change_id: scope.change_id }
|
|
1693
|
+
: scope.baseline_id
|
|
1694
|
+
? { baseline_id: scope.baseline_id }
|
|
1695
|
+
: { project_id: 'project' };
|
|
1696
|
+
const retry = stageCheckpoints.findIdempotentDraft(db, {
|
|
1697
|
+
stage: input.stage,
|
|
1698
|
+
scope: checkpointScope,
|
|
1699
|
+
payload: input.payload || {},
|
|
1700
|
+
idempotency_key: `${input.idempotency_key}:draft`,
|
|
1701
|
+
});
|
|
1702
|
+
if (retry) {
|
|
1703
|
+
const hasHardConflict = retry.diagnostics.some(
|
|
1704
|
+
(item) => item.severity === 'hard_conflict',
|
|
1705
|
+
);
|
|
1706
|
+
const accepted = retry.status === 'draft' && !hasHardConflict
|
|
1707
|
+
? stageCheckpoints.acceptDraft(db, {
|
|
1708
|
+
id: retry.id,
|
|
1709
|
+
idempotency_key: `${input.idempotency_key}:accept`,
|
|
1710
|
+
})
|
|
1711
|
+
: retry;
|
|
1712
|
+
let result = retry.payload?.plan || null;
|
|
1713
|
+
if (input.stage === 'plan' && accepted.status === 'accepted') {
|
|
1714
|
+
result = {
|
|
1715
|
+
...result,
|
|
1716
|
+
team_checkpoint: taskLedger.publishTaskLedger(db, {
|
|
1717
|
+
rootDir,
|
|
1718
|
+
reason: 'plan_checkpoint_retry',
|
|
1719
|
+
}),
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
accepted: accepted.status === 'accepted',
|
|
1724
|
+
mutable: accepted.status !== 'accepted',
|
|
1725
|
+
idempotent: true,
|
|
1726
|
+
diagnostics: accepted.diagnostics,
|
|
1727
|
+
blockers: accepted.diagnostics
|
|
1728
|
+
.filter((item) => item.severity === 'hard_conflict')
|
|
1729
|
+
.map((item) => item.code),
|
|
1730
|
+
checkpoint: accepted,
|
|
1731
|
+
context: accepted.context_envelope_id
|
|
1732
|
+
? contextEnvelope.readEnvelope(db, accepted.context_envelope_id, { rootDir })
|
|
1733
|
+
: null,
|
|
1734
|
+
result,
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
630
1737
|
const context = contextEnvelope.persistEnvelope(db, {
|
|
631
1738
|
stage: input.stage,
|
|
632
1739
|
scope,
|
|
@@ -665,13 +1772,7 @@ async function checkpoint(db, input = {}, {
|
|
|
665
1772
|
|
|
666
1773
|
const draft = stageCheckpoints.saveDraft(db, {
|
|
667
1774
|
stage: input.stage,
|
|
668
|
-
scope:
|
|
669
|
-
? { task_id: scope.task_id }
|
|
670
|
-
: scope.change_id
|
|
671
|
-
? { change_id: scope.change_id }
|
|
672
|
-
: scope.baseline_id
|
|
673
|
-
? { baseline_id: scope.baseline_id }
|
|
674
|
-
: { project_id: 'project' },
|
|
1775
|
+
scope: checkpointScope,
|
|
675
1776
|
payload: input.stage === 'plan' && result
|
|
676
1777
|
? { ...(input.payload || {}), plan: result }
|
|
677
1778
|
: (input.payload || {}),
|
|
@@ -715,20 +1816,68 @@ async function checkpoint(db, input = {}, {
|
|
|
715
1816
|
};
|
|
716
1817
|
}
|
|
717
1818
|
|
|
1819
|
+
function checkpoint(db, input = {}, options = {}) {
|
|
1820
|
+
validateCheckpointInput(input);
|
|
1821
|
+
try {
|
|
1822
|
+
return checkpointMutation(db, input, options);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
throw stablePersistenceError(error, 'checkpoint publication');
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function migrationRequired(error) {
|
|
1829
|
+
return {
|
|
1830
|
+
status: 'migration_required',
|
|
1831
|
+
migration: {
|
|
1832
|
+
required: true,
|
|
1833
|
+
code: errorCode(error),
|
|
1834
|
+
action: 'ultra.sync migrate',
|
|
1835
|
+
},
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function corruptLedgerInspection(error, rootDir) {
|
|
1840
|
+
return {
|
|
1841
|
+
status: 'corrupt',
|
|
1842
|
+
path: taskLedger.ledgerPath(rootDir),
|
|
1843
|
+
diagnostics: [diagnostic(error, 'hard_conflict')],
|
|
1844
|
+
recovery: {
|
|
1845
|
+
actions: [
|
|
1846
|
+
{
|
|
1847
|
+
action: 'doctor',
|
|
1848
|
+
command: 'ultra.doctor',
|
|
1849
|
+
description: 'Inspect the managed authority and its deterministic repairs.',
|
|
1850
|
+
},
|
|
1851
|
+
{
|
|
1852
|
+
action: 'restore',
|
|
1853
|
+
path: taskLedger.LEDGER_RELATIVE_PATH,
|
|
1854
|
+
description: 'Restore the last trusted ledger bytes from version control or backup.',
|
|
1855
|
+
},
|
|
1856
|
+
],
|
|
1857
|
+
},
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
|
|
718
1861
|
function sync(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1862
|
+
validateSyncInput(input);
|
|
719
1863
|
gitBootstrap.ensureExistingProjectStorageBoundary(rootDir);
|
|
720
1864
|
if (input.action === 'inspect') {
|
|
721
1865
|
try {
|
|
722
1866
|
return taskLedger.inspectTaskLedger(db, { rootDir });
|
|
723
1867
|
} catch (error) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1868
|
+
if (errorCode(error) === 'TASK_LEDGER_SCHEMA_MIGRATION_REQUIRED') {
|
|
1869
|
+
return migrationRequired(error);
|
|
1870
|
+
}
|
|
1871
|
+
if (['TASK_LEDGER_INVALID', 'TASK_LEDGER_DIGEST_MISMATCH'].includes(errorCode(error))) {
|
|
1872
|
+
try {
|
|
1873
|
+
const legacy = taskLedger.syncTaskLedger(db, { rootDir });
|
|
1874
|
+
if (legacy.status === 'legacy') return migrationRequired(error);
|
|
1875
|
+
} catch {
|
|
1876
|
+
// Keep the original validated read error as the public diagnostic.
|
|
1877
|
+
}
|
|
1878
|
+
return corruptLedgerInspection(error, rootDir);
|
|
1879
|
+
}
|
|
1880
|
+
throw error;
|
|
732
1881
|
}
|
|
733
1882
|
}
|
|
734
1883
|
if (input.action === 'import') return taskLedger.importTaskLedger(db, { rootDir });
|
|
@@ -743,22 +1892,56 @@ function sync(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
743
1892
|
};
|
|
744
1893
|
}
|
|
745
1894
|
if (input.action === 'publish') {
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
reason
|
|
751
|
-
});
|
|
752
|
-
rememberResult(db, {
|
|
753
|
-
idempotencyKey: input.idempotency_key,
|
|
754
|
-
operation: 'sync:publish',
|
|
755
|
-
result,
|
|
1895
|
+
const operation = 'sync:publish';
|
|
1896
|
+
const reason = input.reason || 'manual_checkpoint';
|
|
1897
|
+
const acceptedRequestDigest = requestDigest(operation, {
|
|
1898
|
+
action: input.action,
|
|
1899
|
+
reason,
|
|
756
1900
|
});
|
|
757
|
-
|
|
1901
|
+
let publication = null;
|
|
1902
|
+
try {
|
|
1903
|
+
return ops.tx(db, () => {
|
|
1904
|
+
const cached = priorResult(db, {
|
|
1905
|
+
idempotencyKey: input.idempotency_key,
|
|
1906
|
+
operation,
|
|
1907
|
+
requestDigest: acceptedRequestDigest,
|
|
1908
|
+
});
|
|
1909
|
+
if (cached.found) return { ...cached.result, idempotent: true };
|
|
1910
|
+
publication = taskLedger.publishTaskLedger(db, { rootDir, reason });
|
|
1911
|
+
rememberResultInTx(db, {
|
|
1912
|
+
idempotencyKey: input.idempotency_key,
|
|
1913
|
+
operation,
|
|
1914
|
+
requestDigest: acceptedRequestDigest,
|
|
1915
|
+
result: publication,
|
|
1916
|
+
});
|
|
1917
|
+
return publication;
|
|
1918
|
+
});
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
if (publication) {
|
|
1921
|
+
try {
|
|
1922
|
+
taskLedger.rollbackTaskLedgerPublication(publication);
|
|
1923
|
+
} catch (rollbackError) {
|
|
1924
|
+
throw Object.assign(
|
|
1925
|
+
new Error(
|
|
1926
|
+
`sync publication failed and the team ledger could not be restored: `
|
|
1927
|
+
+ `${error.message}; ${rollbackError.message}`,
|
|
1928
|
+
),
|
|
1929
|
+
{
|
|
1930
|
+
code: 'STATE_CORRUPT',
|
|
1931
|
+
cause: error,
|
|
1932
|
+
details: {
|
|
1933
|
+
original_error: errorCode(error),
|
|
1934
|
+
rollback_error: errorCode(rollbackError),
|
|
1935
|
+
path: taskLedger.LEDGER_RELATIVE_PATH,
|
|
1936
|
+
},
|
|
1937
|
+
},
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
throw stablePersistenceError(error, 'sync publication');
|
|
1942
|
+
}
|
|
758
1943
|
}
|
|
759
|
-
throw
|
|
760
|
-
code: 'VALIDATION_ERROR',
|
|
761
|
-
});
|
|
1944
|
+
throw validationError(`unsupported sync action: ${input.action}`);
|
|
762
1945
|
}
|
|
763
1946
|
|
|
764
1947
|
function sessionDiagnostic(error) {
|
|
@@ -771,11 +1954,285 @@ function sessionDiagnostic(error) {
|
|
|
771
1954
|
};
|
|
772
1955
|
}
|
|
773
1956
|
|
|
1957
|
+
function attachRecovery(error, recovery) {
|
|
1958
|
+
if (!recovery) return error;
|
|
1959
|
+
error.details = {
|
|
1960
|
+
...(error.details || {}),
|
|
1961
|
+
recovery,
|
|
1962
|
+
};
|
|
1963
|
+
return error;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function idempotentSessionMutation(db, input, mutate, {
|
|
1967
|
+
rollbackEffect = null,
|
|
1968
|
+
prepareEffect = null,
|
|
1969
|
+
} = {}) {
|
|
1970
|
+
const operation = `session:${input.action}`;
|
|
1971
|
+
const request = Object.fromEntries(
|
|
1972
|
+
['action', 'scope', 'payload']
|
|
1973
|
+
.filter((key) => Object.hasOwn(input, key))
|
|
1974
|
+
.map((key) => [key, input[key]]),
|
|
1975
|
+
);
|
|
1976
|
+
const acceptedRequestDigest = requestDigest(operation, request);
|
|
1977
|
+
let mutationCompleted = false;
|
|
1978
|
+
let effectPrepared = false;
|
|
1979
|
+
let result = null;
|
|
1980
|
+
try {
|
|
1981
|
+
const transactionalMutation = () => {
|
|
1982
|
+
const cached = priorResult(db, {
|
|
1983
|
+
idempotencyKey: input.idempotency_key,
|
|
1984
|
+
operation,
|
|
1985
|
+
requestDigest: acceptedRequestDigest,
|
|
1986
|
+
});
|
|
1987
|
+
if (cached.found) return { ...cached.result, idempotent: true };
|
|
1988
|
+
if (prepareEffect) {
|
|
1989
|
+
prepareEffect();
|
|
1990
|
+
effectPrepared = true;
|
|
1991
|
+
}
|
|
1992
|
+
result = mutate();
|
|
1993
|
+
mutationCompleted = true;
|
|
1994
|
+
rememberResultInTx(db, {
|
|
1995
|
+
idempotencyKey: input.idempotency_key,
|
|
1996
|
+
operation,
|
|
1997
|
+
requestDigest: acceptedRequestDigest,
|
|
1998
|
+
result,
|
|
1999
|
+
taskId: input.scope?.task_id || null,
|
|
2000
|
+
});
|
|
2001
|
+
return result;
|
|
2002
|
+
};
|
|
2003
|
+
return ops.tx(db, transactionalMutation);
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
let recovery = null;
|
|
2006
|
+
if ((effectPrepared || mutationCompleted) && rollbackEffect) {
|
|
2007
|
+
recovery = rollbackEffect(result, error);
|
|
2008
|
+
}
|
|
2009
|
+
const recovered = attachRecovery(
|
|
2010
|
+
stablePersistenceError(error, `${operation} receipt`),
|
|
2011
|
+
recovery,
|
|
2012
|
+
);
|
|
2013
|
+
if (recovered !== error || isHardError(recovered)) throw recovered;
|
|
2014
|
+
return sessionDiagnostic(recovered);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function removeRolledBackManagedFile(db, {
|
|
2019
|
+
rootDir,
|
|
2020
|
+
table,
|
|
2021
|
+
idField,
|
|
2022
|
+
id,
|
|
2023
|
+
relative,
|
|
2024
|
+
expectedPrefix,
|
|
2025
|
+
expectedBasename,
|
|
2026
|
+
}) {
|
|
2027
|
+
if (!relative || db.prepare(
|
|
2028
|
+
`SELECT 1 FROM ${table} WHERE ${idField} = ?`,
|
|
2029
|
+
).get(id)) return;
|
|
2030
|
+
if (path.isAbsolute(relative)) {
|
|
2031
|
+
throw validationError(`managed rollback path must be project-relative: ${relative}`);
|
|
2032
|
+
}
|
|
2033
|
+
const absolute = path.resolve(rootDir, relative);
|
|
2034
|
+
const prefix = path.resolve(rootDir, expectedPrefix);
|
|
2035
|
+
if (!absolute.startsWith(`${prefix}${path.sep}`)
|
|
2036
|
+
|| path.basename(absolute) !== expectedBasename) {
|
|
2037
|
+
throw Object.assign(
|
|
2038
|
+
new Error(`managed rollback path escaped its authority: ${relative}`),
|
|
2039
|
+
{ code: 'PATH_AUTHORITY_VIOLATION' },
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
restoreRecordFile(absolute, { existed: false, bytes: null, mode: null });
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function snapshotAcquisitionEffect(db, taskId) {
|
|
2046
|
+
return {
|
|
2047
|
+
context_ids: new Set(db.prepare(
|
|
2048
|
+
`SELECT * FROM context_envelopes
|
|
2049
|
+
WHERE scope_type = 'task' AND scope_id = ?`,
|
|
2050
|
+
).all(taskId).map((row) => row.id)),
|
|
2051
|
+
prior_session: db.prepare(
|
|
2052
|
+
`SELECT * FROM sessions
|
|
2053
|
+
WHERE task_id = ? AND status = 'running'
|
|
2054
|
+
ORDER BY rowid DESC LIMIT 1`,
|
|
2055
|
+
).get(taskId) || null,
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function rollbackAcquisitionEffect(db, rootDir, acquisition, error) {
|
|
2060
|
+
const issues = [];
|
|
2061
|
+
let priorSessionReconciled = false;
|
|
2062
|
+
const attempt = (action, run) => {
|
|
2063
|
+
try { run(); }
|
|
2064
|
+
catch (cause) {
|
|
2065
|
+
issues.push({
|
|
2066
|
+
action,
|
|
2067
|
+
code: errorCode(cause),
|
|
2068
|
+
message: cause.message,
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
if (acquisition.handle?.sid && acquisition.handle?.worktree_path) {
|
|
2073
|
+
attempt('remove_worktree', () => {
|
|
2074
|
+
sessionRunner._internal.reconcileRemovedWorktree(
|
|
2075
|
+
rootDir,
|
|
2076
|
+
acquisition.handle.worktree_path,
|
|
2077
|
+
{ sid: acquisition.handle.sid },
|
|
2078
|
+
);
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
if (acquisition.handle?.sid && acquisition.handle?.artifact_dir) {
|
|
2082
|
+
attempt('remove_artifact_dir', () => {
|
|
2083
|
+
const expected = path.resolve(
|
|
2084
|
+
rootDir,
|
|
2085
|
+
'.ultra',
|
|
2086
|
+
'.runtime',
|
|
2087
|
+
'sessions',
|
|
2088
|
+
acquisition.handle.sid,
|
|
2089
|
+
);
|
|
2090
|
+
const actual = path.resolve(acquisition.handle.artifact_dir);
|
|
2091
|
+
if (actual !== expected) {
|
|
2092
|
+
throw Object.assign(
|
|
2093
|
+
new Error(`session artifact rollback escaped its authority: ${actual}`),
|
|
2094
|
+
{ code: 'PATH_AUTHORITY_VIOLATION' },
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
const stat = fs.lstatSync(actual, { throwIfNoEntry: false });
|
|
2098
|
+
if (stat?.isSymbolicLink() || stat && !stat.isDirectory()) {
|
|
2099
|
+
throw Object.assign(
|
|
2100
|
+
new Error(`session artifact rollback target is unsafe: ${actual}`),
|
|
2101
|
+
{ code: 'PATH_AUTHORITY_VIOLATION' },
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
if (stat) fs.rmSync(actual, { recursive: true });
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
if (acquisition.packet?.created) {
|
|
2108
|
+
const packet = acquisition.packet;
|
|
2109
|
+
attempt('remove_worker_packet', () => removeRolledBackManagedFile(db, {
|
|
2110
|
+
rootDir,
|
|
2111
|
+
table: 'worker_packets',
|
|
2112
|
+
idField: 'id',
|
|
2113
|
+
id: packet.id,
|
|
2114
|
+
relative: packet.packet_path,
|
|
2115
|
+
expectedPrefix: path.join('.ultra', '.runtime', 'worker-packets'),
|
|
2116
|
+
expectedBasename: `${packet.id}.json`,
|
|
2117
|
+
}));
|
|
2118
|
+
}
|
|
2119
|
+
const context = acquisition.packet?.context_envelope;
|
|
2120
|
+
if (context?.id && !acquisition.snapshot?.context_ids.has(context.id)) {
|
|
2121
|
+
attempt('remove_context_envelope', () => removeRolledBackManagedFile(db, {
|
|
2122
|
+
rootDir,
|
|
2123
|
+
table: 'context_envelopes',
|
|
2124
|
+
idField: 'id',
|
|
2125
|
+
id: context.id,
|
|
2126
|
+
relative: context.path,
|
|
2127
|
+
expectedPrefix: '.ultra',
|
|
2128
|
+
expectedBasename: `${context.id}.json`,
|
|
2129
|
+
}));
|
|
2130
|
+
}
|
|
2131
|
+
const priorSession = acquisition.snapshot?.prior_session;
|
|
2132
|
+
if (priorSession?.pid
|
|
2133
|
+
&& !sessionRunner._internal.processIsExecuting(priorSession.pid)) {
|
|
2134
|
+
attempt('reconcile_terminated_prior_session', () => {
|
|
2135
|
+
const current = ops.readSession(db, priorSession.sid);
|
|
2136
|
+
if (current?.status === 'running' && current.pid === priorSession.pid) {
|
|
2137
|
+
ops.updateSession(db, priorSession.sid, { status: 'crashed', pid: null });
|
|
2138
|
+
priorSessionReconciled = true;
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
if (issues.length > 0) {
|
|
2143
|
+
throw Object.assign(
|
|
2144
|
+
new Error(
|
|
2145
|
+
`session acquisition receipt failed and effect rollback requires recovery: `
|
|
2146
|
+
+ `${error.message}`,
|
|
2147
|
+
),
|
|
2148
|
+
{
|
|
2149
|
+
code: 'SESSION_RECEIPT_RECOVERY_REQUIRED',
|
|
2150
|
+
cause: error,
|
|
2151
|
+
details: {
|
|
2152
|
+
issues,
|
|
2153
|
+
retry: 'Run ultra.doctor with repair=true, then retry the exact ultra.session request.',
|
|
2154
|
+
},
|
|
2155
|
+
},
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
return {
|
|
2159
|
+
status: 'rolled_back',
|
|
2160
|
+
prior_session_reconciled: priorSessionReconciled,
|
|
2161
|
+
retry: 'Retry the exact ultra.session request with the same idempotency_key.',
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function recoverReleaseReceiptFailure(db, rootDir, session, payload, error) {
|
|
2166
|
+
if (!session) {
|
|
2167
|
+
throw Object.assign(
|
|
2168
|
+
new Error('session release receipt failed after its session authority disappeared'),
|
|
2169
|
+
{
|
|
2170
|
+
code: 'SESSION_RECEIPT_RECOVERY_REQUIRED',
|
|
2171
|
+
cause: error,
|
|
2172
|
+
details: { retry: 'Run ultra.doctor with repair=true before retrying release.' },
|
|
2173
|
+
},
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const requestedStatus = payload.status || 'completed';
|
|
2177
|
+
const removedWorktree = payload.remove_worktree === true;
|
|
2178
|
+
if (!removedWorktree && !session.pid) {
|
|
2179
|
+
return {
|
|
2180
|
+
status: 'rolled_back',
|
|
2181
|
+
retry: 'Retry the exact ultra.session release request with the same idempotency_key.',
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const closeJournal = sessionRunner._internal.closeJournal;
|
|
2185
|
+
try {
|
|
2186
|
+
if (removedWorktree) {
|
|
2187
|
+
closeJournal.prepare(rootDir, {
|
|
2188
|
+
sid: session.sid,
|
|
2189
|
+
task_id: session.task_id,
|
|
2190
|
+
requested_status: requestedStatus,
|
|
2191
|
+
worktree_path: session.worktree_path,
|
|
2192
|
+
});
|
|
2193
|
+
sessionRunner._internal.reconcileRemovedWorktree(
|
|
2194
|
+
rootDir,
|
|
2195
|
+
session.worktree_path,
|
|
2196
|
+
{ sid: session.sid },
|
|
2197
|
+
);
|
|
2198
|
+
closeJournal.update(rootDir, session.sid, {
|
|
2199
|
+
phase: 'worktree_removed',
|
|
2200
|
+
error: null,
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
ops.updateSession(db, session.sid, { status: requestedStatus, pid: null });
|
|
2204
|
+
if (removedWorktree) closeJournal.discard(rootDir, session.sid);
|
|
2205
|
+
} catch (cause) {
|
|
2206
|
+
throw Object.assign(
|
|
2207
|
+
new Error(
|
|
2208
|
+
`session release receipt failed and close recovery requires attention: `
|
|
2209
|
+
+ `${error.message}; ${cause.message}`,
|
|
2210
|
+
),
|
|
2211
|
+
{
|
|
2212
|
+
code: 'SESSION_RECEIPT_RECOVERY_REQUIRED',
|
|
2213
|
+
cause: error,
|
|
2214
|
+
details: {
|
|
2215
|
+
recovery_error: errorCode(cause),
|
|
2216
|
+
sid: session.sid,
|
|
2217
|
+
retry: 'Run ultra.doctor with repair=true, then retry the exact release request.',
|
|
2218
|
+
},
|
|
2219
|
+
},
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
return {
|
|
2223
|
+
status: 'terminal_recovered',
|
|
2224
|
+
sid: session.sid,
|
|
2225
|
+
session_status: requestedStatus,
|
|
2226
|
+
retry: 'Retry the exact ultra.session release request to persist its receipt.',
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
774
2230
|
async function session(db, input = {}, {
|
|
775
2231
|
rootDir = process.cwd(),
|
|
776
2232
|
runtime = 'unknown',
|
|
777
2233
|
sessionId = null,
|
|
778
2234
|
} = {}) {
|
|
2235
|
+
validateSessionInput(input);
|
|
779
2236
|
const scope = input.scope || {};
|
|
780
2237
|
const payload = input.payload || {};
|
|
781
2238
|
if (input.action === 'get') {
|
|
@@ -785,22 +2242,37 @@ async function session(db, input = {}, {
|
|
|
785
2242
|
const sessions = ops.listActiveSessions(db, { task_id: scope.task_id });
|
|
786
2243
|
return { sessions, count: sessions.length };
|
|
787
2244
|
}
|
|
788
|
-
if (input.action === 'heartbeat')
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
{ code: 'WORKER_SESSION_PARENT_OWNED' },
|
|
794
|
-
));
|
|
795
|
-
}
|
|
796
|
-
return sessionRunner.closeSession(
|
|
797
|
-
{ db, repoRoot: rootDir, sid: scope.sid },
|
|
798
|
-
{
|
|
799
|
-
status: payload.status || 'completed',
|
|
800
|
-
remove_worktree: payload.remove_worktree === true,
|
|
801
|
-
},
|
|
2245
|
+
if (input.action === 'heartbeat') {
|
|
2246
|
+
return idempotentSessionMutation(
|
|
2247
|
+
db,
|
|
2248
|
+
input,
|
|
2249
|
+
() => ops.heartbeatSession(db, scope.sid),
|
|
802
2250
|
);
|
|
803
2251
|
}
|
|
2252
|
+
if (input.action === 'release') {
|
|
2253
|
+
const releasingSession = ops.readSession(db, scope.sid);
|
|
2254
|
+
return idempotentSessionMutation(db, input, () => {
|
|
2255
|
+
if (sessionId) {
|
|
2256
|
+
return sessionDiagnostic(Object.assign(
|
|
2257
|
+
new Error('a worker session is settled by its parent host'),
|
|
2258
|
+
{ code: 'WORKER_SESSION_PARENT_OWNED' },
|
|
2259
|
+
));
|
|
2260
|
+
}
|
|
2261
|
+
return sessionRunner.closeSession(
|
|
2262
|
+
{ db, repoRoot: rootDir, sid: scope.sid },
|
|
2263
|
+
{
|
|
2264
|
+
status: payload.status || 'completed',
|
|
2265
|
+
remove_worktree: payload.remove_worktree === true,
|
|
2266
|
+
},
|
|
2267
|
+
);
|
|
2268
|
+
}, {
|
|
2269
|
+
rollbackEffect: (result, error) => (
|
|
2270
|
+
result?.accepted === false
|
|
2271
|
+
? null
|
|
2272
|
+
: recoverReleaseReceiptFailure(db, rootDir, releasingSession, payload, error)
|
|
2273
|
+
),
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
804
2276
|
const taskId = scope.task_id;
|
|
805
2277
|
if (input.action === 'admission') {
|
|
806
2278
|
try {
|
|
@@ -808,13 +2280,13 @@ async function session(db, input = {}, {
|
|
|
808
2280
|
if (!task) throw Object.assign(new Error(`task ${taskId} not found`), { code: 'TASK_NOT_FOUND' });
|
|
809
2281
|
const attention = ops.taskContractBlockers(task).map((code) => ({
|
|
810
2282
|
code,
|
|
811
|
-
severity: '
|
|
2283
|
+
severity: 'warning',
|
|
812
2284
|
}));
|
|
813
2285
|
const lease = ops.admissionCheck(db, taskId);
|
|
814
2286
|
return {
|
|
815
|
-
accepted:
|
|
2287
|
+
accepted: lease.can_spawn,
|
|
816
2288
|
mutable: true,
|
|
817
|
-
can_acquire:
|
|
2289
|
+
can_acquire: lease.can_spawn,
|
|
818
2290
|
diagnostics: [
|
|
819
2291
|
...attention,
|
|
820
2292
|
...(lease.can_spawn ? [] : [{
|
|
@@ -829,95 +2301,341 @@ async function session(db, input = {}, {
|
|
|
829
2301
|
return sessionDiagnostic(error);
|
|
830
2302
|
}
|
|
831
2303
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
2304
|
+
const acquisition = {
|
|
2305
|
+
task_id: taskId,
|
|
2306
|
+
packet: null,
|
|
2307
|
+
handle: null,
|
|
2308
|
+
snapshot: null,
|
|
2309
|
+
authority: null,
|
|
2310
|
+
};
|
|
2311
|
+
try {
|
|
2312
|
+
acquisition.authority = runtimePaths.ensureRuntimeState(rootDir, {
|
|
2313
|
+
admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(rootDir),
|
|
835
2314
|
});
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
return sessionDiagnostic(error);
|
|
836
2317
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
2318
|
+
return idempotentSessionMutation(db, input, () => {
|
|
2319
|
+
try {
|
|
2320
|
+
acquisition.packet = workerPacket.createWorkerPacket(db, {
|
|
2321
|
+
role: payload.role || 'implement',
|
|
2322
|
+
task_id: taskId,
|
|
2323
|
+
runtime: payload.runtime || runtime,
|
|
2324
|
+
output_path: payload.output_path
|
|
2325
|
+
|| `.ultra/changes/active/${ops.readTask(db, taskId)?.change_id}/delivery/${taskId}-outcome.json`,
|
|
2326
|
+
output_schema: payload.output_schema,
|
|
2327
|
+
evidence_refs: payload.evidence_refs,
|
|
2328
|
+
diff_range: payload.diff_range,
|
|
2329
|
+
changed_files: payload.changed_files,
|
|
2330
|
+
}, { rootDir });
|
|
2331
|
+
acquisition.handle = sessionRunner.spawnSession({
|
|
2332
|
+
db,
|
|
2333
|
+
repoRoot: rootDir,
|
|
2334
|
+
task_id: taskId,
|
|
2335
|
+
runtime: payload.runtime || runtime,
|
|
2336
|
+
authority: acquisition.authority,
|
|
2337
|
+
takeover: payload.takeover === true,
|
|
2338
|
+
worktree_base: payload.worktree_base,
|
|
2339
|
+
kernel_mode: true,
|
|
2340
|
+
mark_task_started: true,
|
|
2341
|
+
packet_digest: acquisition.packet.packet_digest,
|
|
2342
|
+
});
|
|
2343
|
+
workerPacket.markWorkerPacketAssigned(db, acquisition.packet.id);
|
|
2344
|
+
return {
|
|
2345
|
+
accepted: true,
|
|
2346
|
+
sid: acquisition.handle.sid,
|
|
2347
|
+
worktree_path: acquisition.handle.worktree_path,
|
|
2348
|
+
artifact_dir: acquisition.handle.artifact_dir,
|
|
2349
|
+
lease_expires_at: acquisition.handle.lease_expires_at,
|
|
2350
|
+
packet: acquisition.packet,
|
|
2351
|
+
};
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
if (acquisition.packet?.id) {
|
|
2354
|
+
throw error;
|
|
2355
|
+
}
|
|
2356
|
+
return sessionDiagnostic(error);
|
|
2357
|
+
}
|
|
2358
|
+
}, {
|
|
2359
|
+
prepareEffect: () => {
|
|
2360
|
+
acquisition.snapshot = snapshotAcquisitionEffect(db, taskId);
|
|
2361
|
+
},
|
|
2362
|
+
rollbackEffect: (_result, error) => rollbackAcquisitionEffect(
|
|
851
2363
|
db,
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
2364
|
+
rootDir,
|
|
2365
|
+
acquisition,
|
|
2366
|
+
error,
|
|
2367
|
+
),
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function snapshotManagedDirectory(directory) {
|
|
2372
|
+
const rootStat = fs.lstatSync(directory, { throwIfNoEntry: false });
|
|
2373
|
+
if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
2374
|
+
throw Object.assign(
|
|
2375
|
+
new Error(`archive source must be a managed directory: ${directory}`),
|
|
2376
|
+
{ code: 'ARCHIVE_PATH_UNSAFE' },
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
const entries = [];
|
|
2380
|
+
const walk = (parent, relativeParent = '') => {
|
|
2381
|
+
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
2382
|
+
const relative = path.join(relativeParent, entry.name);
|
|
2383
|
+
const absolute = path.join(parent, entry.name);
|
|
2384
|
+
const stat = fs.lstatSync(absolute);
|
|
2385
|
+
if (stat.isSymbolicLink()) {
|
|
2386
|
+
throw Object.assign(
|
|
2387
|
+
new Error(`archive source contains a symbolic link: ${relative}`),
|
|
2388
|
+
{ code: 'ARCHIVE_PATH_UNSAFE' },
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
if (stat.isDirectory()) {
|
|
2392
|
+
entries.push({ type: 'directory', relative, mode: stat.mode });
|
|
2393
|
+
walk(absolute, relative);
|
|
2394
|
+
} else if (stat.isFile()) {
|
|
2395
|
+
entries.push({
|
|
2396
|
+
type: 'file',
|
|
2397
|
+
relative,
|
|
2398
|
+
mode: stat.mode,
|
|
2399
|
+
bytes: fs.readFileSync(absolute),
|
|
2400
|
+
});
|
|
2401
|
+
} else {
|
|
2402
|
+
throw Object.assign(
|
|
2403
|
+
new Error(`archive source contains an unsupported entry: ${relative}`),
|
|
2404
|
+
{ code: 'ARCHIVE_PATH_UNSAFE' },
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
};
|
|
2409
|
+
walk(directory);
|
|
2410
|
+
return {
|
|
2411
|
+
source: directory,
|
|
2412
|
+
mode: rootStat.mode,
|
|
2413
|
+
entries,
|
|
2414
|
+
destination: null,
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function prepareArchiveDirectoryRollback(change, rootDir) {
|
|
2419
|
+
if (!change || change.status === 'archived') return null;
|
|
2420
|
+
const expected = path.resolve(
|
|
2421
|
+
rootDir,
|
|
2422
|
+
'.ultra',
|
|
2423
|
+
'changes',
|
|
2424
|
+
'active',
|
|
2425
|
+
change.id,
|
|
2426
|
+
);
|
|
2427
|
+
const actual = path.resolve(rootDir, change.artifact_root);
|
|
2428
|
+
if (actual !== expected) {
|
|
2429
|
+
throw Object.assign(
|
|
2430
|
+
new Error(`active Change artifact root escaped its authority: ${change.artifact_root}`),
|
|
2431
|
+
{ code: 'ARCHIVE_PATH_UNSAFE' },
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
return snapshotManagedDirectory(actual);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function restoreArchiveDirectory(rootDir, changeId, rollback) {
|
|
2438
|
+
if (!rollback?.destination) return;
|
|
2439
|
+
const archiveRoot = path.resolve(rootDir, '.ultra', 'changes', 'archive');
|
|
2440
|
+
const destination = path.resolve(rollback.destination);
|
|
2441
|
+
if (path.dirname(destination) !== archiveRoot
|
|
2442
|
+
|| !path.basename(destination).endsWith(`-${changeId}`)) {
|
|
2443
|
+
throw Object.assign(
|
|
2444
|
+
new Error(`archive rollback destination escaped its authority: ${destination}`),
|
|
2445
|
+
{ code: 'ARCHIVE_PATH_UNSAFE' },
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
const destinationStat = fs.lstatSync(destination, { throwIfNoEntry: false });
|
|
2449
|
+
if (!destinationStat || destinationStat.isSymbolicLink() || !destinationStat.isDirectory()) {
|
|
2450
|
+
throw Object.assign(
|
|
2451
|
+
new Error(`archive rollback destination is unavailable or unsafe: ${destination}`),
|
|
2452
|
+
{ code: 'ARCHIVE_RECOVERY_REQUIRED' },
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
if (fs.lstatSync(rollback.source, { throwIfNoEntry: false })) {
|
|
2456
|
+
throw Object.assign(
|
|
2457
|
+
new Error(`archive rollback source already exists: ${rollback.source}`),
|
|
2458
|
+
{ code: 'ARCHIVE_RECOVERY_REQUIRED' },
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
fs.renameSync(destination, rollback.source);
|
|
2462
|
+
for (const entry of fs.readdirSync(rollback.source)) {
|
|
2463
|
+
fs.rmSync(path.join(rollback.source, entry), { recursive: true });
|
|
2464
|
+
}
|
|
2465
|
+
const directories = rollback.entries
|
|
2466
|
+
.filter((entry) => entry.type === 'directory')
|
|
2467
|
+
.sort((left, right) => left.relative.length - right.relative.length);
|
|
2468
|
+
for (const entry of directories) {
|
|
2469
|
+
const target = path.join(rollback.source, entry.relative);
|
|
2470
|
+
fs.mkdirSync(target, { recursive: true, mode: entry.mode });
|
|
2471
|
+
}
|
|
2472
|
+
for (const entry of rollback.entries.filter((item) => item.type === 'file')) {
|
|
2473
|
+
const target = path.join(rollback.source, entry.relative);
|
|
2474
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2475
|
+
fs.writeFileSync(target, entry.bytes, { mode: entry.mode });
|
|
2476
|
+
}
|
|
2477
|
+
fs.chmodSync(rollback.source, rollback.mode);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function rollbackArchivePublication({
|
|
2481
|
+
publication,
|
|
2482
|
+
directory,
|
|
2483
|
+
rootDir,
|
|
2484
|
+
changeId,
|
|
2485
|
+
}, error) {
|
|
2486
|
+
const issues = [];
|
|
2487
|
+
try {
|
|
2488
|
+
deliveryTransaction.rollbackDeliveryTransaction({
|
|
2489
|
+
rootDir,
|
|
2490
|
+
changeId,
|
|
2491
|
+
});
|
|
2492
|
+
} catch (cause) {
|
|
2493
|
+
issues.push({
|
|
2494
|
+
action: 'restore_baseline_delivery',
|
|
2495
|
+
code: errorCode(cause),
|
|
2496
|
+
message: cause.message,
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
if (publication) {
|
|
2500
|
+
try { taskLedger.rollbackTaskLedgerPublication(publication); }
|
|
2501
|
+
catch (cause) {
|
|
2502
|
+
issues.push({
|
|
2503
|
+
action: 'restore_team_ledger',
|
|
2504
|
+
code: errorCode(cause),
|
|
2505
|
+
message: cause.message,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (directory?.destination) {
|
|
2510
|
+
try { restoreArchiveDirectory(rootDir, changeId, directory); }
|
|
2511
|
+
catch (cause) {
|
|
2512
|
+
issues.push({
|
|
2513
|
+
action: 'restore_active_change',
|
|
2514
|
+
code: errorCode(cause),
|
|
2515
|
+
message: cause.message,
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
if (issues.length > 0) {
|
|
2520
|
+
throw Object.assign(
|
|
2521
|
+
new Error(
|
|
2522
|
+
`archive publication failed and filesystem recovery requires attention: `
|
|
2523
|
+
+ `${error.message}`,
|
|
2524
|
+
),
|
|
2525
|
+
{
|
|
2526
|
+
code: 'ARCHIVE_RECOVERY_REQUIRED',
|
|
2527
|
+
cause: error,
|
|
2528
|
+
details: {
|
|
2529
|
+
change_id: changeId,
|
|
2530
|
+
issues,
|
|
2531
|
+
recovery: 'Run ultra.doctor with repair=true before retrying archive.',
|
|
2532
|
+
},
|
|
2533
|
+
},
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function finishArchiveDeliveryReceipt(rootDir, changeId, output) {
|
|
2539
|
+
try {
|
|
2540
|
+
deliveryTransaction.completeDeliveryTransaction({
|
|
2541
|
+
rootDir,
|
|
2542
|
+
changeId,
|
|
860
2543
|
});
|
|
861
|
-
|
|
2544
|
+
return output;
|
|
2545
|
+
} catch (error) {
|
|
862
2546
|
return {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
packet,
|
|
2547
|
+
...output,
|
|
2548
|
+
recovery_warning: [
|
|
2549
|
+
output.recovery_warning,
|
|
2550
|
+
`DELIVERY_JOURNAL_CLEANUP_PENDING:${error.message}`,
|
|
2551
|
+
].filter(Boolean).join(';'),
|
|
869
2552
|
};
|
|
870
|
-
} catch (error) {
|
|
871
|
-
if (packet?.id) {
|
|
872
|
-
workerPacket.abandonWorkerPacket(db, packet.id, error.code || error.message);
|
|
873
|
-
}
|
|
874
|
-
return sessionDiagnostic(error);
|
|
875
2553
|
}
|
|
876
2554
|
}
|
|
877
2555
|
|
|
878
2556
|
async function archive(db, input = {}, {
|
|
879
2557
|
rootDir = process.cwd(),
|
|
880
2558
|
} = {}) {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
2559
|
+
validateArchiveInput(input);
|
|
2560
|
+
const operation = 'archive';
|
|
2561
|
+
const acceptedRequestDigest = requestDigest(operation, {
|
|
2562
|
+
change_id: input.change_id,
|
|
2563
|
+
payload: input.payload,
|
|
2564
|
+
});
|
|
2565
|
+
let publication = null;
|
|
2566
|
+
const existing = priorResult(db, {
|
|
2567
|
+
idempotencyKey: input.idempotency_key,
|
|
2568
|
+
operation,
|
|
2569
|
+
requestDigest: acceptedRequestDigest,
|
|
2570
|
+
});
|
|
2571
|
+
if (existing.found) {
|
|
2572
|
+
return finishArchiveDeliveryReceipt(
|
|
2573
|
+
rootDir,
|
|
2574
|
+
input.change_id,
|
|
2575
|
+
{ ...existing.result, idempotent: true },
|
|
2576
|
+
);
|
|
895
2577
|
}
|
|
2578
|
+
const directoryRollback = prepareArchiveDirectoryRollback(
|
|
2579
|
+
changes.readChange(db, input.change_id),
|
|
2580
|
+
rootDir,
|
|
2581
|
+
);
|
|
896
2582
|
try {
|
|
897
|
-
const
|
|
898
|
-
db,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
2583
|
+
const output = ops.tx(db, () => {
|
|
2584
|
+
const cached = priorResult(db, {
|
|
2585
|
+
idempotencyKey: input.idempotency_key,
|
|
2586
|
+
operation,
|
|
2587
|
+
requestDigest: acceptedRequestDigest,
|
|
2588
|
+
});
|
|
2589
|
+
if (cached.found) return { ...cached.result, idempotent: true };
|
|
2590
|
+
const change = changes.readChange(db, input.change_id);
|
|
2591
|
+
if (!change) {
|
|
2592
|
+
return {
|
|
2593
|
+
accepted: false,
|
|
2594
|
+
mutable: true,
|
|
2595
|
+
blockers: ['CHANGE_NOT_FOUND'],
|
|
2596
|
+
diagnostics: [{
|
|
2597
|
+
code: 'CHANGE_NOT_FOUND',
|
|
2598
|
+
severity: 'needs_attention',
|
|
2599
|
+
message: `change ${input.change_id} not found`,
|
|
2600
|
+
}],
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
const result = changes.archiveChange(
|
|
2604
|
+
db,
|
|
2605
|
+
{ ...input.payload, id: input.change_id },
|
|
2606
|
+
{ rootDir, deferDeliveryCleanup: true },
|
|
2607
|
+
);
|
|
2608
|
+
if (directoryRollback) directoryRollback.destination = result.archive_path;
|
|
2609
|
+
const output = {
|
|
2610
|
+
accepted: true,
|
|
2611
|
+
mutable: false,
|
|
2612
|
+
blockers: [],
|
|
2613
|
+
diagnostics: [],
|
|
2614
|
+
result,
|
|
2615
|
+
team_checkpoint: (publication = taskLedger.publishTaskLedger(db, {
|
|
2616
|
+
rootDir,
|
|
2617
|
+
reason: 'change_archived',
|
|
2618
|
+
})),
|
|
2619
|
+
};
|
|
2620
|
+
rememberResultInTx(db, {
|
|
2621
|
+
idempotencyKey: input.idempotency_key,
|
|
2622
|
+
operation,
|
|
2623
|
+
requestDigest: acceptedRequestDigest,
|
|
2624
|
+
result: output,
|
|
2625
|
+
changeId: input.change_id,
|
|
2626
|
+
});
|
|
2627
|
+
return output;
|
|
918
2628
|
});
|
|
919
|
-
return output;
|
|
2629
|
+
return finishArchiveDeliveryReceipt(rootDir, input.change_id, output);
|
|
920
2630
|
} catch (error) {
|
|
2631
|
+
rollbackArchivePublication({
|
|
2632
|
+
publication,
|
|
2633
|
+
directory: directoryRollback,
|
|
2634
|
+
rootDir,
|
|
2635
|
+
changeId: input.change_id,
|
|
2636
|
+
}, error);
|
|
2637
|
+
const persistenceError = stablePersistenceError(error, 'archive publication');
|
|
2638
|
+
if (persistenceError !== error) throw persistenceError;
|
|
921
2639
|
if (isHardError(error)) throw error;
|
|
922
2640
|
return {
|
|
923
2641
|
accepted: false,
|
|
@@ -929,22 +2647,27 @@ async function archive(db, input = {}, {
|
|
|
929
2647
|
}
|
|
930
2648
|
|
|
931
2649
|
async function dispatch(name, input, db, context = {}) {
|
|
932
|
-
|
|
933
|
-
if (name === 'ultra.
|
|
934
|
-
if (name === 'ultra.
|
|
935
|
-
if (name === 'ultra.
|
|
936
|
-
if (name === 'ultra.
|
|
937
|
-
if (name === 'ultra.
|
|
2650
|
+
let result;
|
|
2651
|
+
if (name === 'ultra.context') result = readContext(db, input, context);
|
|
2652
|
+
else if (name === 'ultra.record') result = await record(db, input, context);
|
|
2653
|
+
else if (name === 'ultra.checkpoint') result = await checkpoint(db, input, context);
|
|
2654
|
+
else if (name === 'ultra.sync') result = sync(db, input, context);
|
|
2655
|
+
else if (name === 'ultra.session') result = await session(db, input, context);
|
|
2656
|
+
else if (name === 'ultra.archive') result = await archive(db, input, context);
|
|
938
2657
|
if (name === 'ultra.doctor') {
|
|
939
|
-
|
|
2658
|
+
validateDoctorInput(input);
|
|
2659
|
+
result = doctor.runDoctor(db, {
|
|
940
2660
|
rootDir: context.rootDir || process.cwd(),
|
|
941
2661
|
repair: input.repair === true,
|
|
942
2662
|
project: context.projector,
|
|
943
2663
|
});
|
|
944
2664
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
2665
|
+
if (result === undefined) {
|
|
2666
|
+
const error = new Error(`unhandled public tool ${name}`);
|
|
2667
|
+
error.code = 'UNKNOWN_TOOL';
|
|
2668
|
+
throw error;
|
|
2669
|
+
}
|
|
2670
|
+
return rememberRejectedAttempt(db, name, input, result);
|
|
948
2671
|
}
|
|
949
2672
|
|
|
950
2673
|
module.exports = {
|