ultra-builder-pro-cli 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/docs/research/README.md +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +54 -24
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +36 -3
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +30 -28
- package/bin/orchestrator.js +64 -24
- package/docs/AGENT-CONTEXT.md +74 -43
- package/docs/ARCHITECTURE.md +63 -31
- package/docs/ARTIFACT-AUTHORITY.md +234 -0
- package/docs/DECISIONS.md +10 -4
- package/docs/LEGACY-CLI-CROSSWALK.md +6 -4
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
- package/docs/ROADMAP.md +19 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +24 -13
- package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
- package/docs/WORKFLOW-LIFECYCLE.md +70 -39
- package/hooks/README.md +6 -5
- package/hooks/active_task_context.py +97 -12
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1126 -0
- package/mcp-server/lib/baseline-workflow.cjs +9 -4
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +111 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +345 -3
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +373 -11
- package/mcp-server/lib/git-bootstrap.cjs +459 -47
- package/mcp-server/lib/init-project.cjs +190 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +174 -40
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-db.cjs +263 -9
- package/mcp-server/lib/state-ops.cjs +248 -49
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +610 -58
- package/mcp-server/server.cjs +278 -33
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +26 -8
- package/skills/ultra-deliver/SKILL.md +23 -11
- package/skills/ultra-dev/SKILL.md +33 -6
- package/skills/ultra-doctor/SKILL.md +6 -2
- package/skills/ultra-init/SKILL.md +4 -2
- package/skills/ultra-plan/SKILL.md +49 -16
- package/skills/ultra-research/SKILL.md +30 -27
- package/skills/ultra-review/SKILL.md +9 -5
- package/skills/ultra-status/SKILL.md +9 -4
- package/skills/ultra-test/SKILL.md +10 -5
- package/skills/ultra-think/SKILL.md +6 -2
- package/skills/ultra-think/references/decision-dialogue.md +17 -6
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +18 -7
- package/spec/mcp-tools.yaml +502 -44
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +50 -13
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +995 -80
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +32 -6
- package/ultra-tools/commands/system.cjs +129 -22
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
|
-
const fs = require('node:fs');
|
|
5
4
|
const path = require('node:path');
|
|
6
5
|
const { spawnSync } = require('node:child_process');
|
|
7
6
|
const Ajv = require('ajv/dist/2020');
|
|
@@ -9,6 +8,8 @@ const addFormats = require('ajv-formats');
|
|
|
9
8
|
|
|
10
9
|
const ops = require('./state-ops.cjs');
|
|
11
10
|
const decisionDialogue = require('./decision-dialogue.cjs');
|
|
11
|
+
const artifactRegistry = require('./artifact-registry.cjs');
|
|
12
|
+
const { readStableProjectFile } = require('./safe-project-file.cjs');
|
|
12
13
|
const testReportSchema = require('../../spec/schemas/test-report.v1.schema.json');
|
|
13
14
|
const deliveryReportSchema = require('../../spec/schemas/delivery-report.v1.schema.json');
|
|
14
15
|
|
|
@@ -56,6 +57,9 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
56
57
|
]),
|
|
57
58
|
plan: Object.freeze([
|
|
58
59
|
step('validate-baseline', 'Validate baseline and research coverage', { evidence_required: true }),
|
|
60
|
+
step('compile-context', 'Compile the accepted planning context', {
|
|
61
|
+
evidence_required: true, output_required: true,
|
|
62
|
+
}),
|
|
59
63
|
step('analyze-requirements', 'Trace requirements and acceptance'),
|
|
60
64
|
step('analyze-codebase', 'Inspect current codebase patterns and boundaries', { evidence_required: true }),
|
|
61
65
|
step('design-slices', 'Design walking skeleton and vertical slices'),
|
|
@@ -195,18 +199,89 @@ function nonEmpty(value, field) {
|
|
|
195
199
|
|
|
196
200
|
function safeProjectFile(rootDir, candidate, field) {
|
|
197
201
|
const relative = nonEmpty(candidate, field);
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
try {
|
|
203
|
+
return readStableProjectFile(rootDir, relative, { encoding: 'utf8' });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code === 'PROJECT_FILE_MISSING') {
|
|
206
|
+
throw new WorkflowStateError(
|
|
207
|
+
'WORKFLOW_OUTPUT_MISSING',
|
|
208
|
+
`workflow output does not exist: ${relative}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
throw new WorkflowStateError(
|
|
212
|
+
'VALIDATION_ERROR',
|
|
213
|
+
`${field} is not a safe project-relative regular file: ${error.message}`,
|
|
214
|
+
);
|
|
200
215
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function requireManagedArtifact(db, {
|
|
219
|
+
owner_type, owner_id, kind, artifactPath, digest, error_code, field,
|
|
220
|
+
statuses = ['current'],
|
|
221
|
+
}) {
|
|
222
|
+
let relative;
|
|
223
|
+
try {
|
|
224
|
+
relative = artifactRegistry.normalizeRelativePath(artifactPath);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new WorkflowStateError(
|
|
227
|
+
error_code,
|
|
228
|
+
`${field} has an invalid registry path: ${error.message}`,
|
|
229
|
+
);
|
|
205
230
|
}
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
const rows = db.prepare(
|
|
232
|
+
`SELECT id, owner_type, owner_id, kind, path, digest, content_hash, managed, status
|
|
233
|
+
FROM artifacts
|
|
234
|
+
WHERE path = ?
|
|
235
|
+
ORDER BY updated_at DESC, rowid DESC`,
|
|
236
|
+
).all(relative);
|
|
237
|
+
const eligible = rows.filter((item) => statuses.includes(item.status));
|
|
238
|
+
const row = eligible.length === 1 ? eligible[0] : null;
|
|
239
|
+
const expectedDigest = row?.digest || row?.content_hash || null;
|
|
240
|
+
if (!row || row.owner_type !== owner_type || row.owner_id !== owner_id
|
|
241
|
+
|| row.kind !== kind || row.path !== relative || row.managed !== 1
|
|
242
|
+
|| !statuses.includes(row.status) || !expectedDigest || expectedDigest !== digest) {
|
|
243
|
+
throw new WorkflowStateError(
|
|
244
|
+
error_code,
|
|
245
|
+
`${field} does not match one exact active managed Artifact Registry authority`,
|
|
246
|
+
{
|
|
247
|
+
path: relative,
|
|
248
|
+
expected: {
|
|
249
|
+
owner_type, owner_id, kind, digest, statuses,
|
|
250
|
+
},
|
|
251
|
+
actual: rows.map((item) => ({
|
|
252
|
+
id: item.id,
|
|
253
|
+
owner_type: item.owner_type,
|
|
254
|
+
owner_id: item.owner_id,
|
|
255
|
+
kind: item.kind,
|
|
256
|
+
digest: item.digest || item.content_hash || null,
|
|
257
|
+
managed: Boolean(item.managed),
|
|
258
|
+
status: item.status,
|
|
259
|
+
})),
|
|
260
|
+
},
|
|
261
|
+
);
|
|
208
262
|
}
|
|
209
|
-
return
|
|
263
|
+
return row;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assertChangeArtifactPath(db, run, artifactPath, field) {
|
|
267
|
+
if (!run.change_id) return artifactRegistry.normalizeRelativePath(artifactPath);
|
|
268
|
+
const change = db.prepare(
|
|
269
|
+
'SELECT artifact_root FROM changes WHERE id = ?',
|
|
270
|
+
).get(run.change_id);
|
|
271
|
+
if (!change) {
|
|
272
|
+
throw new WorkflowStateError(
|
|
273
|
+
'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id} does not exist`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const relative = artifactRegistry.normalizeRelativePath(artifactPath);
|
|
277
|
+
const root = artifactRegistry.normalizeRelativePath(change.artifact_root);
|
|
278
|
+
if (relative !== root && !relative.startsWith(`${root}/`)) {
|
|
279
|
+
throw new WorkflowStateError(
|
|
280
|
+
'WORKFLOW_ARTIFACT_SCOPE_INVALID',
|
|
281
|
+
`${field} must stay inside the owning Change root ${root}: ${relative}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return relative;
|
|
210
285
|
}
|
|
211
286
|
|
|
212
287
|
function normalizeObjects(value, field) {
|
|
@@ -233,7 +308,7 @@ function normalizeOutputs(value, rootDir) {
|
|
|
233
308
|
return {
|
|
234
309
|
path: resolved.relative,
|
|
235
310
|
kind: nonEmpty(item.kind, `outputs[${index}].kind`),
|
|
236
|
-
digest:
|
|
311
|
+
digest: resolved.digest,
|
|
237
312
|
};
|
|
238
313
|
});
|
|
239
314
|
}
|
|
@@ -255,7 +330,7 @@ function sourceRefFile(rootDir, sourceRef, field) {
|
|
|
255
330
|
const relative = ref.slice(0, separator);
|
|
256
331
|
const anchor = ref.slice(separator + 1).trim().toLowerCase();
|
|
257
332
|
const resolved = safeProjectFile(rootDir, relative, field);
|
|
258
|
-
const content =
|
|
333
|
+
const content = resolved.text;
|
|
259
334
|
const anchors = new Set();
|
|
260
335
|
for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) anchors.add(markdownSlug(match[1]));
|
|
261
336
|
for (const match of content.matchAll(/<a\s+(?:name|id)=["']([^"']+)["'][^>]*>/gi)) {
|
|
@@ -268,7 +343,7 @@ function sourceRefFile(rootDir, sourceRef, field) {
|
|
|
268
343
|
}
|
|
269
344
|
return {
|
|
270
345
|
ref, relative, anchor,
|
|
271
|
-
digest:
|
|
346
|
+
digest: resolved.digest,
|
|
272
347
|
};
|
|
273
348
|
}
|
|
274
349
|
|
|
@@ -371,10 +446,15 @@ function validateSynthesisTrace(db, runRow, semanticRecords) {
|
|
|
371
446
|
}
|
|
372
447
|
}
|
|
373
448
|
|
|
374
|
-
function validateResearchStepReport(run, stepId, outputs, rootDir) {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
449
|
+
function validateResearchStepReport(db, run, stepId, outputs, rootDir) {
|
|
450
|
+
const change = run.change_id
|
|
451
|
+
? db.prepare('SELECT artifact_root FROM changes WHERE id = ?').get(run.change_id)
|
|
452
|
+
: null;
|
|
453
|
+
const expected = change
|
|
454
|
+
? path.join(
|
|
455
|
+
change.artifact_root, 'research', run.id, `${stepId}.md`,
|
|
456
|
+
)
|
|
457
|
+
: path.join('.ultra', 'docs', 'research', run.id, `${stepId}.md`);
|
|
378
458
|
const report = outputs.find((output) => path.normalize(output.path) === path.normalize(expected));
|
|
379
459
|
if (!report) {
|
|
380
460
|
throw new WorkflowStateError(
|
|
@@ -383,7 +463,7 @@ function validateResearchStepReport(run, stepId, outputs, rootDir) {
|
|
|
383
463
|
);
|
|
384
464
|
}
|
|
385
465
|
const resolved = safeProjectFile(rootDir, report.path, `${stepId}.research_report`);
|
|
386
|
-
const content =
|
|
466
|
+
const content = resolved.text;
|
|
387
467
|
const requiredSections = ['Evidence', 'Specification updates', 'Decisions and unknowns'];
|
|
388
468
|
const missing = requiredSections.filter(
|
|
389
469
|
(heading) => !new RegExp(`^##\\s+${heading}\\s*$`, 'im').test(content),
|
|
@@ -412,6 +492,56 @@ function validateResearchStepReport(run, stepId, outputs, rootDir) {
|
|
|
412
492
|
}
|
|
413
493
|
}
|
|
414
494
|
|
|
495
|
+
function registerChangeResearchOutputsInTx(db, run, stepId, outputs, rootDir) {
|
|
496
|
+
if (!run.change_id) return [];
|
|
497
|
+
const change = db.prepare(
|
|
498
|
+
'SELECT artifact_root FROM changes WHERE id = ?',
|
|
499
|
+
).get(run.change_id);
|
|
500
|
+
if (!change) {
|
|
501
|
+
throw new WorkflowStateError(
|
|
502
|
+
'WORKFLOW_CHANGE_NOT_FOUND',
|
|
503
|
+
`research change ${run.change_id} does not exist`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const prefix = artifactRegistry.normalizeRelativePath(change.artifact_root);
|
|
507
|
+
return outputs.map((output) => {
|
|
508
|
+
const relative = artifactRegistry.normalizeRelativePath(output.path);
|
|
509
|
+
if (relative !== prefix && !relative.startsWith(`${prefix}/`)) {
|
|
510
|
+
throw new WorkflowStateError(
|
|
511
|
+
'WORKFLOW_RESEARCH_REPORT_REQUIRED',
|
|
512
|
+
`Change research output must stay inside ${prefix}: ${relative}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const kind = relative.endsWith(`/research/${run.id}/${stepId}.md`)
|
|
516
|
+
? 'research_step_report'
|
|
517
|
+
: String(output.kind || 'research_semantic_artifact')
|
|
518
|
+
.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');
|
|
519
|
+
return artifactRegistry.recordArtifactInTx(db, {
|
|
520
|
+
id: `workflow-${run.id}-${stepId}-${crypto.createHash('sha256')
|
|
521
|
+
.update(relative).digest('hex').slice(0, 12)}`,
|
|
522
|
+
owner_type: 'workflow',
|
|
523
|
+
owner_id: run.id,
|
|
524
|
+
kind,
|
|
525
|
+
path: relative,
|
|
526
|
+
content_digest: output.digest,
|
|
527
|
+
source_refs: [
|
|
528
|
+
{ type: 'workflow', id: run.id, relation: 'produced_by' },
|
|
529
|
+
{ type: 'change', id: run.change_id, relation: 'researched_for' },
|
|
530
|
+
...(run.baseline_id
|
|
531
|
+
? [{ type: 'baseline', id: run.baseline_id, relation: 'deltas_from' }]
|
|
532
|
+
: []),
|
|
533
|
+
],
|
|
534
|
+
consumer_refs: [],
|
|
535
|
+
provenance: {
|
|
536
|
+
writer: 'workflow.step',
|
|
537
|
+
workflow_run_id: run.id,
|
|
538
|
+
step_id: stepId,
|
|
539
|
+
},
|
|
540
|
+
metadata: { terminal_role: true, semantic: true },
|
|
541
|
+
}, { rootDir }).artifact;
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
415
545
|
function rowToStep(row) {
|
|
416
546
|
if (!row) return null;
|
|
417
547
|
const value = {
|
|
@@ -466,6 +596,10 @@ function definitionForRun(run) {
|
|
|
466
596
|
|
|
467
597
|
function inspectRunHealth(run, steps, rootDir) {
|
|
468
598
|
const blockers = [...inspectOutputHealth(steps, rootDir).blockers];
|
|
599
|
+
const runMetadata = parseJson(run.metadata_json, 'workflow_runs.metadata_json', {});
|
|
600
|
+
if (runMetadata.authority_invalidation?.invalidated === true) {
|
|
601
|
+
blockers.push('WORKFLOW_AUTHORITY_STALE');
|
|
602
|
+
}
|
|
469
603
|
const definition = definitionForRun(run);
|
|
470
604
|
const expectedIds = new Set(definition.map((item) => item.id));
|
|
471
605
|
const actualIds = new Set(steps.map((item) => item.step_id));
|
|
@@ -544,8 +678,7 @@ function inspectOutputHealth(steps, rootDir) {
|
|
|
544
678
|
for (const output of latestByPath.values()) {
|
|
545
679
|
try {
|
|
546
680
|
const resolved = safeProjectFile(rootDir, output.path, 'output.path');
|
|
547
|
-
|
|
548
|
-
if (current !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
|
|
681
|
+
if (resolved.digest !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
|
|
549
682
|
} catch (error) {
|
|
550
683
|
blockers.push(error.code === 'WORKFLOW_OUTPUT_MISSING'
|
|
551
684
|
? `WORKFLOW_OUTPUT_MISSING:${output.path}`
|
|
@@ -720,6 +853,19 @@ function assertWorkflowAuthority(db, input, mode) {
|
|
|
720
853
|
);
|
|
721
854
|
}
|
|
722
855
|
};
|
|
856
|
+
const changeWideKinds = new Set(['research', 'change', 'plan', 'deliver']);
|
|
857
|
+
if ((input.kind === 'init' || changeWideKinds.has(input.kind)) && input.task_id) {
|
|
858
|
+
throw new WorkflowStateError(
|
|
859
|
+
'WORKFLOW_SCOPE_INVALID',
|
|
860
|
+
`${input.kind} workflow is not task-scoped and must not provide task_id`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (input.kind === 'init' && input.change_id) {
|
|
864
|
+
throw new WorkflowStateError(
|
|
865
|
+
'WORKFLOW_SCOPE_INVALID',
|
|
866
|
+
'init workflow is baseline-scoped and must not provide change_id',
|
|
867
|
+
);
|
|
868
|
+
}
|
|
723
869
|
if (input.kind !== 'research'
|
|
724
870
|
&& (input.mode !== undefined || input.selected_steps !== undefined || input.coverage !== undefined)) {
|
|
725
871
|
throw new WorkflowStateError(
|
|
@@ -807,11 +953,17 @@ function assertPlanWorkflowAuthority(db, input) {
|
|
|
807
953
|
change.research_disposition_json || '{}', 'changes.research_disposition_json', {},
|
|
808
954
|
);
|
|
809
955
|
if (['bounded', 'required'].includes(disposition.status)) {
|
|
810
|
-
const
|
|
956
|
+
const rows = db.prepare(
|
|
811
957
|
`SELECT id FROM workflow_runs
|
|
812
958
|
WHERE kind = 'research' AND change_id = ? AND mode = ? AND status = 'completed'
|
|
813
|
-
ORDER BY completed_at DESC, rowid DESC
|
|
814
|
-
).
|
|
959
|
+
ORDER BY completed_at DESC, rowid DESC`,
|
|
960
|
+
).all(change.id, disposition.mode);
|
|
961
|
+
const row = rows.find((candidate) => {
|
|
962
|
+
const run = readWorkflow(db, candidate.id, {
|
|
963
|
+
rootDir: input.root_dir || process.cwd(),
|
|
964
|
+
});
|
|
965
|
+
return isConsumableWorkflowAuthority(run);
|
|
966
|
+
});
|
|
815
967
|
const research = row ? readWorkflow(db, row.id, { rootDir: input.root_dir || process.cwd() }) : null;
|
|
816
968
|
const expectedSteps = new Set([
|
|
817
969
|
...(Array.isArray(disposition.selected_steps) ? disposition.selected_steps : []),
|
|
@@ -1044,6 +1196,188 @@ function startWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1044
1196
|
return readWorkflow(db, id, { rootDir });
|
|
1045
1197
|
}
|
|
1046
1198
|
|
|
1199
|
+
function reviseWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1200
|
+
const id = nonEmpty(input.id, 'id');
|
|
1201
|
+
const replacementId = nonEmpty(input.replacement_id, 'replacement_id');
|
|
1202
|
+
const reason = nonEmpty(input.reason, 'reason');
|
|
1203
|
+
const subject = nonEmpty(input.subject, 'subject');
|
|
1204
|
+
return ops.tx(db, () => {
|
|
1205
|
+
const current = readWorkflow(db, id, { rootDir });
|
|
1206
|
+
if (!current) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
1207
|
+
if (current.status !== 'completed') {
|
|
1208
|
+
throw new WorkflowStateError(
|
|
1209
|
+
'WORKFLOW_REVISION_SOURCE_NOT_ACCEPTED',
|
|
1210
|
+
`workflow ${id} must be completed before a revision can be proposed`,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
if (current.metadata.revision?.superseded_by) {
|
|
1214
|
+
throw new WorkflowStateError(
|
|
1215
|
+
'WORKFLOW_ALREADY_SUPERSEDED',
|
|
1216
|
+
`workflow ${id} was superseded by ${current.metadata.revision.superseded_by}`,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const generation = Number(current.metadata.revision?.generation || 1) + 1;
|
|
1220
|
+
const metadata = {
|
|
1221
|
+
...current.metadata,
|
|
1222
|
+
revision: {
|
|
1223
|
+
revision_of: current.id,
|
|
1224
|
+
generation,
|
|
1225
|
+
reason,
|
|
1226
|
+
candidate_created_at: nowIso(),
|
|
1227
|
+
authority_status: 'candidate',
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
delete metadata.authority_invalidation;
|
|
1231
|
+
const nextInput = {
|
|
1232
|
+
id: replacementId,
|
|
1233
|
+
kind: current.kind,
|
|
1234
|
+
mode: current.kind === 'research' ? current.mode : undefined,
|
|
1235
|
+
subject,
|
|
1236
|
+
baseline_id: current.baseline_id,
|
|
1237
|
+
change_id: current.change_id,
|
|
1238
|
+
task_id: current.task_id,
|
|
1239
|
+
metadata,
|
|
1240
|
+
...(current.kind === 'research'
|
|
1241
|
+
? { coverage: current.metadata.coverage }
|
|
1242
|
+
: {}),
|
|
1243
|
+
};
|
|
1244
|
+
const inserted = insertWorkflowInTx(db, nextInput, { rootDir });
|
|
1245
|
+
ops.appendEventInTx(db, {
|
|
1246
|
+
type: 'workflow_revision_started',
|
|
1247
|
+
task_id: current.task_id,
|
|
1248
|
+
change_id: current.change_id,
|
|
1249
|
+
payload: {
|
|
1250
|
+
workflow_id: inserted,
|
|
1251
|
+
revision_of: current.id,
|
|
1252
|
+
generation,
|
|
1253
|
+
reason,
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
return readWorkflow(db, inserted, { rootDir });
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function sameWorkflowAuthority(left, right) {
|
|
1261
|
+
return left.kind === right.kind
|
|
1262
|
+
&& (left.baseline_id || null) === (right.baseline_id || null)
|
|
1263
|
+
&& (left.change_id || null) === (right.change_id || null)
|
|
1264
|
+
&& (left.task_id || null) === (right.task_id || null);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function isConsumableWorkflowAuthority(run) {
|
|
1268
|
+
const metadata = run?.metadata || {};
|
|
1269
|
+
const authorityStatus = metadata.revision?.authority_status;
|
|
1270
|
+
return !metadata.revision?.superseded_by
|
|
1271
|
+
&& metadata.authority_invalidation?.invalidated !== true
|
|
1272
|
+
&& !['candidate', 'superseded'].includes(authorityStatus);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function supersedeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1276
|
+
const id = nonEmpty(input.id, 'id');
|
|
1277
|
+
const replacementId = nonEmpty(input.replacement_id, 'replacement_id');
|
|
1278
|
+
const reason = nonEmpty(input.reason, 'reason');
|
|
1279
|
+
if (id === replacementId) {
|
|
1280
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'a workflow cannot supersede itself');
|
|
1281
|
+
}
|
|
1282
|
+
return ops.tx(db, () => {
|
|
1283
|
+
const prior = readWorkflow(db, id, { rootDir });
|
|
1284
|
+
const replacement = readWorkflow(db, replacementId, { rootDir });
|
|
1285
|
+
if (!prior) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
1286
|
+
if (!replacement) {
|
|
1287
|
+
throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${replacementId} not found`);
|
|
1288
|
+
}
|
|
1289
|
+
if (prior.status !== 'completed' || replacement.status !== 'completed') {
|
|
1290
|
+
throw new WorkflowStateError(
|
|
1291
|
+
'WORKFLOW_SUPERSESSION_NOT_ACCEPTED',
|
|
1292
|
+
'both the prior workflow and its replacement must be completed',
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
if (!sameWorkflowAuthority(prior, replacement)) {
|
|
1296
|
+
throw new WorkflowStateError(
|
|
1297
|
+
'WORKFLOW_SUPERSESSION_AUTHORITY_MISMATCH',
|
|
1298
|
+
'replacement workflow kind and authority bindings must match the prior workflow',
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
if (replacement.metadata.revision?.revision_of !== prior.id) {
|
|
1302
|
+
throw new WorkflowStateError(
|
|
1303
|
+
'WORKFLOW_SUPERSESSION_AUTHORITY_MISMATCH',
|
|
1304
|
+
`replacement ${replacement.id} is not a revision of ${prior.id}`,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
if (replacement.artifact_health.status !== 'pass') {
|
|
1308
|
+
throw new WorkflowStateError(
|
|
1309
|
+
'WORKFLOW_OUTPUT_STALE',
|
|
1310
|
+
`replacement workflow ${replacement.id} is not current`,
|
|
1311
|
+
replacement.artifact_health,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
if (prior.metadata.revision?.superseded_by) {
|
|
1315
|
+
if (prior.metadata.revision.superseded_by === replacement.id) {
|
|
1316
|
+
return {
|
|
1317
|
+
superseded: prior,
|
|
1318
|
+
workflow: replacement,
|
|
1319
|
+
invalidated: [],
|
|
1320
|
+
idempotent: true,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
throw new WorkflowStateError(
|
|
1324
|
+
'WORKFLOW_ALREADY_SUPERSEDED',
|
|
1325
|
+
`workflow ${prior.id} was superseded by ${prior.metadata.revision.superseded_by}`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
const ts = nowIso();
|
|
1329
|
+
const priorMetadata = {
|
|
1330
|
+
...prior.metadata,
|
|
1331
|
+
revision: {
|
|
1332
|
+
...(prior.metadata.revision || { generation: 1 }),
|
|
1333
|
+
superseded_by: replacement.id,
|
|
1334
|
+
superseded_at: ts,
|
|
1335
|
+
supersession_reason: reason,
|
|
1336
|
+
authority_status: 'superseded',
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
const replacementMetadata = {
|
|
1340
|
+
...replacement.metadata,
|
|
1341
|
+
revision: {
|
|
1342
|
+
...replacement.metadata.revision,
|
|
1343
|
+
authority_status: 'current',
|
|
1344
|
+
accepted_at: ts,
|
|
1345
|
+
},
|
|
1346
|
+
};
|
|
1347
|
+
db.prepare(
|
|
1348
|
+
'UPDATE workflow_runs SET metadata_json = ?, updated_at = ? WHERE id = ?',
|
|
1349
|
+
).run(JSON.stringify(priorMetadata), ts, prior.id);
|
|
1350
|
+
db.prepare(
|
|
1351
|
+
'UPDATE workflow_runs SET metadata_json = ?, updated_at = ? WHERE id = ?',
|
|
1352
|
+
).run(JSON.stringify(replacementMetadata), ts, replacement.id);
|
|
1353
|
+
const invalidated = artifactRegistry.invalidateConsumersFromEndpointInTx(
|
|
1354
|
+
db,
|
|
1355
|
+
{ type: 'workflow', id: prior.id },
|
|
1356
|
+
{
|
|
1357
|
+
reason: 'workflow_superseded',
|
|
1358
|
+
exclude: [{ type: 'workflow', id: replacement.id }],
|
|
1359
|
+
},
|
|
1360
|
+
);
|
|
1361
|
+
ops.appendEventInTx(db, {
|
|
1362
|
+
type: 'workflow_superseded',
|
|
1363
|
+
task_id: prior.task_id,
|
|
1364
|
+
change_id: prior.change_id,
|
|
1365
|
+
payload: {
|
|
1366
|
+
workflow_id: prior.id,
|
|
1367
|
+
replacement_id: replacement.id,
|
|
1368
|
+
reason,
|
|
1369
|
+
invalidated,
|
|
1370
|
+
},
|
|
1371
|
+
});
|
|
1372
|
+
return {
|
|
1373
|
+
superseded: readWorkflow(db, prior.id, { rootDir }),
|
|
1374
|
+
workflow: readWorkflow(db, replacement.id, { rootDir }),
|
|
1375
|
+
invalidated,
|
|
1376
|
+
idempotent: false,
|
|
1377
|
+
};
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1047
1381
|
function allowedStepTransition(from, to, required) {
|
|
1048
1382
|
if (from === to) return true;
|
|
1049
1383
|
if (from === 'pending') return ['in_progress', 'completed', 'blocked'].includes(to)
|
|
@@ -1065,6 +1399,13 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1065
1399
|
if (['completed', 'cancelled'].includes(runRow.status)) {
|
|
1066
1400
|
throw new WorkflowStateError('WORKFLOW_NOT_MUTABLE', `workflow ${id} is ${runRow.status}`);
|
|
1067
1401
|
}
|
|
1402
|
+
const runMetadata = parseJson(runRow.metadata_json, 'workflow_runs.metadata_json', {});
|
|
1403
|
+
if (runMetadata.authority_invalidation?.invalidated === true
|
|
1404
|
+
|| runMetadata.revision?.superseded_by) {
|
|
1405
|
+
throw new WorkflowStateError(
|
|
1406
|
+
'WORKFLOW_NOT_MUTABLE', `workflow ${id} no longer owns current authority`,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1068
1409
|
const stepRow = db.prepare('SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?').get(id, stepId);
|
|
1069
1410
|
if (!stepRow) throw new WorkflowStateError('WORKFLOW_STEP_NOT_FOUND', `workflow step ${stepId} not found`);
|
|
1070
1411
|
if (!allowedStepTransition(stepRow.status, input.status, Boolean(stepRow.required))) {
|
|
@@ -1118,7 +1459,7 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1118
1459
|
throw new WorkflowStateError('WORKFLOW_OUTPUT_REQUIRED', `${stepId} requires a durable output`);
|
|
1119
1460
|
}
|
|
1120
1461
|
if (input.status === 'completed' && runRow.kind === 'research') {
|
|
1121
|
-
validateResearchStepReport(runRow, stepId, outputs, rootDir);
|
|
1462
|
+
validateResearchStepReport(db, runRow, stepId, outputs, rootDir);
|
|
1122
1463
|
if (semanticRecords.length === 0) {
|
|
1123
1464
|
throw new WorkflowStateError(
|
|
1124
1465
|
'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
|
|
@@ -1154,6 +1495,9 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1154
1495
|
JSON.stringify(semanticRecords), JSON.stringify(blockers),
|
|
1155
1496
|
startedAt, completedAt, ts, id, stepId,
|
|
1156
1497
|
);
|
|
1498
|
+
if (input.status === 'completed' && runRow.kind === 'research') {
|
|
1499
|
+
registerChangeResearchOutputsInTx(db, runRow, stepId, outputs, rootDir);
|
|
1500
|
+
}
|
|
1157
1501
|
|
|
1158
1502
|
const next = db.prepare(
|
|
1159
1503
|
`SELECT step_id FROM workflow_steps
|
|
@@ -1192,19 +1536,44 @@ function assertIsoTimestamp(value, field) {
|
|
|
1192
1536
|
}
|
|
1193
1537
|
}
|
|
1194
1538
|
|
|
1195
|
-
function readOutputJson(run, stepId, rootDir, schema) {
|
|
1539
|
+
function readOutputJson(db, run, stepId, rootDir, schema) {
|
|
1196
1540
|
const workflowStep = run.steps.find((item) => item.step_id === stepId);
|
|
1197
1541
|
const candidates = workflowStep?.outputs || [];
|
|
1542
|
+
const authorityKind = {
|
|
1543
|
+
'ultra-test-report-v1': 'test_report',
|
|
1544
|
+
'ultra-review-summary-v2': 'review_summary',
|
|
1545
|
+
'ultra-delivery-report-v1': 'delivery_report',
|
|
1546
|
+
}[schema];
|
|
1198
1547
|
for (const output of candidates) {
|
|
1548
|
+
if (authorityKind) {
|
|
1549
|
+
assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
|
|
1550
|
+
}
|
|
1199
1551
|
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
1200
1552
|
let value;
|
|
1201
|
-
try { value = JSON.parse(
|
|
1553
|
+
try { value = JSON.parse(resolved.text); }
|
|
1202
1554
|
catch (error) {
|
|
1203
1555
|
throw new WorkflowStateError(
|
|
1204
1556
|
'WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`,
|
|
1205
1557
|
);
|
|
1206
1558
|
}
|
|
1207
1559
|
if (value && typeof value === 'object' && !Array.isArray(value) && value.$schema === schema) {
|
|
1560
|
+
if (resolved.digest !== output.digest) {
|
|
1561
|
+
throw new WorkflowStateError(
|
|
1562
|
+
'WORKFLOW_REPORT_INVALID',
|
|
1563
|
+
`${output.path} no longer matches its workflow output digest`,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
if (authorityKind) {
|
|
1567
|
+
requireManagedArtifact(db, {
|
|
1568
|
+
owner_type: 'workflow',
|
|
1569
|
+
owner_id: run.id,
|
|
1570
|
+
kind: authorityKind,
|
|
1571
|
+
artifactPath: output.path,
|
|
1572
|
+
digest: resolved.digest,
|
|
1573
|
+
error_code: 'WORKFLOW_REPORT_INVALID',
|
|
1574
|
+
field: `${stepId}.output`,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1208
1577
|
return { value, output };
|
|
1209
1578
|
}
|
|
1210
1579
|
}
|
|
@@ -1215,6 +1584,7 @@ function readOutputJson(run, stepId, rootDir, schema) {
|
|
|
1215
1584
|
|
|
1216
1585
|
function contextStepForKind(kind) {
|
|
1217
1586
|
return {
|
|
1587
|
+
plan: 'compile-context',
|
|
1218
1588
|
change: 'compile-context',
|
|
1219
1589
|
dev: 'compile-context',
|
|
1220
1590
|
test: 'compile-context',
|
|
@@ -1225,6 +1595,7 @@ function contextStepForKind(kind) {
|
|
|
1225
1595
|
|
|
1226
1596
|
function contextExpectation(kind) {
|
|
1227
1597
|
return {
|
|
1598
|
+
plan: { roles: ['plan'], gates: ['planning'] },
|
|
1228
1599
|
change: { roles: ['plan', 'implement'], gates: ['planning', 'implementation'] },
|
|
1229
1600
|
dev: { roles: ['implement'], gates: ['implementation'] },
|
|
1230
1601
|
test: { roles: ['check'], gates: ['verification'] },
|
|
@@ -1238,14 +1609,16 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
|
|
|
1238
1609
|
if (!expectation) return null;
|
|
1239
1610
|
let candidate = null;
|
|
1240
1611
|
let manifest = null;
|
|
1612
|
+
let candidateDigest = null;
|
|
1241
1613
|
for (const output of outputs || []) {
|
|
1242
1614
|
const resolved = safeProjectFile(rootDir, output.path, 'context output');
|
|
1243
1615
|
let value;
|
|
1244
|
-
try { value = JSON.parse(
|
|
1616
|
+
try { value = JSON.parse(resolved.text); }
|
|
1245
1617
|
catch { continue; }
|
|
1246
1618
|
if (value?.schema_version === '3.0' && value?.snapshot_id) {
|
|
1247
1619
|
candidate = output;
|
|
1248
1620
|
manifest = value;
|
|
1621
|
+
candidateDigest = resolved.digest;
|
|
1249
1622
|
break;
|
|
1250
1623
|
}
|
|
1251
1624
|
}
|
|
@@ -1262,9 +1635,13 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
|
|
|
1262
1635
|
'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to the expected change, role, gate, or readiness`,
|
|
1263
1636
|
);
|
|
1264
1637
|
}
|
|
1265
|
-
|
|
1638
|
+
const expectedTaskId = run.task_id || null;
|
|
1639
|
+
if ((manifest.resume?.task_id || null) !== expectedTaskId) {
|
|
1266
1640
|
throw new WorkflowStateError(
|
|
1267
|
-
'WORKFLOW_CONTEXT_MISMATCH',
|
|
1641
|
+
'WORKFLOW_CONTEXT_MISMATCH',
|
|
1642
|
+
expectedTaskId
|
|
1643
|
+
? `${run.kind} context is not bound to task ${expectedTaskId}`
|
|
1644
|
+
: `${run.kind} change-wide context must not be task-scoped`,
|
|
1268
1645
|
);
|
|
1269
1646
|
}
|
|
1270
1647
|
const stored = db.prepare(
|
|
@@ -1272,23 +1649,52 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
|
|
|
1272
1649
|
FROM context_snapshots WHERE id = ?`,
|
|
1273
1650
|
).get(manifest.snapshot_id);
|
|
1274
1651
|
if (!stored || stored.manifest_path !== candidate.path || stored.manifest_hash !== candidate.digest
|
|
1652
|
+
|| candidateDigest !== candidate.digest
|
|
1275
1653
|
|| stored.change_id !== run.change_id || stored.role !== manifest.role
|
|
1276
1654
|
|| stored.gate !== manifest.gate || stored.readiness !== 'ready'
|
|
1277
|
-
|| (
|
|
1655
|
+
|| (stored.task_id || null) !== expectedTaskId) {
|
|
1278
1656
|
throw new WorkflowStateError(
|
|
1279
1657
|
'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH', 'context output does not match its authoritative snapshot row',
|
|
1280
1658
|
);
|
|
1281
1659
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1660
|
+
requireManagedArtifact(db, {
|
|
1661
|
+
owner_type: expectedTaskId ? 'task' : 'change',
|
|
1662
|
+
owner_id: expectedTaskId || run.change_id,
|
|
1663
|
+
kind: 'context_manifest',
|
|
1664
|
+
artifactPath: candidate.path,
|
|
1665
|
+
digest: candidateDigest,
|
|
1666
|
+
error_code: 'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH',
|
|
1667
|
+
field: 'context output',
|
|
1668
|
+
statuses: run.kind === 'deliver'
|
|
1669
|
+
&& db.prepare('SELECT status FROM changes WHERE id = ?').get(run.change_id)?.status === 'archived'
|
|
1670
|
+
? ['archived']
|
|
1671
|
+
: ['current'],
|
|
1672
|
+
});
|
|
1673
|
+
const validation = require('./context-spine.cjs').validateContextSnapshot(db, {
|
|
1674
|
+
change_id: run.change_id,
|
|
1675
|
+
task_id: expectedTaskId,
|
|
1676
|
+
role: manifest.role,
|
|
1677
|
+
gate: manifest.gate,
|
|
1678
|
+
}, {
|
|
1679
|
+
rootDir,
|
|
1680
|
+
require_current_checkout: current,
|
|
1681
|
+
});
|
|
1682
|
+
const blockers = [...validation.blockers];
|
|
1683
|
+
if (validation.snapshot?.id !== manifest.snapshot_id) {
|
|
1684
|
+
blockers.push('CONTEXT_SNAPSHOT_SUPERSEDED');
|
|
1290
1685
|
}
|
|
1291
|
-
|
|
1686
|
+
if (blockers.length > 0) {
|
|
1687
|
+
throw new WorkflowStateError(
|
|
1688
|
+
'WORKFLOW_CONTEXT_STALE',
|
|
1689
|
+
`${run.kind} context no longer matches current task, change, reference, or checkout authority`,
|
|
1690
|
+
{ blockers: [...new Set(blockers)] },
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
return {
|
|
1694
|
+
manifest,
|
|
1695
|
+
output: candidate,
|
|
1696
|
+
warnings: validation.warnings || [],
|
|
1697
|
+
};
|
|
1292
1698
|
}
|
|
1293
1699
|
|
|
1294
1700
|
function recordedContext(db, run, rootDir, options) {
|
|
@@ -1335,11 +1741,15 @@ function taskPlanDigest(task) {
|
|
|
1335
1741
|
}
|
|
1336
1742
|
|
|
1337
1743
|
function latestCompletedWorkflow(db, { kind, changeId, taskId = null }, rootDir) {
|
|
1338
|
-
const
|
|
1744
|
+
const rows = db.prepare(
|
|
1339
1745
|
`SELECT id FROM workflow_runs
|
|
1340
1746
|
WHERE kind = ? AND change_id = ? AND task_id IS ? AND status = 'completed'
|
|
1341
|
-
ORDER BY completed_at DESC, rowid DESC
|
|
1342
|
-
).
|
|
1747
|
+
ORDER BY completed_at DESC, rowid DESC`,
|
|
1748
|
+
).all(kind, changeId, taskId);
|
|
1749
|
+
const row = rows.find((candidate) => {
|
|
1750
|
+
const run = readWorkflow(db, candidate.id, { rootDir });
|
|
1751
|
+
return isConsumableWorkflowAuthority(run);
|
|
1752
|
+
});
|
|
1343
1753
|
return row ? readWorkflow(db, row.id, { rootDir }) : null;
|
|
1344
1754
|
}
|
|
1345
1755
|
|
|
@@ -1518,6 +1928,7 @@ function validateDevCompletion(db, run, rootDir) {
|
|
|
1518
1928
|
verification_command: task.verification_command,
|
|
1519
1929
|
context_path: context.output.path,
|
|
1520
1930
|
context_digest: context.output.digest,
|
|
1931
|
+
context_warnings: context.warnings,
|
|
1521
1932
|
git_commit: checkout.head,
|
|
1522
1933
|
worktree_digest: checkout.digest,
|
|
1523
1934
|
review_workflow_id: review.id,
|
|
@@ -1554,6 +1965,14 @@ function validateChangeCompletion(db, run, rootDir) {
|
|
|
1554
1965
|
function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
1555
1966
|
const tasks = assertDevelopmentComplete(db, run.change_id, rootDir);
|
|
1556
1967
|
const taskIds = tasks.map((task) => task.id).sort();
|
|
1968
|
+
const change = db.prepare(
|
|
1969
|
+
'SELECT id, kind, contract_json FROM changes WHERE id = ?',
|
|
1970
|
+
).get(run.change_id);
|
|
1971
|
+
if (!change) {
|
|
1972
|
+
throw new WorkflowStateError(
|
|
1973
|
+
'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id || '(missing)'} does not exist`,
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1557
1976
|
const test = latestCompletedWorkflow(
|
|
1558
1977
|
db, { kind: 'test', changeId: run.change_id, taskId: null }, rootDir,
|
|
1559
1978
|
);
|
|
@@ -1566,6 +1985,38 @@ function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
|
1566
1985
|
'WORKFLOW_GATE_STALE', `test gate for ${run.change_id} does not cover the current task set`,
|
|
1567
1986
|
);
|
|
1568
1987
|
}
|
|
1988
|
+
const contract = parseJson(change.contract_json, 'changes.contract_json', {});
|
|
1989
|
+
const acceptanceIds = (contract.acceptance || []).map((item) => item.id).sort();
|
|
1990
|
+
if (JSON.stringify([...(test.summary.acceptance_ids || [])].sort())
|
|
1991
|
+
!== JSON.stringify(acceptanceIds)) {
|
|
1992
|
+
throw new WorkflowStateError(
|
|
1993
|
+
'WORKFLOW_ACCEPTANCE_EVIDENCE_STALE',
|
|
1994
|
+
`test gate for ${run.change_id} does not cover the current accepted intent`,
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
if (['standard', 'major'].includes(change.kind)) {
|
|
1998
|
+
try {
|
|
1999
|
+
// Lazy loading avoids the baseline-workflow <-> workflow-state module
|
|
2000
|
+
// cycle while keeping packet validation on the live Deliver boundary.
|
|
2001
|
+
const changePacket = require('./change-packet.cjs');
|
|
2002
|
+
changePacket.deliveryEntries(db, {
|
|
2003
|
+
...change,
|
|
2004
|
+
contract,
|
|
2005
|
+
docs_impact: parseJson(
|
|
2006
|
+
db.prepare('SELECT docs_impact_json FROM changes WHERE id = ?').get(run.change_id)
|
|
2007
|
+
.docs_impact_json,
|
|
2008
|
+
'changes.docs_impact_json',
|
|
2009
|
+
{},
|
|
2010
|
+
),
|
|
2011
|
+
}, { rootDir });
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
throw new WorkflowStateError(
|
|
2014
|
+
error.code || 'CHANGE_PACKET_INVALID',
|
|
2015
|
+
`delivery packet for ${run.change_id} is not current: ${error.message}`,
|
|
2016
|
+
error.details,
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
1569
2020
|
const review = assertApprovedReview(db, run.change_id, null, rootDir);
|
|
1570
2021
|
if (JSON.stringify([...(review.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
|
|
1571
2022
|
throw new WorkflowStateError(
|
|
@@ -1578,6 +2029,7 @@ function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
|
1578
2029
|
review_workflow_id: review.id,
|
|
1579
2030
|
test_summary: test.summary,
|
|
1580
2031
|
review_summary: review.summary,
|
|
2032
|
+
acceptance_ids: acceptanceIds,
|
|
1581
2033
|
};
|
|
1582
2034
|
}
|
|
1583
2035
|
|
|
@@ -1657,7 +2109,7 @@ function validateTestCompletion(db, run, rootDir) {
|
|
|
1657
2109
|
assertDevelopmentComplete(db, run.change_id, rootDir, run.task_id);
|
|
1658
2110
|
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1659
2111
|
const { value: report, output } = readOutputJson(
|
|
1660
|
-
run, 'write-report', rootDir, 'ultra-test-report-v1',
|
|
2112
|
+
db, run, 'write-report', rootDir, 'ultra-test-report-v1',
|
|
1661
2113
|
);
|
|
1662
2114
|
assertReportSchema(report, 'test');
|
|
1663
2115
|
const verificationProfile = validateVerificationProfile(report);
|
|
@@ -1717,6 +2169,7 @@ function validateTestCompletion(db, run, rootDir) {
|
|
|
1717
2169
|
return {
|
|
1718
2170
|
change_id: run.change_id,
|
|
1719
2171
|
task_ids: report.task_ids,
|
|
2172
|
+
acceptance_ids: report.acceptance.map((item) => item.id).sort(),
|
|
1720
2173
|
passed: report.passed,
|
|
1721
2174
|
git_commit: checkout.head,
|
|
1722
2175
|
worktree_digest: checkout.digest,
|
|
@@ -1724,6 +2177,7 @@ function validateTestCompletion(db, run, rootDir) {
|
|
|
1724
2177
|
report_digest: output.digest,
|
|
1725
2178
|
context_path: context.output.path,
|
|
1726
2179
|
context_digest: context.output.digest,
|
|
2180
|
+
context_warnings: context.warnings,
|
|
1727
2181
|
regression_signal: report.regression_signal,
|
|
1728
2182
|
verification_profile: verificationProfile,
|
|
1729
2183
|
verification_dimensions: report.verification_dimensions,
|
|
@@ -1775,7 +2229,7 @@ function validateReviewFinding(finding, axis, seenIds, label) {
|
|
|
1775
2229
|
seenIds.add(finding.id);
|
|
1776
2230
|
}
|
|
1777
2231
|
|
|
1778
|
-
function readReviewSpecialists(run, rootDir) {
|
|
2232
|
+
function readReviewSpecialists(db, run, rootDir) {
|
|
1779
2233
|
const axes = [
|
|
1780
2234
|
['review-specification', 'spec_fidelity'],
|
|
1781
2235
|
['review-engineering', 'engineering_standards'],
|
|
@@ -1791,12 +2245,28 @@ function readReviewSpecialists(run, rootDir) {
|
|
|
1791
2245
|
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${stepId} has no specialist artifact`);
|
|
1792
2246
|
}
|
|
1793
2247
|
for (const output of outputs) {
|
|
2248
|
+
assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
|
|
1794
2249
|
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
1795
2250
|
let artifact;
|
|
1796
|
-
try { artifact = JSON.parse(
|
|
2251
|
+
try { artifact = JSON.parse(resolved.text); }
|
|
1797
2252
|
catch (error) {
|
|
1798
2253
|
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`);
|
|
1799
2254
|
}
|
|
2255
|
+
if (resolved.digest !== output.digest) {
|
|
2256
|
+
throw new WorkflowStateError(
|
|
2257
|
+
'WORKFLOW_REPORT_INVALID',
|
|
2258
|
+
`${output.path} no longer matches its workflow output digest`,
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
requireManagedArtifact(db, {
|
|
2262
|
+
owner_type: 'workflow',
|
|
2263
|
+
owner_id: run.id,
|
|
2264
|
+
kind: 'review_findings',
|
|
2265
|
+
artifactPath: output.path,
|
|
2266
|
+
digest: resolved.digest,
|
|
2267
|
+
error_code: 'WORKFLOW_REPORT_INVALID',
|
|
2268
|
+
field: `${stepId}.output`,
|
|
2269
|
+
});
|
|
1800
2270
|
if (artifact?.$schema !== 'ultra-review-findings-v2'
|
|
1801
2271
|
|| artifact.axis !== expectedAxis || artifact.status !== 'complete'
|
|
1802
2272
|
|| !String(artifact.agent || '').trim() || !String(artifact.session || '').trim()
|
|
@@ -1925,9 +2395,9 @@ function assertCoordinatedReviewEvidence(report, specialists) {
|
|
|
1925
2395
|
|
|
1926
2396
|
function validateReviewCompletion(db, run, rootDir) {
|
|
1927
2397
|
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1928
|
-
const specialists = readReviewSpecialists(run, rootDir);
|
|
2398
|
+
const specialists = readReviewSpecialists(db, run, rootDir);
|
|
1929
2399
|
const { value: report, output } = readOutputJson(
|
|
1930
|
-
run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
|
|
2400
|
+
db, run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
|
|
1931
2401
|
);
|
|
1932
2402
|
if (report.change_id !== run.change_id) {
|
|
1933
2403
|
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'review summary change_id is not bound to the workflow');
|
|
@@ -2000,6 +2470,7 @@ function validateReviewCompletion(db, run, rootDir) {
|
|
|
2000
2470
|
report_digest: output.digest,
|
|
2001
2471
|
context_path: context.output.path,
|
|
2002
2472
|
context_digest: context.output.digest,
|
|
2473
|
+
context_warnings: context.warnings,
|
|
2003
2474
|
};
|
|
2004
2475
|
}
|
|
2005
2476
|
|
|
@@ -2013,7 +2484,7 @@ function validateDeliveryCompletion(db, run, rootDir) {
|
|
|
2013
2484
|
);
|
|
2014
2485
|
}
|
|
2015
2486
|
const { value: report, output } = readOutputJson(
|
|
2016
|
-
run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
|
|
2487
|
+
db, run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
|
|
2017
2488
|
);
|
|
2018
2489
|
if (report.release !== undefined) {
|
|
2019
2490
|
throw new WorkflowStateError(
|
|
@@ -2058,6 +2529,7 @@ function validateDeliveryCompletion(db, run, rootDir) {
|
|
|
2058
2529
|
report_digest: output.digest,
|
|
2059
2530
|
context_path: context.output.path,
|
|
2060
2531
|
context_digest: context.output.digest,
|
|
2532
|
+
context_warnings: context.warnings,
|
|
2061
2533
|
...gates,
|
|
2062
2534
|
};
|
|
2063
2535
|
}
|
|
@@ -2073,6 +2545,7 @@ function validateStageCompletion(db, run, rootDir) {
|
|
|
2073
2545
|
}
|
|
2074
2546
|
|
|
2075
2547
|
function validatePlanArtifact(db, run, rootDir) {
|
|
2548
|
+
const context = recordedContext(db, run, rootDir, { current: false });
|
|
2076
2549
|
const workflowStep = run.steps.find((item) => item.step_id === 'verify-plan');
|
|
2077
2550
|
const outputs = workflowStep?.outputs || [];
|
|
2078
2551
|
if (outputs.length !== 1) {
|
|
@@ -2082,19 +2555,39 @@ function validatePlanArtifact(db, run, rootDir) {
|
|
|
2082
2555
|
);
|
|
2083
2556
|
}
|
|
2084
2557
|
const output = outputs[0];
|
|
2085
|
-
const
|
|
2558
|
+
const change = db.prepare('SELECT id, artifact_root FROM changes WHERE id = ?').get(run.change_id);
|
|
2559
|
+
const planStore = require('./plan-store.cjs');
|
|
2086
2560
|
let actual;
|
|
2087
|
-
try {
|
|
2561
|
+
try {
|
|
2562
|
+
actual = planStore.loadChangePlanArtifact(rootDir, change, { db, strict: true });
|
|
2563
|
+
}
|
|
2088
2564
|
catch (error) {
|
|
2089
2565
|
throw new WorkflowStateError(
|
|
2090
2566
|
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
2091
|
-
`${output.path} is not
|
|
2567
|
+
`${output.path} is not a current canonical Change plan: ${error.message}`,
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
const expectedPaths = planStore.changePlanPaths(rootDir, change);
|
|
2571
|
+
const expectedJsonRelative = path.relative(rootDir, expectedPaths.json).split(path.sep).join('/');
|
|
2572
|
+
if (output.path !== expectedJsonRelative) {
|
|
2573
|
+
throw new WorkflowStateError(
|
|
2574
|
+
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
2575
|
+
`verify-plan must use the owning Change artifact ${expectedJsonRelative}`,
|
|
2092
2576
|
);
|
|
2093
2577
|
}
|
|
2578
|
+
requireManagedArtifact(db, {
|
|
2579
|
+
owner_type: 'change',
|
|
2580
|
+
owner_id: run.change_id,
|
|
2581
|
+
kind: 'execution_plan',
|
|
2582
|
+
artifactPath: output.path,
|
|
2583
|
+
digest: output.digest,
|
|
2584
|
+
error_code: 'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
2585
|
+
field: 'verify-plan.output',
|
|
2586
|
+
});
|
|
2094
2587
|
const tasks = changeTasks(db, run.change_id);
|
|
2095
|
-
const planStore = require('./plan-store.cjs');
|
|
2096
2588
|
const expected = planStore.buildPlan(tasks, { changeId: run.change_id });
|
|
2097
|
-
|
|
2589
|
+
const { context: actualContext, ...actualPlan } = actual || {};
|
|
2590
|
+
if (JSON.stringify(actualPlan) !== JSON.stringify(expected)) {
|
|
2098
2591
|
throw new WorkflowStateError(
|
|
2099
2592
|
'WORKFLOW_PLAN_ARTIFACT_STALE',
|
|
2100
2593
|
'execution-plan artifact does not match the current change task graph and contracts',
|
|
@@ -2105,9 +2598,51 @@ function validatePlanArtifact(db, run, rootDir) {
|
|
|
2105
2598
|
},
|
|
2106
2599
|
);
|
|
2107
2600
|
}
|
|
2601
|
+
if (!actualContext
|
|
2602
|
+
|| actualContext.snapshot_id !== context.manifest.snapshot_id
|
|
2603
|
+
|| actualContext.manifest_path !== context.output.path
|
|
2604
|
+
|| actualContext.manifest_digest !== context.output.digest) {
|
|
2605
|
+
throw new WorkflowStateError(
|
|
2606
|
+
'WORKFLOW_PLAN_CONTEXT_MISMATCH',
|
|
2607
|
+
'execution-plan artifact is not bound to the compile-context output',
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
const expectedMdRelative = path.relative(rootDir, expectedPaths.md).split(path.sep).join('/');
|
|
2611
|
+
let planMd;
|
|
2612
|
+
try {
|
|
2613
|
+
planMd = safeProjectFile(rootDir, expectedMdRelative, 'verify-plan.markdown');
|
|
2614
|
+
requireManagedArtifact(db, {
|
|
2615
|
+
owner_type: 'change',
|
|
2616
|
+
owner_id: run.change_id,
|
|
2617
|
+
kind: 'execution_plan_markdown',
|
|
2618
|
+
artifactPath: expectedMdRelative,
|
|
2619
|
+
digest: planMd.digest,
|
|
2620
|
+
error_code: 'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
2621
|
+
field: 'verify-plan.markdown',
|
|
2622
|
+
});
|
|
2623
|
+
} catch (error) {
|
|
2624
|
+
if (error instanceof WorkflowStateError && error.code === 'WORKFLOW_PLAN_ARTIFACT_INVALID') {
|
|
2625
|
+
throw error;
|
|
2626
|
+
}
|
|
2627
|
+
throw new WorkflowStateError(
|
|
2628
|
+
'WORKFLOW_PLAN_ARTIFACT_STALE',
|
|
2629
|
+
`change-scoped plan.md is missing or unsafe: ${error.message}`,
|
|
2630
|
+
);
|
|
2631
|
+
}
|
|
2632
|
+
if (planMd.text !== planStore.renderPlanMd(actual, { tasks })) {
|
|
2633
|
+
throw new WorkflowStateError(
|
|
2634
|
+
'WORKFLOW_PLAN_ARTIFACT_STALE',
|
|
2635
|
+
'change-scoped plan.md is missing or does not match plan.json and current task contracts',
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2108
2638
|
return {
|
|
2109
2639
|
plan_path: output.path,
|
|
2110
2640
|
plan_digest: output.digest,
|
|
2641
|
+
plan_md_path: expectedMdRelative,
|
|
2642
|
+
plan_context_path: context.output.path,
|
|
2643
|
+
plan_context_digest: context.output.digest,
|
|
2644
|
+
context_warnings: context.warnings,
|
|
2645
|
+
plan_context_snapshot_id: context.manifest.snapshot_id,
|
|
2111
2646
|
plan_schema_version: actual.schema_version,
|
|
2112
2647
|
plan_wave_count: actual.waves.length,
|
|
2113
2648
|
};
|
|
@@ -2122,6 +2657,12 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
2122
2657
|
if (run.status !== 'ready') {
|
|
2123
2658
|
throw new WorkflowStateError('WORKFLOW_NOT_READY', `workflow ${id} is ${run.status}`);
|
|
2124
2659
|
}
|
|
2660
|
+
if (run.metadata.authority_invalidation?.invalidated === true
|
|
2661
|
+
|| run.metadata.revision?.superseded_by) {
|
|
2662
|
+
throw new WorkflowStateError(
|
|
2663
|
+
'WORKFLOW_NOT_MUTABLE', `workflow ${id} no longer owns current authority`,
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2125
2666
|
decisionDialogue.assertDecisionGate(db, {
|
|
2126
2667
|
workflow_run_id: run.id,
|
|
2127
2668
|
change_id: run.change_id,
|
|
@@ -2211,12 +2752,20 @@ function validatePlanApproval(approval) {
|
|
|
2211
2752
|
}
|
|
2212
2753
|
|
|
2213
2754
|
function completedResearchSemantics(db, changeId) {
|
|
2755
|
+
const runs = db.prepare(
|
|
2756
|
+
`SELECT id, metadata_json FROM workflow_runs
|
|
2757
|
+
WHERE kind = 'research' AND change_id = ? AND status = 'completed'
|
|
2758
|
+
ORDER BY completed_at DESC, rowid DESC`,
|
|
2759
|
+
).all(changeId).filter((row) => {
|
|
2760
|
+
const metadata = parseJson(row.metadata_json, 'workflow_runs.metadata_json', {});
|
|
2761
|
+
return isConsumableWorkflowAuthority({ metadata });
|
|
2762
|
+
});
|
|
2763
|
+
if (runs.length === 0) return [];
|
|
2764
|
+
const placeholders = runs.map(() => '?').join(', ');
|
|
2214
2765
|
return db.prepare(
|
|
2215
|
-
`SELECT
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
AND ws.required = 1 AND ws.status = 'completed'`,
|
|
2219
|
-
).all(changeId).flatMap((row) => (
|
|
2766
|
+
`SELECT semantic_records_json FROM workflow_steps
|
|
2767
|
+
WHERE run_id IN (${placeholders}) AND required = 1 AND status = 'completed'`,
|
|
2768
|
+
).all(...runs.map((row) => row.id)).flatMap((row) => (
|
|
2220
2769
|
parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
|
|
2221
2770
|
));
|
|
2222
2771
|
}
|
|
@@ -2402,6 +2951,8 @@ module.exports = {
|
|
|
2402
2951
|
readWorkflow,
|
|
2403
2952
|
listWorkflows,
|
|
2404
2953
|
recordWorkflowStep,
|
|
2954
|
+
reviseWorkflow,
|
|
2955
|
+
supersedeWorkflow,
|
|
2405
2956
|
completeWorkflow,
|
|
2406
2957
|
inspectWorkflowHealth,
|
|
2407
2958
|
recoverUntrackedChangeWorkflows,
|
|
@@ -2409,5 +2960,6 @@ module.exports = {
|
|
|
2409
2960
|
validatePlanContract,
|
|
2410
2961
|
assertCurrentPlan,
|
|
2411
2962
|
assertApprovedReview,
|
|
2963
|
+
isConsumableWorkflowAuthority,
|
|
2412
2964
|
resolveProjectSourceRef: sourceRefFile,
|
|
2413
2965
|
};
|