ultra-builder-pro-cli 0.22.0 → 0.24.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/.ultra-template/docs/research/README.md +4 -4
- package/CHANGELOG.md +83 -0
- package/README.md +68 -35
- package/adapters/_shared/codex-assets.cjs +121 -60
- package/adapters/_shared/interaction-contract.cjs +20 -7
- package/adapters/_shared/provenance.cjs +7 -2
- package/adapters/_shared/runtime-assets.cjs +4 -3
- package/adapters/_shared/runtime-native.cjs +132 -0
- package/adapters/claude.js +61 -31
- package/adapters/codex.js +178 -81
- package/adapters/grok.js +372 -0
- package/adapters/kimi.js +8 -4
- package/adapters/opencode.js +211 -33
- package/agents/code-reviewer.md +4 -2
- package/agents/debugger.md +4 -1
- package/agents/review-code.md +7 -3
- package/agents/review-comments.md +7 -4
- package/agents/review-coordinator.md +11 -7
- package/agents/review-design.md +8 -4
- package/agents/review-errors.md +8 -4
- package/agents/review-spec.md +8 -5
- package/agents/review-tests.md +7 -4
- package/agents/tdd-runner.md +5 -2
- package/bin/install.js +9 -4
- package/bin/orchestrator.js +4 -13
- package/docs/AGENT-CONTEXT.md +141 -231
- package/docs/ARCHITECTURE.md +203 -498
- package/docs/ARTIFACT-AUTHORITY.md +38 -35
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +12 -9
- package/docs/ROADMAP.md +85 -110
- package/docs/RUNTIME-COMPAT-MATRIX.md +139 -174
- package/docs/SKILL-AUTHORING.md +3 -3
- package/docs/STATE-DB-ACCESS-POLICY.md +25 -20
- package/docs/WORKFLOW-LIFECYCLE.md +174 -442
- package/hooks/README.md +11 -8
- package/hooks/active_task_context.py +16 -16
- package/hooks/adapters/grok.py +134 -0
- package/hooks/{context_spine.py → context_envelope.py} +26 -22
- package/hooks/health_check.py +19 -29
- package/hooks/pre_stop_check.py +22 -12
- package/hooks/subagent_tracker.py +1 -1
- package/hooks/workflow_checkpoint.py +14 -13
- package/hooks/workflow_context.py +11 -12
- package/hooks/workflow_resume.py +14 -13
- package/mcp-server/{breadcrumb.cjs → hook-context.cjs} +7 -9
- package/mcp-server/lib/artifact-registry.cjs +25 -1
- package/mcp-server/lib/baseline-workflow.cjs +36 -38
- package/mcp-server/lib/canonical-json.cjs +19 -0
- package/mcp-server/lib/change-workflow.cjs +637 -49
- package/mcp-server/lib/context-envelope.cjs +508 -0
- package/mcp-server/lib/decision-records.cjs +337 -0
- package/mcp-server/lib/doctor.cjs +91 -33
- package/mcp-server/lib/init-project.cjs +78 -39
- package/mcp-server/lib/managed-file-write.cjs +116 -0
- package/mcp-server/lib/plan-checkpoint.cjs +177 -0
- package/mcp-server/lib/project-context.cjs +178 -0
- package/mcp-server/lib/stage-checkpoints.cjs +309 -0
- package/mcp-server/lib/state-db.cjs +155 -24
- package/mcp-server/lib/task-ledger.cjs +522 -13
- package/mcp-server/lib/ultra-facade.cjs +834 -0
- package/mcp-server/lib/worker-packet.cjs +358 -0
- package/mcp-server/server.cjs +208 -1059
- package/orchestrator/daemon.cjs +27 -9
- package/orchestrator/parallel-orchestrator.cjs +111 -76
- package/orchestrator/session-runner.cjs +135 -34
- package/package.json +10 -3
- package/skills/ultra-change/SKILL.md +39 -98
- package/skills/ultra-deliver/SKILL.md +42 -65
- package/skills/ultra-dev/SKILL.md +52 -99
- package/skills/ultra-doctor/SKILL.md +35 -57
- package/skills/ultra-init/SKILL.md +48 -88
- package/skills/ultra-init/references/brownfield-adoption.md +11 -8
- package/skills/ultra-plan/SKILL.md +54 -132
- package/skills/ultra-research/SKILL.md +58 -112
- package/skills/ultra-review/SKILL.md +28 -46
- package/skills/ultra-review/references/unified-schema.md +2 -0
- package/skills/ultra-review/scripts/review_wait.py +8 -0
- package/skills/ultra-status/SKILL.md +22 -45
- package/skills/ultra-test/SKILL.md +27 -61
- package/skills/ultra-think/SKILL.md +29 -48
- package/skills/ultra-think/references/interaction-boundary.md +56 -0
- package/spec/cli-protocol.md +80 -187
- package/spec/mcp-tools.yaml +130 -2738
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +107 -4
- package/spec/schemas/{task-ledger.v1.schema.json → task-ledger.v2.schema.json} +70 -6
- package/ultra-tools/commands/status.cjs +369 -243
- package/mcp-server/lib/context-spine.cjs +0 -1208
- package/mcp-server/lib/decision-dialogue.cjs +0 -1128
- package/mcp-server/lib/project-breadcrumb.cjs +0 -173
- package/mcp-server/lib/spec-learning.cjs +0 -284
- package/mcp-server/lib/workflow-state.cjs +0 -2965
- package/skills/ultra-think/references/decision-dialogue.md +0 -153
|
@@ -1,2965 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('node:crypto');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { spawnSync } = require('node:child_process');
|
|
6
|
-
const Ajv = require('ajv/dist/2020');
|
|
7
|
-
const addFormats = require('ajv-formats');
|
|
8
|
-
|
|
9
|
-
const ops = require('./state-ops.cjs');
|
|
10
|
-
const decisionDialogue = require('./decision-dialogue.cjs');
|
|
11
|
-
const artifactRegistry = require('./artifact-registry.cjs');
|
|
12
|
-
const { readStableProjectFile } = require('./safe-project-file.cjs');
|
|
13
|
-
const testReportSchema = require('../../spec/schemas/test-report.v1.schema.json');
|
|
14
|
-
const deliveryReportSchema = require('../../spec/schemas/delivery-report.v1.schema.json');
|
|
15
|
-
|
|
16
|
-
const DEFINITION_VERSION = '2.0';
|
|
17
|
-
const RUN_KINDS = new Set(['init', 'research', 'plan', 'change', 'dev', 'test', 'review', 'deliver']);
|
|
18
|
-
const RUN_STATUSES = new Set(['active', 'blocked', 'ready', 'completed', 'cancelled']);
|
|
19
|
-
const STEP_STATUSES = new Set(['pending', 'in_progress', 'completed', 'skipped', 'blocked']);
|
|
20
|
-
|
|
21
|
-
const reportAjv = new Ajv({ allErrors: true, strict: false });
|
|
22
|
-
addFormats(reportAjv);
|
|
23
|
-
const REPORT_VALIDATORS = Object.freeze({
|
|
24
|
-
test: reportAjv.compile(testReportSchema),
|
|
25
|
-
delivery: reportAjv.compile(deliveryReportSchema),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function step(id, title, options = {}) {
|
|
29
|
-
return Object.freeze({ id, title, ...options });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
33
|
-
init: Object.freeze([
|
|
34
|
-
step('inspect-authority', 'Inspect repository and existing Ultra authority'),
|
|
35
|
-
step('classify-repository', 'Classify greenfield, brownfield, or migrated state'),
|
|
36
|
-
step('scaffold-authority', 'Create or resume the authoritative state and scaffold'),
|
|
37
|
-
step('verify-initialization', 'Verify authority, projections, and the initialization result', { evidence_required: true }),
|
|
38
|
-
]),
|
|
39
|
-
research: Object.freeze([
|
|
40
|
-
step('00-problem-validation', 'Validate the problem and demand evidence', { evidence_required: true, output_required: true }),
|
|
41
|
-
step('01-opportunity-discovery', 'Map outcomes and opportunity space', { evidence_required: true, output_required: true }),
|
|
42
|
-
step('02-market-assessment', 'Assess the relevant market and constraints', { evidence_required: true, output_required: true }),
|
|
43
|
-
step('03-competitive-landscape', 'Assess direct, adjacent, and substitute alternatives', { evidence_required: true, output_required: true }),
|
|
44
|
-
step('04-product-strategy', 'Define product strategy and explicit tradeoffs', { evidence_required: true, output_required: true }),
|
|
45
|
-
step('05-assumptions-validation', 'Identify assumptions and validation evidence', { evidence_required: true, output_required: true }),
|
|
46
|
-
step('10-user-personas', 'Describe evidence-backed actors and jobs', { evidence_required: true, output_required: true }),
|
|
47
|
-
step('11-user-scenarios', 'Trace current and desired user scenarios', { evidence_required: true, output_required: true }),
|
|
48
|
-
step('20-user-stories', 'Define behavior and acceptance contracts', { evidence_required: true, output_required: true }),
|
|
49
|
-
step('21-features-scope', 'Define scope, exclusions, and dependencies', { evidence_required: true, output_required: true }),
|
|
50
|
-
step('22-success-metrics', 'Define measurable outcomes and anti-metrics', { evidence_required: true, output_required: true }),
|
|
51
|
-
step('30-architecture-context', 'Record quality goals, constraints, and system context', { evidence_required: true, output_required: true }),
|
|
52
|
-
step('31-solution-strategy', 'Select solution strategy from accepted constraints', { evidence_required: true, output_required: true }),
|
|
53
|
-
step('32-building-blocks', 'Define building blocks and runtime paths', { evidence_required: true, output_required: true }),
|
|
54
|
-
step('40-deployment', 'Define environments, delivery, rollback, and operations', { evidence_required: true, output_required: true }),
|
|
55
|
-
step('41-quality-risks', 'Define quality scenarios, risks, and recovery', { evidence_required: true, output_required: true }),
|
|
56
|
-
step('99-synthesis', 'Validate traceability and compile planning context', { evidence_required: true, output_required: true }),
|
|
57
|
-
]),
|
|
58
|
-
plan: Object.freeze([
|
|
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
|
-
}),
|
|
63
|
-
step('analyze-requirements', 'Trace requirements and acceptance'),
|
|
64
|
-
step('analyze-codebase', 'Inspect current codebase patterns and boundaries', { evidence_required: true }),
|
|
65
|
-
step('design-slices', 'Design walking skeleton and vertical slices'),
|
|
66
|
-
step('validate-dependencies', 'Validate ownership, dependencies, and parallel waves', { evidence_required: true }),
|
|
67
|
-
step('persist-task-contracts', 'Persist tasks and complete execution contracts', { evidence_required: true }),
|
|
68
|
-
step('verify-plan', 'Read back and verify the authoritative plan', {
|
|
69
|
-
evidence_required: true, output_required: true,
|
|
70
|
-
}),
|
|
71
|
-
]),
|
|
72
|
-
change: Object.freeze([
|
|
73
|
-
step('bind-baseline', 'Bind current baseline and repository revision', { evidence_required: true }),
|
|
74
|
-
step('classify-change', 'Classify quick, standard, major, or incident'),
|
|
75
|
-
step('record-intent', 'Record intent, scope, acceptance, and documentation impact', { evidence_required: true }),
|
|
76
|
-
]),
|
|
77
|
-
dev: Object.freeze([
|
|
78
|
-
step('bind-task', 'Bind the authoritative task and dependencies', { evidence_required: true }),
|
|
79
|
-
step('compile-context', 'Compile fresh implementation context', { evidence_required: true, output_required: true }),
|
|
80
|
-
step('establish-feedback-loop', 'Establish the failing or characterization signal', { evidence_required: true }),
|
|
81
|
-
step('implement-slice', 'Implement the complete live-path slice'),
|
|
82
|
-
step('verify-slice', 'Run focused, adjacent, build, and public-seam checks', { evidence_required: true }),
|
|
83
|
-
step('review-slice', 'Run independent specification and engineering review', { evidence_required: true }),
|
|
84
|
-
step('record-completion', 'Record completion evidence and final task state', { evidence_required: true }),
|
|
85
|
-
]),
|
|
86
|
-
test: Object.freeze([
|
|
87
|
-
step('bind-scope', 'Bind completed tasks and current revision', { evidence_required: true }),
|
|
88
|
-
step('compile-context', 'Compile independent checking context', { evidence_required: true, output_required: true }),
|
|
89
|
-
step('map-acceptance', 'Map acceptance claims to executable checks'),
|
|
90
|
-
step('execute-checks', 'Execute the risk-selected verification profile', { evidence_required: true }),
|
|
91
|
-
step('verify-public-seam', 'Verify the declared public seam', { evidence_required: true }),
|
|
92
|
-
step('write-report', 'Write the current test evidence report', { evidence_required: true, output_required: true }),
|
|
93
|
-
step('verify-test-gate', 'Verify report freshness and expose valid transitions', { evidence_required: true }),
|
|
94
|
-
]),
|
|
95
|
-
review: Object.freeze([
|
|
96
|
-
step('bind-diff', 'Bind one current diff and acceptance scope', { evidence_required: true }),
|
|
97
|
-
step('compile-context', 'Compile independent review context', { evidence_required: true, output_required: true }),
|
|
98
|
-
step('review-specification', 'Run specification-fidelity review', { evidence_required: true, output_required: true }),
|
|
99
|
-
step('review-engineering', 'Run engineering-standards review', { evidence_required: true, output_required: true }),
|
|
100
|
-
step('coordinate-findings', 'Coordinate findings without collapsing axes', { evidence_required: true, output_required: true }),
|
|
101
|
-
step('verify-review-gate', 'Verify both verdict axes and current revision', { evidence_required: true }),
|
|
102
|
-
]),
|
|
103
|
-
deliver: Object.freeze([
|
|
104
|
-
step('bind-evidence', 'Bind current test, review, task, and revision evidence', { evidence_required: true }),
|
|
105
|
-
step('reconcile-specifications', 'Resolve learning and reconcile specifications', { evidence_required: true }),
|
|
106
|
-
step('verify-candidate', 'Verify the local delivery candidate and recovery path', { evidence_required: true, output_required: true }),
|
|
107
|
-
step('converge-authority', 'Converge baseline or change authority', { evidence_required: true }),
|
|
108
|
-
step('archive-change', 'Archive the converged change when applicable', { evidence_required: true }),
|
|
109
|
-
step('verify-delivery', 'Verify local delivery and recovery evidence', { evidence_required: true, output_required: true }),
|
|
110
|
-
]),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const RESEARCH_DISPOSITIONS = new Set([
|
|
114
|
-
'execute', 'verify_existing', 'reuse', 'not_applicable', 'deferred',
|
|
115
|
-
]);
|
|
116
|
-
|
|
117
|
-
const RESEARCH_MODE_NAMES = new Set([
|
|
118
|
-
'full', 'adoption', 'product', 'feature', 'architecture', 'custom',
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
|
-
const RESEARCH_SEMANTIC_CONTRACTS = Object.freeze({
|
|
122
|
-
'00-problem-validation': ['problem', ['actor', 'current_workaround', 'consequence', 'evidence_status']],
|
|
123
|
-
'01-opportunity-discovery': ['opportunity', ['actor', 'desired_outcome', 'evidence_status']],
|
|
124
|
-
'02-market-assessment': ['market_constraint', ['constraint', 'decision_impact', 'freshness']],
|
|
125
|
-
'03-competitive-landscape': ['alternative', ['category', 'switching_constraint', 'strategy_implication']],
|
|
126
|
-
'04-product-strategy': ['strategy_decision', ['tradeoff', 'rationale']],
|
|
127
|
-
'05-assumptions-validation': ['assumption', [
|
|
128
|
-
'category', 'consequence', 'validation_signal', 'success_rule', 'failure_rule', 'ambiguous_rule',
|
|
129
|
-
]],
|
|
130
|
-
'10-user-personas': ['actor', ['job', 'current_workflow', 'goal', 'constraint']],
|
|
131
|
-
'11-user-scenarios': ['scenario', [
|
|
132
|
-
'actor_id', 'trigger', 'preconditions', 'flow', 'success', 'failure', 'recovery',
|
|
133
|
-
]],
|
|
134
|
-
'20-user-stories': ['requirement', [
|
|
135
|
-
'preconditions', 'action', 'observable_result', 'error_recovery', 'verification',
|
|
136
|
-
]],
|
|
137
|
-
'21-features-scope': ['capability', ['requirement_ids', 'scope_status', 'rationale']],
|
|
138
|
-
'22-success-metrics': ['metric', ['definition', 'source', 'window', 'owner', 'decision_use']],
|
|
139
|
-
'30-architecture-context': ['architecture_context', [
|
|
140
|
-
'boundary', 'inputs_outputs', 'trust_authority', 'consumers',
|
|
141
|
-
]],
|
|
142
|
-
'31-solution-strategy': ['architecture_decision', [
|
|
143
|
-
'drivers', 'direction', 'consequences', 'compatibility', 'recovery',
|
|
144
|
-
]],
|
|
145
|
-
'32-building-blocks': ['runtime_path', [
|
|
146
|
-
'entry_point', 'state_side_effects', 'observable_result', 'failure_recovery', 'consumers',
|
|
147
|
-
]],
|
|
148
|
-
'40-deployment': ['deployment', [
|
|
149
|
-
'environment', 'entry_point', 'config_migration', 'observation', 'rollback_recovery',
|
|
150
|
-
]],
|
|
151
|
-
'41-quality-risks': ['risk', [
|
|
152
|
-
'trigger_condition', 'expected_response', 'measurement', 'mitigation', 'recovery', 'owner',
|
|
153
|
-
]],
|
|
154
|
-
'99-synthesis': ['synthesis_trace', [
|
|
155
|
-
'problem_id', 'scenario_id', 'requirement_ids', 'architecture_path_ids', 'verification_refs',
|
|
156
|
-
]],
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const SEMANTIC_STATUSES = new Set(['observed', 'verified', 'decided', 'accepted', 'unknown', 'not_applicable']);
|
|
160
|
-
const SEMANTIC_ID = /^[a-zA-Z0-9_-]+$/;
|
|
161
|
-
|
|
162
|
-
class WorkflowStateError extends Error {
|
|
163
|
-
constructor(code, message, details) {
|
|
164
|
-
super(message);
|
|
165
|
-
this.name = 'WorkflowStateError';
|
|
166
|
-
this.code = code;
|
|
167
|
-
if (details !== undefined) this.details = details;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function assertReportSchema(report, kind) {
|
|
172
|
-
const validate = REPORT_VALIDATORS[kind];
|
|
173
|
-
if (!validate(report)) {
|
|
174
|
-
throw new WorkflowStateError(
|
|
175
|
-
'WORKFLOW_REPORT_INVALID',
|
|
176
|
-
`${kind} report does not satisfy its published schema: ${reportAjv.errorsText(validate.errors)}`,
|
|
177
|
-
validate.errors,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function nowIso() {
|
|
183
|
-
return new Date().toISOString();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function parseJson(value, field, fallback) {
|
|
187
|
-
if (value == null || value === '') return fallback;
|
|
188
|
-
try { return JSON.parse(value); }
|
|
189
|
-
catch (error) {
|
|
190
|
-
throw new WorkflowStateError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function nonEmpty(value, field) {
|
|
195
|
-
const text = typeof value === 'string' ? value.trim() : '';
|
|
196
|
-
if (!text) throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
197
|
-
return text;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function safeProjectFile(rootDir, candidate, field) {
|
|
201
|
-
const relative = nonEmpty(candidate, field);
|
|
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
|
-
);
|
|
215
|
-
}
|
|
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
|
-
);
|
|
230
|
-
}
|
|
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
|
-
);
|
|
262
|
-
}
|
|
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;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function normalizeObjects(value, field) {
|
|
288
|
-
if (value === undefined) return [];
|
|
289
|
-
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
290
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be an array of objects`);
|
|
291
|
-
}
|
|
292
|
-
return JSON.parse(JSON.stringify(value));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function normalizeEvidence(value) {
|
|
296
|
-
const evidence = normalizeObjects(value, 'evidence');
|
|
297
|
-
return evidence.map((item, index) => ({
|
|
298
|
-
kind: nonEmpty(item.kind, `evidence[${index}].kind`),
|
|
299
|
-
ref: nonEmpty(item.ref, `evidence[${index}].ref`),
|
|
300
|
-
summary: nonEmpty(item.summary, `evidence[${index}].summary`),
|
|
301
|
-
}));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function normalizeOutputs(value, rootDir) {
|
|
305
|
-
const outputs = normalizeObjects(value, 'outputs');
|
|
306
|
-
return outputs.map((item, index) => {
|
|
307
|
-
const resolved = safeProjectFile(rootDir, item.path, `outputs[${index}].path`);
|
|
308
|
-
return {
|
|
309
|
-
path: resolved.relative,
|
|
310
|
-
kind: nonEmpty(item.kind, `outputs[${index}].kind`),
|
|
311
|
-
digest: resolved.digest,
|
|
312
|
-
};
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function markdownSlug(value) {
|
|
317
|
-
return String(value).trim().toLowerCase()
|
|
318
|
-
.replace(/<[^>]+>/g, '')
|
|
319
|
-
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
320
|
-
.replace(/\s+/g, '-')
|
|
321
|
-
.replace(/-+/g, '-');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function sourceRefFile(rootDir, sourceRef, field) {
|
|
325
|
-
const ref = nonEmpty(sourceRef, field);
|
|
326
|
-
const separator = ref.lastIndexOf('#');
|
|
327
|
-
if (separator <= 0 || separator === ref.length - 1) {
|
|
328
|
-
throw new WorkflowStateError('WORKFLOW_SEMANTIC_SOURCE_INVALID', `${field} must use path#anchor`);
|
|
329
|
-
}
|
|
330
|
-
const relative = ref.slice(0, separator);
|
|
331
|
-
const anchor = ref.slice(separator + 1).trim().toLowerCase();
|
|
332
|
-
const resolved = safeProjectFile(rootDir, relative, field);
|
|
333
|
-
const content = resolved.text;
|
|
334
|
-
const anchors = new Set();
|
|
335
|
-
for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) anchors.add(markdownSlug(match[1]));
|
|
336
|
-
for (const match of content.matchAll(/<a\s+(?:name|id)=["']([^"']+)["'][^>]*>/gi)) {
|
|
337
|
-
anchors.add(match[1].trim().toLowerCase());
|
|
338
|
-
}
|
|
339
|
-
if (!anchors.has(anchor)) {
|
|
340
|
-
throw new WorkflowStateError(
|
|
341
|
-
'WORKFLOW_SEMANTIC_SOURCE_INVALID', `${field} anchor does not exist: ${ref}`,
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
return {
|
|
345
|
-
ref, relative, anchor,
|
|
346
|
-
digest: resolved.digest,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function semanticValuePresent(value) {
|
|
351
|
-
if (typeof value === 'string') return value.trim().length > 0;
|
|
352
|
-
if (Array.isArray(value)) return value.length > 0 && value.every(semanticValuePresent);
|
|
353
|
-
if (typeof value === 'number') return Number.isFinite(value);
|
|
354
|
-
if (typeof value === 'boolean') return true;
|
|
355
|
-
return Boolean(value && typeof value === 'object' && Object.keys(value).length > 0);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function normalizeSemanticRecords(value, stepId, rootDir) {
|
|
359
|
-
const records = normalizeObjects(value, 'semantic_records');
|
|
360
|
-
const contract = RESEARCH_SEMANTIC_CONTRACTS[stepId];
|
|
361
|
-
if (!contract) return records;
|
|
362
|
-
if (records.length === 0) {
|
|
363
|
-
throw new WorkflowStateError(
|
|
364
|
-
'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
const [expectedKind, requiredAttributes] = contract;
|
|
368
|
-
const ids = new Set();
|
|
369
|
-
return records.map((record, index) => {
|
|
370
|
-
const label = `semantic_records[${index}]`;
|
|
371
|
-
const id = nonEmpty(record.id, `${label}.id`);
|
|
372
|
-
if (!SEMANTIC_ID.test(id) || ids.has(id)) {
|
|
373
|
-
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.id is invalid or duplicated`);
|
|
374
|
-
}
|
|
375
|
-
ids.add(id);
|
|
376
|
-
const kind = nonEmpty(record.kind, `${label}.kind`);
|
|
377
|
-
if (kind !== expectedKind) {
|
|
378
|
-
throw new WorkflowStateError(
|
|
379
|
-
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${stepId} requires semantic kind ${expectedKind}, got ${kind}`,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
const status = nonEmpty(record.status, `${label}.status`);
|
|
383
|
-
if (!SEMANTIC_STATUSES.has(status)) {
|
|
384
|
-
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.status is invalid`);
|
|
385
|
-
}
|
|
386
|
-
const attributes = record.attributes;
|
|
387
|
-
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
388
|
-
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.attributes must be an object`);
|
|
389
|
-
}
|
|
390
|
-
const required = status === 'not_applicable' ? ['rationale'] : requiredAttributes;
|
|
391
|
-
const missing = required.filter((field) => !semanticValuePresent(attributes[field]));
|
|
392
|
-
if (missing.length > 0) {
|
|
393
|
-
throw new WorkflowStateError(
|
|
394
|
-
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.attributes is missing: ${missing.join(', ')}`,
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
const evidenceRefs = record.evidence_refs;
|
|
398
|
-
if (!Array.isArray(evidenceRefs) || evidenceRefs.length === 0
|
|
399
|
-
|| evidenceRefs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
|
|
400
|
-
throw new WorkflowStateError(
|
|
401
|
-
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.evidence_refs must contain non-empty references`,
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
const links = record.links === undefined ? [] : record.links;
|
|
405
|
-
if (!Array.isArray(links) || links.some((link) => (
|
|
406
|
-
!link || typeof link !== 'object' || Array.isArray(link)
|
|
407
|
-
|| !String(link.relation || '').trim() || !String(link.target || '').trim()
|
|
408
|
-
))) {
|
|
409
|
-
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.links is invalid`);
|
|
410
|
-
}
|
|
411
|
-
const source = sourceRefFile(rootDir, record.source_ref, `${label}.source_ref`);
|
|
412
|
-
return {
|
|
413
|
-
id, kind, status,
|
|
414
|
-
summary: nonEmpty(record.summary, `${label}.summary`),
|
|
415
|
-
source_ref: source.ref,
|
|
416
|
-
source_digest: source.digest,
|
|
417
|
-
evidence_refs: [...new Set(evidenceRefs.map((ref) => ref.trim()))],
|
|
418
|
-
attributes: JSON.parse(JSON.stringify(attributes)),
|
|
419
|
-
links: links.map((link) => ({
|
|
420
|
-
relation: String(link.relation).trim(), target: String(link.target).trim(),
|
|
421
|
-
})),
|
|
422
|
-
};
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function validateSynthesisTrace(db, runRow, semanticRecords) {
|
|
427
|
-
if (runRow.kind !== 'research' || !['full', 'adoption'].includes(runRow.mode)) return;
|
|
428
|
-
const rows = db.prepare(
|
|
429
|
-
'SELECT semantic_records_json FROM workflow_steps WHERE run_id = ? AND step_id != ?',
|
|
430
|
-
).all(runRow.id, '99-synthesis');
|
|
431
|
-
const known = new Set(rows.flatMap((row) => (
|
|
432
|
-
parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
|
|
433
|
-
)).map((record) => record.id));
|
|
434
|
-
const trace = semanticRecords.find((record) => record.kind === 'synthesis_trace');
|
|
435
|
-
const refs = [
|
|
436
|
-
trace?.attributes?.problem_id,
|
|
437
|
-
trace?.attributes?.scenario_id,
|
|
438
|
-
...(trace?.attributes?.requirement_ids || []),
|
|
439
|
-
...(trace?.attributes?.architecture_path_ids || []),
|
|
440
|
-
];
|
|
441
|
-
const missing = refs.filter((ref) => !known.has(ref));
|
|
442
|
-
if (missing.length > 0) {
|
|
443
|
-
throw new WorkflowStateError(
|
|
444
|
-
'WORKFLOW_SYNTHESIS_TRACE_INVALID', `99-synthesis references missing semantic records: ${missing.join(', ')}`,
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
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`);
|
|
458
|
-
const report = outputs.find((output) => path.normalize(output.path) === path.normalize(expected));
|
|
459
|
-
if (!report) {
|
|
460
|
-
throw new WorkflowStateError(
|
|
461
|
-
'WORKFLOW_RESEARCH_REPORT_REQUIRED',
|
|
462
|
-
`${stepId} requires its immutable research report at ${expected}`,
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
const resolved = safeProjectFile(rootDir, report.path, `${stepId}.research_report`);
|
|
466
|
-
const content = resolved.text;
|
|
467
|
-
const requiredSections = ['Evidence', 'Specification updates', 'Decisions and unknowns'];
|
|
468
|
-
const missing = requiredSections.filter(
|
|
469
|
-
(heading) => !new RegExp(`^##\\s+${heading}\\s*$`, 'im').test(content),
|
|
470
|
-
);
|
|
471
|
-
if (!/^#\s+\S/m.test(content) || missing.length > 0) {
|
|
472
|
-
throw new WorkflowStateError(
|
|
473
|
-
'WORKFLOW_RESEARCH_REPORT_INVALID',
|
|
474
|
-
`${report.path} is missing a title or required sections: ${missing.join(', ')}`,
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
if (stepId === '99-synthesis' && ['full', 'adoption'].includes(run.mode)) {
|
|
478
|
-
const recorded = new Set(outputs.map((output) => path.normalize(output.path)));
|
|
479
|
-
const required = [
|
|
480
|
-
'.ultra/specs/discovery.md',
|
|
481
|
-
'.ultra/specs/product.md',
|
|
482
|
-
'.ultra/specs/architecture.md',
|
|
483
|
-
'.ultra/specs/research-distillate.md',
|
|
484
|
-
].map((item) => path.normalize(item));
|
|
485
|
-
const missingArtifacts = required.filter((item) => !recorded.has(item));
|
|
486
|
-
if (missingArtifacts.length > 0) {
|
|
487
|
-
throw new WorkflowStateError(
|
|
488
|
-
'WORKFLOW_RESEARCH_SYNTHESIS_INCOMPLETE',
|
|
489
|
-
`99-synthesis must bind the current baseline specifications and distillate: ${missingArtifacts.join(', ')}`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
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
|
-
|
|
545
|
-
function rowToStep(row) {
|
|
546
|
-
if (!row) return null;
|
|
547
|
-
const value = {
|
|
548
|
-
...row,
|
|
549
|
-
required: Boolean(row.required),
|
|
550
|
-
evidence: parseJson(row.evidence_json, 'workflow_steps.evidence_json', []),
|
|
551
|
-
outputs: parseJson(row.outputs_json, 'workflow_steps.outputs_json', []),
|
|
552
|
-
decisions: parseJson(row.decisions_json, 'workflow_steps.decisions_json', []),
|
|
553
|
-
semantic_records: parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', []),
|
|
554
|
-
blockers: parseJson(row.blockers_json, 'workflow_steps.blockers_json', []),
|
|
555
|
-
};
|
|
556
|
-
for (const field of [
|
|
557
|
-
'evidence_json', 'outputs_json', 'decisions_json', 'semantic_records_json', 'blockers_json',
|
|
558
|
-
]) {
|
|
559
|
-
delete value[field];
|
|
560
|
-
}
|
|
561
|
-
return value;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function decorateRun(db, row, rootDir) {
|
|
565
|
-
if (!row) return null;
|
|
566
|
-
const steps = db.prepare(
|
|
567
|
-
'SELECT * FROM workflow_steps WHERE run_id = ? ORDER BY position ASC',
|
|
568
|
-
).all(row.id).map(rowToStep);
|
|
569
|
-
const nextStep = steps.find((item) => item.required && !['completed', 'skipped'].includes(item.status)) || null;
|
|
570
|
-
const value = {
|
|
571
|
-
...row,
|
|
572
|
-
metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json', {}),
|
|
573
|
-
blockers: parseJson(row.blockers_json, 'workflow_runs.blockers_json', []),
|
|
574
|
-
approval: parseJson(row.approval_json, 'workflow_runs.approval_json', null),
|
|
575
|
-
summary: parseJson(row.summary_json, 'workflow_runs.summary_json', {}),
|
|
576
|
-
steps,
|
|
577
|
-
next_step: nextStep,
|
|
578
|
-
artifact_health: inspectRunHealth(row, steps, rootDir),
|
|
579
|
-
};
|
|
580
|
-
for (const field of ['metadata_json', 'blockers_json', 'approval_json', 'summary_json']) {
|
|
581
|
-
delete value[field];
|
|
582
|
-
}
|
|
583
|
-
return value;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function definitionForRun(run) {
|
|
587
|
-
const definition = WORKFLOW_DEFINITIONS[run.kind] || [];
|
|
588
|
-
if (run.kind !== 'research') return definition;
|
|
589
|
-
const metadata = parseJson(run.metadata_json, 'workflow_runs.metadata_json', {});
|
|
590
|
-
if (!Array.isArray(metadata.coverage) || metadata.coverage.length === 0) {
|
|
591
|
-
return definition;
|
|
592
|
-
}
|
|
593
|
-
const covered = new Set(metadata.coverage.map((item) => item?.step_id).filter(Boolean));
|
|
594
|
-
return definition.filter((item) => covered.has(item.id));
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function inspectRunHealth(run, steps, rootDir) {
|
|
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
|
-
}
|
|
603
|
-
const definition = definitionForRun(run);
|
|
604
|
-
const expectedIds = new Set(definition.map((item) => item.id));
|
|
605
|
-
const actualIds = new Set(steps.map((item) => item.step_id));
|
|
606
|
-
if (run.definition_version !== DEFINITION_VERSION) {
|
|
607
|
-
blockers.push(`WORKFLOW_DEFINITION_VERSION_STALE:${run.definition_version || 'missing'}`);
|
|
608
|
-
}
|
|
609
|
-
definition.forEach((item, position) => {
|
|
610
|
-
const actual = steps.find((candidate) => candidate.step_id === item.id);
|
|
611
|
-
if (!actual) blockers.push(`WORKFLOW_STEP_MISSING:${item.id}`);
|
|
612
|
-
else if (actual.position !== position) blockers.push(`WORKFLOW_STEP_POSITION_INVALID:${item.id}`);
|
|
613
|
-
});
|
|
614
|
-
for (const item of steps) {
|
|
615
|
-
if (!expectedIds.has(item.step_id)) blockers.push(`WORKFLOW_STEP_UNKNOWN:${item.step_id}`);
|
|
616
|
-
if (!item.required && item.status !== 'skipped') {
|
|
617
|
-
blockers.push(`WORKFLOW_OPTIONAL_STEP_STATE_INVALID:${item.step_id}`);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (run.status === 'completed') {
|
|
621
|
-
for (const item of steps.filter((candidate) => candidate.required)) {
|
|
622
|
-
if (item.status !== 'completed') blockers.push(`WORKFLOW_COMPLETED_STEP_INVALID:${item.step_id}`);
|
|
623
|
-
}
|
|
624
|
-
if (run.current_step) blockers.push('WORKFLOW_COMPLETED_CURRENT_STEP_PRESENT');
|
|
625
|
-
}
|
|
626
|
-
if (run.kind === 'research') {
|
|
627
|
-
for (const item of steps.filter((candidate) => candidate.required && candidate.status === 'completed')) {
|
|
628
|
-
if (!Array.isArray(item.semantic_records) || item.semantic_records.length === 0) {
|
|
629
|
-
blockers.push(`WORKFLOW_SEMANTIC_RECORDS_MISSING:${item.step_id}`);
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
for (const record of item.semantic_records) {
|
|
633
|
-
try {
|
|
634
|
-
const source = sourceRefFile(
|
|
635
|
-
rootDir, record.source_ref,
|
|
636
|
-
`workflow ${run.id} semantic record ${record.id || 'unknown'}`,
|
|
637
|
-
);
|
|
638
|
-
if (source.digest !== record.source_digest) {
|
|
639
|
-
blockers.push(`WORKFLOW_SEMANTIC_SOURCE_STALE:${item.step_id}:${record.id || 'unknown'}`);
|
|
640
|
-
}
|
|
641
|
-
} catch (_error) {
|
|
642
|
-
blockers.push(`WORKFLOW_SEMANTIC_SOURCE_INVALID:${item.step_id}:${record.id || 'unknown'}`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
if (run.status === 'ready') {
|
|
648
|
-
const incomplete = steps.filter(
|
|
649
|
-
(item) => item.required && item.status !== 'completed',
|
|
650
|
-
);
|
|
651
|
-
if (incomplete.length > 0) {
|
|
652
|
-
blockers.push(...incomplete.map((item) => `WORKFLOW_READY_STEP_INCOMPLETE:${item.step_id}`));
|
|
653
|
-
}
|
|
654
|
-
if (run.current_step) blockers.push('WORKFLOW_READY_CURRENT_STEP_PRESENT');
|
|
655
|
-
}
|
|
656
|
-
if (['active', 'blocked'].includes(run.status)) {
|
|
657
|
-
const firstOpen = steps.find(
|
|
658
|
-
(item) => item.required && !['completed', 'skipped'].includes(item.status),
|
|
659
|
-
);
|
|
660
|
-
if (!firstOpen) blockers.push('WORKFLOW_ACTIVE_WITHOUT_OPEN_STEP');
|
|
661
|
-
else if (run.current_step !== firstOpen.step_id) {
|
|
662
|
-
blockers.push(`WORKFLOW_CURRENT_STEP_INVALID:${run.current_step || 'missing'}`);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
if (actualIds.size !== steps.length) blockers.push('WORKFLOW_DUPLICATE_STEP_ID');
|
|
666
|
-
return {
|
|
667
|
-
status: blockers.length === 0 ? 'pass' : 'fail',
|
|
668
|
-
blockers: [...new Set(blockers)].sort(),
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function inspectOutputHealth(steps, rootDir) {
|
|
673
|
-
const blockers = [];
|
|
674
|
-
const latestByPath = new Map();
|
|
675
|
-
for (const workflowStep of steps) {
|
|
676
|
-
for (const output of workflowStep.outputs) latestByPath.set(output.path, output);
|
|
677
|
-
}
|
|
678
|
-
for (const output of latestByPath.values()) {
|
|
679
|
-
try {
|
|
680
|
-
const resolved = safeProjectFile(rootDir, output.path, 'output.path');
|
|
681
|
-
if (resolved.digest !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
|
|
682
|
-
} catch (error) {
|
|
683
|
-
blockers.push(error.code === 'WORKFLOW_OUTPUT_MISSING'
|
|
684
|
-
? `WORKFLOW_OUTPUT_MISSING:${output.path}`
|
|
685
|
-
: `WORKFLOW_OUTPUT_INVALID:${output.path}`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
return { status: blockers.length === 0 ? 'pass' : 'fail', blockers };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
function readWorkflow(db, id, { rootDir = process.cwd() } = {}) {
|
|
692
|
-
const row = db.prepare('SELECT * FROM workflow_runs WHERE id = ?').get(id);
|
|
693
|
-
return decorateRun(db, row, rootDir);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function listWorkflows(db, filter = {}, { rootDir = process.cwd() } = {}) {
|
|
697
|
-
const rows = db.prepare(
|
|
698
|
-
`SELECT * FROM workflow_runs
|
|
699
|
-
WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR status = ?)
|
|
700
|
-
AND (? IS NULL OR baseline_id = ?) AND (? IS NULL OR change_id = ?)
|
|
701
|
-
AND (? IS NULL OR task_id = ?)
|
|
702
|
-
ORDER BY updated_at DESC, rowid DESC LIMIT ?`,
|
|
703
|
-
).all(
|
|
704
|
-
filter.kind || null, filter.kind || null,
|
|
705
|
-
filter.status || null, filter.status || null,
|
|
706
|
-
filter.baseline_id || null, filter.baseline_id || null,
|
|
707
|
-
filter.change_id || null, filter.change_id || null,
|
|
708
|
-
filter.task_id || null, filter.task_id || null,
|
|
709
|
-
Math.min(Math.max(filter.limit || 100, 1), 500),
|
|
710
|
-
);
|
|
711
|
-
return rows.map((row) => decorateRun(db, row, rootDir));
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function normalizeResearchCoverage(mode, coverage, selectedSteps, metadata = {}) {
|
|
715
|
-
const knownSteps = WORKFLOW_DEFINITIONS.research.map((item) => item.id);
|
|
716
|
-
const known = new Set(knownSteps);
|
|
717
|
-
if (!RESEARCH_MODE_NAMES.has(mode)) {
|
|
718
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `unsupported research mode: ${mode}`);
|
|
719
|
-
}
|
|
720
|
-
if (coverage !== undefined) {
|
|
721
|
-
if (!Array.isArray(coverage) || coverage.length === 0) {
|
|
722
|
-
throw new WorkflowStateError(
|
|
723
|
-
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
724
|
-
'research coverage must be a non-empty array of semantic dispositions',
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
const seen = new Set();
|
|
728
|
-
const normalized = coverage.map((item) => {
|
|
729
|
-
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
730
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'research coverage entries must be objects');
|
|
731
|
-
}
|
|
732
|
-
const stepId = String(item.step_id || '').trim();
|
|
733
|
-
const disposition = String(item.disposition || '').trim();
|
|
734
|
-
const rationale = String(item.rationale || '').trim();
|
|
735
|
-
const evidenceRefs = Array.isArray(item.evidence_refs)
|
|
736
|
-
? [...new Set(item.evidence_refs.map((ref) => String(ref).trim()).filter(Boolean))]
|
|
737
|
-
: [];
|
|
738
|
-
if (!known.has(stepId)) {
|
|
739
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `unknown research step: ${stepId || '(missing)'}`);
|
|
740
|
-
}
|
|
741
|
-
if (seen.has(stepId)) {
|
|
742
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `duplicate research coverage: ${stepId}`);
|
|
743
|
-
}
|
|
744
|
-
if (!RESEARCH_DISPOSITIONS.has(disposition)) {
|
|
745
|
-
throw new WorkflowStateError(
|
|
746
|
-
'VALIDATION_ERROR',
|
|
747
|
-
`unsupported research disposition for ${stepId}: ${disposition || '(missing)'}`,
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
if (rationale.length < 3) {
|
|
751
|
-
throw new WorkflowStateError(
|
|
752
|
-
'VALIDATION_ERROR', `research coverage ${stepId} requires a rationale`,
|
|
753
|
-
);
|
|
754
|
-
}
|
|
755
|
-
if (['verify_existing', 'reuse', 'not_applicable'].includes(disposition)
|
|
756
|
-
&& evidenceRefs.length === 0) {
|
|
757
|
-
throw new WorkflowStateError(
|
|
758
|
-
'WORKFLOW_RESEARCH_COVERAGE_EVIDENCE_REQUIRED',
|
|
759
|
-
`${stepId} ${disposition} requires at least one evidence reference`,
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
const acceptedBy = item.accepted_by === undefined ? null : String(item.accepted_by).trim();
|
|
763
|
-
const consequence = item.consequence === undefined ? null : String(item.consequence).trim();
|
|
764
|
-
if (disposition === 'deferred' && (!consequence || consequence.length < 3)) {
|
|
765
|
-
throw new WorkflowStateError(
|
|
766
|
-
'VALIDATION_ERROR', `${stepId} deferred coverage requires a consequence`,
|
|
767
|
-
);
|
|
768
|
-
}
|
|
769
|
-
seen.add(stepId);
|
|
770
|
-
return {
|
|
771
|
-
step_id: stepId,
|
|
772
|
-
disposition,
|
|
773
|
-
rationale,
|
|
774
|
-
evidence_refs: evidenceRefs,
|
|
775
|
-
...(acceptedBy ? { accepted_by: acceptedBy } : {}),
|
|
776
|
-
...(consequence ? { consequence } : {}),
|
|
777
|
-
};
|
|
778
|
-
});
|
|
779
|
-
const selectionReason = String(metadata.selection_reason || '').trim();
|
|
780
|
-
if (selectionReason.length < 3) {
|
|
781
|
-
throw new WorkflowStateError(
|
|
782
|
-
'WORKFLOW_SELECTION_REASON_REQUIRED',
|
|
783
|
-
`${mode} research requires an evidence-based rationale for the accepted coverage`,
|
|
784
|
-
);
|
|
785
|
-
}
|
|
786
|
-
const synthesis = normalized.find((item) => item.step_id === '99-synthesis');
|
|
787
|
-
if (!synthesis || !['execute', 'verify_existing', 'reuse'].includes(synthesis.disposition)) {
|
|
788
|
-
throw new WorkflowStateError(
|
|
789
|
-
'WORKFLOW_RESEARCH_SYNTHESIS_REQUIRED',
|
|
790
|
-
`${mode} research requires an executable or reusable 99-synthesis disposition`,
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
const selectedSemanticArea = normalized.some((item) => (
|
|
794
|
-
item.step_id !== '99-synthesis'
|
|
795
|
-
&& (
|
|
796
|
-
['execute', 'verify_existing', 'reuse'].includes(item.disposition)
|
|
797
|
-
|| (item.disposition === 'deferred' && !item.accepted_by)
|
|
798
|
-
)
|
|
799
|
-
));
|
|
800
|
-
if (!selectedSemanticArea) {
|
|
801
|
-
throw new WorkflowStateError(
|
|
802
|
-
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
803
|
-
`${mode} research requires at least one applicable semantic area besides synthesis`,
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
return normalized.sort(
|
|
807
|
-
(left, right) => knownSteps.indexOf(left.step_id) - knownSteps.indexOf(right.step_id),
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if (mode === 'custom') {
|
|
812
|
-
if (!Array.isArray(selectedSteps) || selectedSteps.length === 0) {
|
|
813
|
-
throw new WorkflowStateError(
|
|
814
|
-
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
815
|
-
'custom research requires accepted coverage or selected_steps',
|
|
816
|
-
);
|
|
817
|
-
}
|
|
818
|
-
const invalid = selectedSteps.find((item) => !known.has(item));
|
|
819
|
-
if (invalid) throw new WorkflowStateError('VALIDATION_ERROR', `unknown research step: ${invalid}`);
|
|
820
|
-
const selectionReason = String(metadata.selection_reason || '').trim();
|
|
821
|
-
if (selectionReason.length < 3) {
|
|
822
|
-
throw new WorkflowStateError(
|
|
823
|
-
'WORKFLOW_SELECTION_REASON_REQUIRED',
|
|
824
|
-
'custom research requires an evidence-based rationale for the accepted coverage',
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
const explicitCoverage = [...new Set([...selectedSteps, '99-synthesis'])].map((stepId) => ({
|
|
828
|
-
step_id: stepId,
|
|
829
|
-
disposition: 'execute',
|
|
830
|
-
rationale: selectionReason,
|
|
831
|
-
evidence_refs: [],
|
|
832
|
-
}));
|
|
833
|
-
return normalizeResearchCoverage(mode, explicitCoverage, undefined, metadata);
|
|
834
|
-
}
|
|
835
|
-
throw new WorkflowStateError(
|
|
836
|
-
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
837
|
-
`${mode} research requires accepted coverage dispositions`,
|
|
838
|
-
);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
function assertReference(db, table, id, field) {
|
|
842
|
-
if (!id) return;
|
|
843
|
-
if (!db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id)) {
|
|
844
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `${field} does not exist: ${id}`);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function assertWorkflowAuthority(db, input, mode) {
|
|
849
|
-
const requireField = (field) => {
|
|
850
|
-
if (!input[field]) {
|
|
851
|
-
throw new WorkflowStateError(
|
|
852
|
-
'WORKFLOW_AUTHORITY_REQUIRED', `${input.kind} workflow requires ${field}`,
|
|
853
|
-
);
|
|
854
|
-
}
|
|
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
|
-
}
|
|
869
|
-
if (input.kind !== 'research'
|
|
870
|
-
&& (input.mode !== undefined || input.selected_steps !== undefined || input.coverage !== undefined)) {
|
|
871
|
-
throw new WorkflowStateError(
|
|
872
|
-
'VALIDATION_ERROR', 'mode, selected_steps, and coverage are valid only for research workflows',
|
|
873
|
-
);
|
|
874
|
-
}
|
|
875
|
-
if (input.kind === 'init') requireField('baseline_id');
|
|
876
|
-
if (input.kind === 'plan') {
|
|
877
|
-
requireField('change_id');
|
|
878
|
-
}
|
|
879
|
-
if (input.kind === 'dev') {
|
|
880
|
-
requireField('change_id');
|
|
881
|
-
requireField('task_id');
|
|
882
|
-
}
|
|
883
|
-
if (['change', 'test', 'review', 'deliver'].includes(input.kind)) requireField('change_id');
|
|
884
|
-
if (input.kind === 'research') {
|
|
885
|
-
if (!input.baseline_id && !input.change_id) {
|
|
886
|
-
throw new WorkflowStateError(
|
|
887
|
-
'WORKFLOW_AUTHORITY_REQUIRED', 'research workflow requires baseline_id or change_id',
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
if (['product', 'feature', 'architecture', 'custom'].includes(mode)) requireField('change_id');
|
|
891
|
-
if (input.baseline_id && !input.change_id) {
|
|
892
|
-
const baseline = db.prepare('SELECT mode FROM baselines WHERE id = ?').get(input.baseline_id);
|
|
893
|
-
const expectedMode = baseline?.mode === 'brownfield' ? 'adoption' : 'full';
|
|
894
|
-
if (mode !== expectedMode) {
|
|
895
|
-
throw new WorkflowStateError(
|
|
896
|
-
'WORKFLOW_RESEARCH_MODE_MISMATCH',
|
|
897
|
-
`${baseline?.mode || 'greenfield'} baseline research requires ${expectedMode} mode`,
|
|
898
|
-
);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function assertPlanWorkflowAuthority(db, input) {
|
|
905
|
-
const change = db.prepare(
|
|
906
|
-
`SELECT id, kind, status, baseline_bypass_json, contract_json,
|
|
907
|
-
research_disposition_json FROM changes WHERE id = ?`,
|
|
908
|
-
).get(input.change_id);
|
|
909
|
-
if (!change) return;
|
|
910
|
-
if (!['active', 'blocked'].includes(change.status)) {
|
|
911
|
-
throw new WorkflowStateError(
|
|
912
|
-
'WORKFLOW_CHANGE_NOT_MUTABLE', `plan change ${change.id} is ${change.status}`,
|
|
913
|
-
);
|
|
914
|
-
}
|
|
915
|
-
const linked = db.prepare(
|
|
916
|
-
`SELECT id, baseline_id FROM workflow_runs
|
|
917
|
-
WHERE kind = 'change' AND change_id = ?
|
|
918
|
-
ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
919
|
-
).get(change.id);
|
|
920
|
-
if (!linked) {
|
|
921
|
-
throw new WorkflowStateError(
|
|
922
|
-
'WORKFLOW_CHANGE_RUN_REQUIRED',
|
|
923
|
-
`change ${change.id} has no durable change workflow; run ultra-doctor repair before planning`,
|
|
924
|
-
);
|
|
925
|
-
}
|
|
926
|
-
const expectedBaseline = linked.baseline_id || null;
|
|
927
|
-
const suppliedBaseline = input.baseline_id || null;
|
|
928
|
-
if (suppliedBaseline !== expectedBaseline) {
|
|
929
|
-
throw new WorkflowStateError(
|
|
930
|
-
'WORKFLOW_BASELINE_MISMATCH',
|
|
931
|
-
`plan baseline ${suppliedBaseline || '(none)'} does not match change workflow ${linked.id} baseline ${expectedBaseline || '(none)'}`,
|
|
932
|
-
);
|
|
933
|
-
}
|
|
934
|
-
if (expectedBaseline === null) {
|
|
935
|
-
const bypass = parseJson(change.baseline_bypass_json, 'changes.baseline_bypass_json', null);
|
|
936
|
-
if (change.kind !== 'incident' || bypass?.mode !== 'incident_break_glass'
|
|
937
|
-
|| !String(bypass.reason || '').trim() || !String(bypass.approved_by || '').trim()) {
|
|
938
|
-
throw new WorkflowStateError(
|
|
939
|
-
'WORKFLOW_BASELINE_REQUIRED',
|
|
940
|
-
`ordinary plan ${change.id} requires the baseline bound by its change workflow`,
|
|
941
|
-
);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
const contract = parseJson(change.contract_json || '{}', 'changes.contract_json', {});
|
|
945
|
-
const blockingDecisions = (contract.unresolved_decisions || []).filter((item) => item?.blocking === true);
|
|
946
|
-
if (blockingDecisions.length > 0) {
|
|
947
|
-
throw new WorkflowStateError(
|
|
948
|
-
'WORKFLOW_CHANGE_DECISION_BLOCKING',
|
|
949
|
-
`change ${change.id} has unresolved blocking decisions: ${blockingDecisions.map((item) => item.id).join(', ')}`,
|
|
950
|
-
);
|
|
951
|
-
}
|
|
952
|
-
const disposition = parseJson(
|
|
953
|
-
change.research_disposition_json || '{}', 'changes.research_disposition_json', {},
|
|
954
|
-
);
|
|
955
|
-
if (['bounded', 'required'].includes(disposition.status)) {
|
|
956
|
-
const rows = db.prepare(
|
|
957
|
-
`SELECT id FROM workflow_runs
|
|
958
|
-
WHERE kind = 'research' AND change_id = ? AND mode = ? AND status = 'completed'
|
|
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
|
-
});
|
|
967
|
-
const research = row ? readWorkflow(db, row.id, { rootDir: input.root_dir || process.cwd() }) : null;
|
|
968
|
-
const expectedSteps = new Set([
|
|
969
|
-
...(Array.isArray(disposition.selected_steps) ? disposition.selected_steps : []),
|
|
970
|
-
'99-synthesis',
|
|
971
|
-
]);
|
|
972
|
-
const actualSteps = new Set(
|
|
973
|
-
(research?.steps || []).filter((item) => item.required).map((item) => item.step_id),
|
|
974
|
-
);
|
|
975
|
-
const selectionMatches = expectedSteps.size === actualSteps.size
|
|
976
|
-
&& [...expectedSteps].every((item) => actualSteps.has(item));
|
|
977
|
-
if (!research || research.artifact_health.status !== 'pass' || !selectionMatches) {
|
|
978
|
-
throw new WorkflowStateError(
|
|
979
|
-
'WORKFLOW_CHANGE_RESEARCH_INCOMPLETE',
|
|
980
|
-
`change ${change.id} requires completed current ${disposition.mode} research before planning`,
|
|
981
|
-
);
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const CHANGE_BOUND_STAGE_KINDS = new Set([
|
|
987
|
-
'research', 'plan', 'dev', 'test', 'review', 'deliver',
|
|
988
|
-
]);
|
|
989
|
-
|
|
990
|
-
function bindChangeWorkflowAuthority(db, input) {
|
|
991
|
-
if (!input.change_id || !CHANGE_BOUND_STAGE_KINDS.has(input.kind)) return input;
|
|
992
|
-
const change = db.prepare(
|
|
993
|
-
'SELECT id, kind, status, baseline_bypass_json FROM changes WHERE id = ?',
|
|
994
|
-
).get(input.change_id);
|
|
995
|
-
if (!change) {
|
|
996
|
-
throw new WorkflowStateError(
|
|
997
|
-
'WORKFLOW_CHANGE_NOT_FOUND', `workflow change does not exist: ${input.change_id}`,
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
const allowedStatuses = input.kind === 'deliver'
|
|
1001
|
-
? new Set(['active', 'blocked', 'ready'])
|
|
1002
|
-
: new Set(['active', 'blocked']);
|
|
1003
|
-
if (!allowedStatuses.has(change.status)) {
|
|
1004
|
-
throw new WorkflowStateError(
|
|
1005
|
-
'WORKFLOW_CHANGE_NOT_MUTABLE',
|
|
1006
|
-
`${input.kind} workflow cannot start while change ${change.id} is ${change.status}`,
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
const linked = db.prepare(
|
|
1010
|
-
`SELECT id, baseline_id FROM workflow_runs
|
|
1011
|
-
WHERE kind = 'change' AND change_id = ?
|
|
1012
|
-
ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
1013
|
-
).get(change.id);
|
|
1014
|
-
if (!linked) {
|
|
1015
|
-
throw new WorkflowStateError(
|
|
1016
|
-
'WORKFLOW_CHANGE_RUN_REQUIRED',
|
|
1017
|
-
`change ${change.id} has no durable change workflow; run ultra-doctor repair before ${input.kind}`,
|
|
1018
|
-
);
|
|
1019
|
-
}
|
|
1020
|
-
const expectedBaseline = linked.baseline_id || null;
|
|
1021
|
-
if (input.baseline_id !== undefined && (input.baseline_id || null) !== expectedBaseline) {
|
|
1022
|
-
throw new WorkflowStateError(
|
|
1023
|
-
'WORKFLOW_BASELINE_MISMATCH',
|
|
1024
|
-
`${input.kind} baseline ${input.baseline_id || '(none)'} does not match change workflow ${linked.id} baseline ${expectedBaseline || '(none)'}`,
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
if (expectedBaseline) {
|
|
1028
|
-
const baseline = db.prepare('SELECT mode, status FROM baselines WHERE id = ?').get(expectedBaseline);
|
|
1029
|
-
if (!baseline || baseline.status !== 'ready' || baseline.mode === 'migrated') {
|
|
1030
|
-
throw new WorkflowStateError(
|
|
1031
|
-
'WORKFLOW_BASELINE_NOT_READY',
|
|
1032
|
-
`${input.kind} workflow requires its bound baseline ${expectedBaseline} to remain approved and ready`,
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
|
-
} else {
|
|
1036
|
-
const bypass = parseJson(change.baseline_bypass_json, 'changes.baseline_bypass_json', null);
|
|
1037
|
-
if (change.kind !== 'incident' || bypass?.mode !== 'incident_break_glass'
|
|
1038
|
-
|| !String(bypass.reason || '').trim() || !String(bypass.approved_by || '').trim()) {
|
|
1039
|
-
throw new WorkflowStateError(
|
|
1040
|
-
'WORKFLOW_BASELINE_REQUIRED',
|
|
1041
|
-
`${input.kind} workflow requires a bound ready baseline or approved incident break-glass authority`,
|
|
1042
|
-
);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
return { ...input, baseline_id: expectedBaseline };
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function insertWorkflowInTx(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1049
|
-
if (!RUN_KINDS.has(input.kind)) {
|
|
1050
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `unsupported workflow kind: ${input.kind}`);
|
|
1051
|
-
}
|
|
1052
|
-
input = bindChangeWorkflowAuthority(db, input);
|
|
1053
|
-
const catalogDefinition = WORKFLOW_DEFINITIONS[input.kind];
|
|
1054
|
-
const mode = input.kind === 'research' ? (input.mode || 'full') : null;
|
|
1055
|
-
assertWorkflowAuthority(db, input, mode);
|
|
1056
|
-
const id = input.id || `wf-${input.kind}-${crypto.randomUUID().slice(0, 12)}`;
|
|
1057
|
-
const subject = nonEmpty(input.subject, 'subject');
|
|
1058
|
-
if (db.prepare('SELECT 1 FROM workflow_runs WHERE id = ?').get(id)) {
|
|
1059
|
-
throw new WorkflowStateError('DUPLICATE_WORKFLOW_ID', `workflow ${id} already exists`);
|
|
1060
|
-
}
|
|
1061
|
-
assertReference(db, 'baselines', input.baseline_id, 'baseline_id');
|
|
1062
|
-
assertReference(db, 'changes', input.change_id, 'change_id');
|
|
1063
|
-
assertReference(db, 'tasks', input.task_id, 'task_id');
|
|
1064
|
-
if (input.task_id && input.change_id) {
|
|
1065
|
-
const taskOwner = db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(input.task_id);
|
|
1066
|
-
if (taskOwner?.change_id !== input.change_id) {
|
|
1067
|
-
throw new WorkflowStateError(
|
|
1068
|
-
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
1069
|
-
`task ${input.task_id} belongs to ${taskOwner?.change_id || '(none)'}, not ${input.change_id}`,
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
if (input.kind === 'plan') assertPlanWorkflowAuthority(db, { ...input, root_dir: rootDir });
|
|
1074
|
-
if (input.kind === 'dev') {
|
|
1075
|
-
if (!input.task_id) {
|
|
1076
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'dev workflow requires task_id');
|
|
1077
|
-
}
|
|
1078
|
-
const task = ops.readTask(db, input.task_id);
|
|
1079
|
-
ops.assertTaskExecutionContract(task);
|
|
1080
|
-
if (!task.change_id) {
|
|
1081
|
-
throw new WorkflowStateError(
|
|
1082
|
-
'WORKFLOW_AUTHORITY_REQUIRED', `dev task ${task.id} is not owned by a change`,
|
|
1083
|
-
);
|
|
1084
|
-
}
|
|
1085
|
-
if (!['pending', 'in_progress', 'blocked'].includes(task.status) || task.stale) {
|
|
1086
|
-
throw new WorkflowStateError(
|
|
1087
|
-
'WORKFLOW_TASK_NOT_EXECUTABLE',
|
|
1088
|
-
`task ${task.id} is not executable from status ${task.status}${task.stale ? ' (stale)' : ''}`,
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
const incompleteDependencies = (task.deps || []).filter((id) => {
|
|
1092
|
-
const dependency = ops.readTask(db, id);
|
|
1093
|
-
return !dependency || !['completed', 'expanded'].includes(dependency.status);
|
|
1094
|
-
});
|
|
1095
|
-
if (incompleteDependencies.length > 0) {
|
|
1096
|
-
throw new WorkflowStateError(
|
|
1097
|
-
'TASK_DEPENDENCIES_INCOMPLETE',
|
|
1098
|
-
`task ${task.id} has incomplete dependencies: ${incompleteDependencies.join(', ')}`,
|
|
1099
|
-
);
|
|
1100
|
-
}
|
|
1101
|
-
if (input.change_id && task.change_id !== input.change_id) {
|
|
1102
|
-
throw new WorkflowStateError(
|
|
1103
|
-
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
1104
|
-
`task ${task.id} belongs to ${task.change_id || '(none)'}, not ${input.change_id}`,
|
|
1105
|
-
);
|
|
1106
|
-
}
|
|
1107
|
-
assertCurrentPlan(db, task.change_id, rootDir);
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const active = db.prepare(
|
|
1111
|
-
`SELECT id FROM workflow_runs
|
|
1112
|
-
WHERE kind = ? AND status IN ('active', 'blocked', 'ready')
|
|
1113
|
-
AND baseline_id IS ? AND change_id IS ? AND task_id IS ? LIMIT 1`,
|
|
1114
|
-
).get(input.kind, input.baseline_id || null, input.change_id || null, input.task_id || null);
|
|
1115
|
-
if (active) {
|
|
1116
|
-
throw new WorkflowStateError('WORKFLOW_IN_PROGRESS', `workflow ${active.id} is already active`);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
const researchCoverage = input.kind === 'research'
|
|
1120
|
-
? normalizeResearchCoverage(mode, input.coverage, input.selected_steps, input.metadata)
|
|
1121
|
-
: null;
|
|
1122
|
-
const coverageByStep = new Map((researchCoverage || []).map((item) => [item.step_id, item]));
|
|
1123
|
-
const definition = input.kind === 'research'
|
|
1124
|
-
? catalogDefinition.filter((item) => coverageByStep.has(item.id))
|
|
1125
|
-
: catalogDefinition;
|
|
1126
|
-
const selected = new Set(input.kind === 'research'
|
|
1127
|
-
? researchCoverage
|
|
1128
|
-
.filter((item) => ['execute', 'verify_existing', 'reuse'].includes(item.disposition)
|
|
1129
|
-
|| (item.disposition === 'deferred' && !item.accepted_by))
|
|
1130
|
-
.map((item) => item.step_id)
|
|
1131
|
-
: definition.map((item) => item.id));
|
|
1132
|
-
let metadata = input.kind === 'research'
|
|
1133
|
-
? {
|
|
1134
|
-
...(input.metadata || {}),
|
|
1135
|
-
selected_steps: [...selected],
|
|
1136
|
-
coverage: researchCoverage,
|
|
1137
|
-
}
|
|
1138
|
-
: (input.metadata || {});
|
|
1139
|
-
if (input.kind === 'plan') {
|
|
1140
|
-
const profile = db.prepare('SELECT kind FROM changes WHERE id = ?').get(input.change_id)?.kind;
|
|
1141
|
-
metadata = { ...metadata, profile };
|
|
1142
|
-
}
|
|
1143
|
-
const ts = nowIso();
|
|
1144
|
-
const currentStep = definition.find((item) => selected.has(item.id))?.id || null;
|
|
1145
|
-
db.prepare(
|
|
1146
|
-
`INSERT INTO workflow_runs
|
|
1147
|
-
(id, kind, mode, subject, definition_version, status, current_step, baseline_id,
|
|
1148
|
-
change_id, task_id, metadata_json, blockers_json, summary_json, started_at, updated_at)
|
|
1149
|
-
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, '[]', '{}', ?, ?)`,
|
|
1150
|
-
).run(
|
|
1151
|
-
id, input.kind, mode, subject, DEFINITION_VERSION, currentStep,
|
|
1152
|
-
input.baseline_id || null, input.change_id || null, input.task_id || null,
|
|
1153
|
-
JSON.stringify(metadata), ts, ts,
|
|
1154
|
-
);
|
|
1155
|
-
const insertStep = db.prepare(
|
|
1156
|
-
`INSERT INTO workflow_steps
|
|
1157
|
-
(run_id, step_id, position, title, required, status, skip_reason, updated_at)
|
|
1158
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1159
|
-
);
|
|
1160
|
-
definition.forEach((item, position) => {
|
|
1161
|
-
const required = selected.has(item.id);
|
|
1162
|
-
const coverage = coverageByStep.get(item.id);
|
|
1163
|
-
const disposition = coverage?.disposition || null;
|
|
1164
|
-
const deferred = disposition === 'deferred' && !coverage.accepted_by;
|
|
1165
|
-
const skippedReason = coverage && !required
|
|
1166
|
-
? `${disposition}: ${coverage.rationale}${coverage.accepted_by ? ` (accepted by ${coverage.accepted_by})` : ''}`
|
|
1167
|
-
: (required ? null : `Excluded by ${mode} mode.`);
|
|
1168
|
-
insertStep.run(
|
|
1169
|
-
id, item.id, position, item.title, required ? 1 : 0,
|
|
1170
|
-
deferred ? 'blocked' : (required ? 'pending' : 'skipped'), skippedReason, ts,
|
|
1171
|
-
);
|
|
1172
|
-
});
|
|
1173
|
-
if (researchCoverage?.some((item) => item.disposition === 'deferred' && !item.accepted_by)) {
|
|
1174
|
-
const blockers = researchCoverage
|
|
1175
|
-
.filter((item) => item.disposition === 'deferred' && !item.accepted_by)
|
|
1176
|
-
.map((item) => `RESEARCH_DEFERRED:${item.step_id}`);
|
|
1177
|
-
db.prepare(
|
|
1178
|
-
"UPDATE workflow_runs SET status = 'blocked', blockers_json = ? WHERE id = ?",
|
|
1179
|
-
).run(JSON.stringify(blockers), id);
|
|
1180
|
-
for (const blocker of blockers) {
|
|
1181
|
-
const stepId = blocker.split(':')[1];
|
|
1182
|
-
db.prepare(
|
|
1183
|
-
'UPDATE workflow_steps SET blockers_json = ? WHERE run_id = ? AND step_id = ?',
|
|
1184
|
-
).run(JSON.stringify([blocker]), id, stepId);
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
ops.appendEventInTx(db, {
|
|
1188
|
-
type: 'workflow_started', task_id: input.task_id, change_id: input.change_id,
|
|
1189
|
-
payload: { workflow_id: id, kind: input.kind, mode, baseline_id: input.baseline_id || null },
|
|
1190
|
-
});
|
|
1191
|
-
return id;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
function startWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1195
|
-
const id = ops.tx(db, () => insertWorkflowInTx(db, input, { rootDir }));
|
|
1196
|
-
return readWorkflow(db, id, { rootDir });
|
|
1197
|
-
}
|
|
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
|
-
|
|
1381
|
-
function allowedStepTransition(from, to, required) {
|
|
1382
|
-
if (from === to) return true;
|
|
1383
|
-
if (from === 'pending') return ['in_progress', 'completed', 'blocked'].includes(to)
|
|
1384
|
-
|| (!required && to === 'skipped');
|
|
1385
|
-
if (from === 'in_progress') return ['completed', 'blocked'].includes(to);
|
|
1386
|
-
if (from === 'blocked') return ['in_progress', 'completed'].includes(to);
|
|
1387
|
-
return false;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1391
|
-
const id = nonEmpty(input.id, 'id');
|
|
1392
|
-
const stepId = nonEmpty(input.step_id, 'step_id');
|
|
1393
|
-
if (!STEP_STATUSES.has(input.status) || input.status === 'pending') {
|
|
1394
|
-
throw new WorkflowStateError('VALIDATION_ERROR', `unsupported step status: ${input.status}`);
|
|
1395
|
-
}
|
|
1396
|
-
return ops.tx(db, () => {
|
|
1397
|
-
const runRow = db.prepare('SELECT * FROM workflow_runs WHERE id = ?').get(id);
|
|
1398
|
-
if (!runRow) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
1399
|
-
if (['completed', 'cancelled'].includes(runRow.status)) {
|
|
1400
|
-
throw new WorkflowStateError('WORKFLOW_NOT_MUTABLE', `workflow ${id} is ${runRow.status}`);
|
|
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
|
-
}
|
|
1409
|
-
const stepRow = db.prepare('SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?').get(id, stepId);
|
|
1410
|
-
if (!stepRow) throw new WorkflowStateError('WORKFLOW_STEP_NOT_FOUND', `workflow step ${stepId} not found`);
|
|
1411
|
-
if (!allowedStepTransition(stepRow.status, input.status, Boolean(stepRow.required))) {
|
|
1412
|
-
throw new WorkflowStateError(
|
|
1413
|
-
'ILLEGAL_WORKFLOW_STEP_TRANSITION',
|
|
1414
|
-
`cannot transition ${id}/${stepId} from ${stepRow.status} to ${input.status}`,
|
|
1415
|
-
);
|
|
1416
|
-
}
|
|
1417
|
-
if (input.status === 'completed') {
|
|
1418
|
-
decisionDialogue.assertDecisionGate(db, {
|
|
1419
|
-
workflow_run_id: runRow.id,
|
|
1420
|
-
change_id: runRow.change_id,
|
|
1421
|
-
baseline_id: runRow.baseline_id,
|
|
1422
|
-
}, { rootDir });
|
|
1423
|
-
}
|
|
1424
|
-
const firstOpen = db.prepare(
|
|
1425
|
-
`SELECT step_id FROM workflow_steps
|
|
1426
|
-
WHERE run_id = ? AND required = 1 AND status NOT IN ('completed', 'skipped')
|
|
1427
|
-
ORDER BY position ASC LIMIT 1`,
|
|
1428
|
-
).get(id);
|
|
1429
|
-
if (firstOpen && firstOpen.step_id !== stepId) {
|
|
1430
|
-
throw new WorkflowStateError(
|
|
1431
|
-
'WORKFLOW_STEP_OUT_OF_ORDER',
|
|
1432
|
-
`complete ${firstOpen.step_id} before ${stepId}`,
|
|
1433
|
-
);
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
const definition = WORKFLOW_DEFINITIONS[runRow.kind].find((item) => item.id === stepId);
|
|
1437
|
-
const evidence = input.evidence === undefined
|
|
1438
|
-
? parseJson(stepRow.evidence_json, 'evidence_json', [])
|
|
1439
|
-
: normalizeEvidence(input.evidence);
|
|
1440
|
-
const outputs = input.outputs === undefined
|
|
1441
|
-
? parseJson(stepRow.outputs_json, 'outputs_json', [])
|
|
1442
|
-
: normalizeOutputs(input.outputs, rootDir);
|
|
1443
|
-
const decisions = input.decisions === undefined
|
|
1444
|
-
? parseJson(stepRow.decisions_json, 'decisions_json', [])
|
|
1445
|
-
: normalizeObjects(input.decisions, 'decisions');
|
|
1446
|
-
const semanticRecords = input.semantic_records === undefined
|
|
1447
|
-
? parseJson(stepRow.semantic_records_json, 'semantic_records_json', [])
|
|
1448
|
-
: (runRow.kind === 'research'
|
|
1449
|
-
? normalizeSemanticRecords(input.semantic_records, stepId, rootDir)
|
|
1450
|
-
: normalizeObjects(input.semantic_records, 'semantic_records'));
|
|
1451
|
-
const blockers = input.blockers === undefined ? [] : input.blockers;
|
|
1452
|
-
if (!Array.isArray(blockers) || blockers.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
1453
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'blockers must be an array of non-empty strings');
|
|
1454
|
-
}
|
|
1455
|
-
if (input.status === 'completed' && definition.evidence_required && evidence.length === 0) {
|
|
1456
|
-
throw new WorkflowStateError('WORKFLOW_EVIDENCE_REQUIRED', `${stepId} requires evidence`);
|
|
1457
|
-
}
|
|
1458
|
-
if (input.status === 'completed' && definition.output_required && outputs.length === 0) {
|
|
1459
|
-
throw new WorkflowStateError('WORKFLOW_OUTPUT_REQUIRED', `${stepId} requires a durable output`);
|
|
1460
|
-
}
|
|
1461
|
-
if (input.status === 'completed' && runRow.kind === 'research') {
|
|
1462
|
-
validateResearchStepReport(db, runRow, stepId, outputs, rootDir);
|
|
1463
|
-
if (semanticRecords.length === 0) {
|
|
1464
|
-
throw new WorkflowStateError(
|
|
1465
|
-
'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
|
|
1466
|
-
);
|
|
1467
|
-
}
|
|
1468
|
-
if (stepId === '99-synthesis') validateSynthesisTrace(db, runRow, semanticRecords);
|
|
1469
|
-
}
|
|
1470
|
-
if (input.status === 'completed' && contextStepForKind(runRow.kind) === stepId) {
|
|
1471
|
-
validateContextManifestOutput(db, runRow, outputs, rootDir, { current: true });
|
|
1472
|
-
}
|
|
1473
|
-
if (input.status === 'blocked' && blockers.length === 0) {
|
|
1474
|
-
throw new WorkflowStateError('WORKFLOW_BLOCKER_REQUIRED', `${stepId} requires a blocker code`);
|
|
1475
|
-
}
|
|
1476
|
-
if (input.status === 'completed' && runRow.kind === 'test' && stepId === 'bind-scope') {
|
|
1477
|
-
assertDevelopmentComplete(db, runRow.change_id, rootDir, runRow.task_id);
|
|
1478
|
-
}
|
|
1479
|
-
if (input.status === 'completed' && runRow.kind === 'dev' && stepId === 'review-slice') {
|
|
1480
|
-
assertApprovedReview(db, runRow.change_id, runRow.task_id, rootDir);
|
|
1481
|
-
}
|
|
1482
|
-
if (input.status === 'completed' && runRow.kind === 'deliver' && stepId === 'bind-evidence') {
|
|
1483
|
-
validateDeliveryPrerequisites(db, { change_id: runRow.change_id }, rootDir);
|
|
1484
|
-
}
|
|
1485
|
-
const ts = nowIso();
|
|
1486
|
-
const startedAt = stepRow.started_at || ts;
|
|
1487
|
-
const completedAt = input.status === 'completed' ? ts : null;
|
|
1488
|
-
db.prepare(
|
|
1489
|
-
`UPDATE workflow_steps SET status = ?, evidence_json = ?, outputs_json = ?,
|
|
1490
|
-
decisions_json = ?, semantic_records_json = ?, blockers_json = ?,
|
|
1491
|
-
started_at = ?, completed_at = ?, updated_at = ?
|
|
1492
|
-
WHERE run_id = ? AND step_id = ?`,
|
|
1493
|
-
).run(
|
|
1494
|
-
input.status, JSON.stringify(evidence), JSON.stringify(outputs), JSON.stringify(decisions),
|
|
1495
|
-
JSON.stringify(semanticRecords), JSON.stringify(blockers),
|
|
1496
|
-
startedAt, completedAt, ts, id, stepId,
|
|
1497
|
-
);
|
|
1498
|
-
if (input.status === 'completed' && runRow.kind === 'research') {
|
|
1499
|
-
registerChangeResearchOutputsInTx(db, runRow, stepId, outputs, rootDir);
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
const next = db.prepare(
|
|
1503
|
-
`SELECT step_id FROM workflow_steps
|
|
1504
|
-
WHERE run_id = ? AND required = 1 AND status NOT IN ('completed', 'skipped')
|
|
1505
|
-
ORDER BY position ASC LIMIT 1`,
|
|
1506
|
-
).get(id);
|
|
1507
|
-
let runStatus = 'active';
|
|
1508
|
-
let runBlockers = [];
|
|
1509
|
-
if (input.status === 'blocked') {
|
|
1510
|
-
runStatus = 'blocked';
|
|
1511
|
-
runBlockers = blockers.map((item) => item.trim());
|
|
1512
|
-
} else if (!next) {
|
|
1513
|
-
runStatus = 'ready';
|
|
1514
|
-
}
|
|
1515
|
-
db.prepare(
|
|
1516
|
-
`UPDATE workflow_runs SET status = ?, current_step = ?, blockers_json = ?, updated_at = ?
|
|
1517
|
-
WHERE id = ?`,
|
|
1518
|
-
).run(runStatus, next?.step_id || null, JSON.stringify(runBlockers), ts, id);
|
|
1519
|
-
ops.appendEventInTx(db, {
|
|
1520
|
-
type: input.status === 'blocked' ? 'workflow_blocked' : 'workflow_step_updated',
|
|
1521
|
-
task_id: runRow.task_id, change_id: runRow.change_id,
|
|
1522
|
-
payload: { workflow_id: id, kind: runRow.kind, step_id: stepId, status: input.status },
|
|
1523
|
-
});
|
|
1524
|
-
return readWorkflow(db, id, { rootDir });
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
function currentCheckout(rootDir) {
|
|
1529
|
-
const baselines = require('./baseline-workflow.cjs');
|
|
1530
|
-
return baselines.gitWorktreeSnapshot(rootDir, ['.']);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
function assertIsoTimestamp(value, field) {
|
|
1534
|
-
if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
|
|
1535
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${field} must be an ISO-8601 timestamp`);
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
function readOutputJson(db, run, stepId, rootDir, schema) {
|
|
1540
|
-
const workflowStep = run.steps.find((item) => item.step_id === stepId);
|
|
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];
|
|
1547
|
-
for (const output of candidates) {
|
|
1548
|
-
if (authorityKind) {
|
|
1549
|
-
assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
|
|
1550
|
-
}
|
|
1551
|
-
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
1552
|
-
let value;
|
|
1553
|
-
try { value = JSON.parse(resolved.text); }
|
|
1554
|
-
catch (error) {
|
|
1555
|
-
throw new WorkflowStateError(
|
|
1556
|
-
'WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`,
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
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
|
-
}
|
|
1577
|
-
return { value, output };
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
throw new WorkflowStateError(
|
|
1581
|
-
'WORKFLOW_REPORT_INVALID', `${stepId} requires a ${schema} output`,
|
|
1582
|
-
);
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
function contextStepForKind(kind) {
|
|
1586
|
-
return {
|
|
1587
|
-
plan: 'compile-context',
|
|
1588
|
-
change: 'compile-context',
|
|
1589
|
-
dev: 'compile-context',
|
|
1590
|
-
test: 'compile-context',
|
|
1591
|
-
review: 'compile-context',
|
|
1592
|
-
deliver: 'verify-candidate',
|
|
1593
|
-
}[kind] || null;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
function contextExpectation(kind) {
|
|
1597
|
-
return {
|
|
1598
|
-
plan: { roles: ['plan'], gates: ['planning'] },
|
|
1599
|
-
change: { roles: ['plan', 'implement'], gates: ['planning', 'implementation'] },
|
|
1600
|
-
dev: { roles: ['implement'], gates: ['implementation'] },
|
|
1601
|
-
test: { roles: ['check'], gates: ['verification'] },
|
|
1602
|
-
review: { roles: ['review'], gates: ['review'] },
|
|
1603
|
-
deliver: { roles: ['check'], gates: ['convergence'] },
|
|
1604
|
-
}[kind] || null;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
function validateContextManifestOutput(db, run, outputs, rootDir, { current = true } = {}) {
|
|
1608
|
-
const expectation = contextExpectation(run.kind);
|
|
1609
|
-
if (!expectation) return null;
|
|
1610
|
-
let candidate = null;
|
|
1611
|
-
let manifest = null;
|
|
1612
|
-
let candidateDigest = null;
|
|
1613
|
-
for (const output of outputs || []) {
|
|
1614
|
-
const resolved = safeProjectFile(rootDir, output.path, 'context output');
|
|
1615
|
-
let value;
|
|
1616
|
-
try { value = JSON.parse(resolved.text); }
|
|
1617
|
-
catch { continue; }
|
|
1618
|
-
if (value?.schema_version === '3.0' && value?.snapshot_id) {
|
|
1619
|
-
candidate = output;
|
|
1620
|
-
manifest = value;
|
|
1621
|
-
candidateDigest = resolved.digest;
|
|
1622
|
-
break;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
if (!candidate) {
|
|
1626
|
-
throw new WorkflowStateError(
|
|
1627
|
-
'WORKFLOW_CONTEXT_OUTPUT_REQUIRED', `${run.kind} requires a current Context Manifest output`,
|
|
1628
|
-
);
|
|
1629
|
-
}
|
|
1630
|
-
if (manifest.change?.id !== run.change_id
|
|
1631
|
-
|| !expectation.roles.includes(manifest.role)
|
|
1632
|
-
|| !expectation.gates.includes(manifest.gate)
|
|
1633
|
-
|| manifest.readiness?.status !== 'ready') {
|
|
1634
|
-
throw new WorkflowStateError(
|
|
1635
|
-
'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to the expected change, role, gate, or readiness`,
|
|
1636
|
-
);
|
|
1637
|
-
}
|
|
1638
|
-
const expectedTaskId = run.task_id || null;
|
|
1639
|
-
if ((manifest.resume?.task_id || null) !== expectedTaskId) {
|
|
1640
|
-
throw new WorkflowStateError(
|
|
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`,
|
|
1645
|
-
);
|
|
1646
|
-
}
|
|
1647
|
-
const stored = db.prepare(
|
|
1648
|
-
`SELECT manifest_path, manifest_hash, role, gate, readiness, change_id, task_id
|
|
1649
|
-
FROM context_snapshots WHERE id = ?`,
|
|
1650
|
-
).get(manifest.snapshot_id);
|
|
1651
|
-
if (!stored || stored.manifest_path !== candidate.path || stored.manifest_hash !== candidate.digest
|
|
1652
|
-
|| candidateDigest !== candidate.digest
|
|
1653
|
-
|| stored.change_id !== run.change_id || stored.role !== manifest.role
|
|
1654
|
-
|| stored.gate !== manifest.gate || stored.readiness !== 'ready'
|
|
1655
|
-
|| (stored.task_id || null) !== expectedTaskId) {
|
|
1656
|
-
throw new WorkflowStateError(
|
|
1657
|
-
'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH', 'context output does not match its authoritative snapshot row',
|
|
1658
|
-
);
|
|
1659
|
-
}
|
|
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');
|
|
1685
|
-
}
|
|
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
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
function recordedContext(db, run, rootDir, options) {
|
|
1701
|
-
const stepId = contextStepForKind(run.kind);
|
|
1702
|
-
const workflowStep = run.steps.find((item) => item.step_id === stepId);
|
|
1703
|
-
return validateContextManifestOutput(db, run, workflowStep?.outputs || [], rootDir, options);
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
function expectedTaskIds(db, run) {
|
|
1707
|
-
if (run.task_id) return [run.task_id];
|
|
1708
|
-
return db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
|
|
1709
|
-
.all(run.change_id).map((row) => row.id);
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
function changeTasks(db, changeId) {
|
|
1713
|
-
return db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
|
|
1714
|
-
.all(changeId).map((row) => ops.readTask(db, row.id));
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
function taskPlanDigest(task) {
|
|
1718
|
-
const contract = {
|
|
1719
|
-
id: task.id,
|
|
1720
|
-
change_id: task.change_id,
|
|
1721
|
-
parent_id: task.parent_id,
|
|
1722
|
-
title: task.title,
|
|
1723
|
-
type: task.type,
|
|
1724
|
-
priority: task.priority,
|
|
1725
|
-
complexity: task.complexity,
|
|
1726
|
-
estimated_days: task.estimated_days,
|
|
1727
|
-
deps: task.deps || [],
|
|
1728
|
-
files_modified: task.files_modified || [],
|
|
1729
|
-
tag: task.tag,
|
|
1730
|
-
trace_to: task.trace_to,
|
|
1731
|
-
outcome: task.outcome,
|
|
1732
|
-
slice_kind: task.slice_kind,
|
|
1733
|
-
public_seam: task.public_seam,
|
|
1734
|
-
verification_command: task.verification_command,
|
|
1735
|
-
acceptance: task.acceptance || [],
|
|
1736
|
-
context_refs: task.context_refs || [],
|
|
1737
|
-
docs_impact: task.docs_impact || {},
|
|
1738
|
-
ownership: task.ownership || {},
|
|
1739
|
-
};
|
|
1740
|
-
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
function latestCompletedWorkflow(db, { kind, changeId, taskId = null }, rootDir) {
|
|
1744
|
-
const rows = db.prepare(
|
|
1745
|
-
`SELECT id FROM workflow_runs
|
|
1746
|
-
WHERE kind = ? AND change_id = ? AND task_id IS ? AND status = 'completed'
|
|
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
|
-
});
|
|
1753
|
-
return row ? readWorkflow(db, row.id, { rootDir }) : null;
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
function assertCurrentPlan(db, changeId, rootDir) {
|
|
1757
|
-
const plan = latestCompletedWorkflow(
|
|
1758
|
-
db, { kind: 'plan', changeId, taskId: null }, rootDir,
|
|
1759
|
-
);
|
|
1760
|
-
if (!plan || plan.artifact_health.status !== 'pass') {
|
|
1761
|
-
throw new WorkflowStateError(
|
|
1762
|
-
'WORKFLOW_PLAN_NOT_COMPLETED', `change ${changeId} requires a completed current plan workflow`,
|
|
1763
|
-
);
|
|
1764
|
-
}
|
|
1765
|
-
const tasks = changeTasks(db, changeId);
|
|
1766
|
-
const ids = tasks.map((task) => task.id);
|
|
1767
|
-
if (JSON.stringify(ids) !== JSON.stringify([...(plan.summary.task_ids || [])].sort())) {
|
|
1768
|
-
throw new WorkflowStateError(
|
|
1769
|
-
'WORKFLOW_PLAN_TASK_SET_STALE', `change ${changeId} task set changed after planning`,
|
|
1770
|
-
);
|
|
1771
|
-
}
|
|
1772
|
-
const plannedDigests = plan.summary.task_contract_digests;
|
|
1773
|
-
if (!plannedDigests || typeof plannedDigests !== 'object' || Array.isArray(plannedDigests)) {
|
|
1774
|
-
throw new WorkflowStateError(
|
|
1775
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `change ${changeId} plan has no task-contract provenance`,
|
|
1776
|
-
);
|
|
1777
|
-
}
|
|
1778
|
-
for (const task of tasks) {
|
|
1779
|
-
if (task.stale) {
|
|
1780
|
-
throw new WorkflowStateError(
|
|
1781
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
1782
|
-
`task ${task.id} was invalidated by newer change authority and must be reconciled`,
|
|
1783
|
-
);
|
|
1784
|
-
}
|
|
1785
|
-
if (plannedDigests[task.id] !== taskPlanDigest(task)) {
|
|
1786
|
-
throw new WorkflowStateError(
|
|
1787
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `task ${task.id} changed after plan convergence`,
|
|
1788
|
-
);
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
return { plan, tasks, taskIds: ids };
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
function assertGateCheckoutCurrent(run, rootDir, label) {
|
|
1795
|
-
if (!run || run.artifact_health.status !== 'pass') {
|
|
1796
|
-
throw new WorkflowStateError(
|
|
1797
|
-
'WORKFLOW_GATE_MISSING', `${label} workflow is missing or has stale outputs`,
|
|
1798
|
-
);
|
|
1799
|
-
}
|
|
1800
|
-
const checkout = currentCheckout(rootDir);
|
|
1801
|
-
if (checkout.head && run.summary.git_commit !== checkout.head) {
|
|
1802
|
-
throw new WorkflowStateError('WORKFLOW_GATE_STALE', `${label} workflow does not match current HEAD`);
|
|
1803
|
-
}
|
|
1804
|
-
if (checkout.digest && run.summary.worktree_digest !== checkout.digest) {
|
|
1805
|
-
throw new WorkflowStateError('WORKFLOW_GATE_STALE', `${label} workflow does not match current worktree`);
|
|
1806
|
-
}
|
|
1807
|
-
return run;
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
function gitCommitIsAncestor(rootDir, ancestor, descendant) {
|
|
1811
|
-
if (!ancestor || !descendant) return true;
|
|
1812
|
-
const result = spawnSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], {
|
|
1813
|
-
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore'],
|
|
1814
|
-
});
|
|
1815
|
-
return result.status === 0;
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
function assertCompletedDevelopmentEvidence(run, task, rootDir) {
|
|
1819
|
-
if (!run || run.artifact_health.status !== 'pass') {
|
|
1820
|
-
throw new WorkflowStateError(
|
|
1821
|
-
'WORKFLOW_GATE_MISSING', `dev:${task.id} workflow is missing or has stale outputs`,
|
|
1822
|
-
);
|
|
1823
|
-
}
|
|
1824
|
-
if (run.summary.task_id !== task.id
|
|
1825
|
-
|| (run.summary.public_seam !== undefined && run.summary.public_seam !== task.public_seam)
|
|
1826
|
-
|| (run.summary.verification_command !== undefined
|
|
1827
|
-
&& run.summary.verification_command !== task.verification_command)) {
|
|
1828
|
-
throw new WorkflowStateError(
|
|
1829
|
-
'WORKFLOW_GATE_STALE', `dev:${task.id} workflow no longer matches the task contract`,
|
|
1830
|
-
);
|
|
1831
|
-
}
|
|
1832
|
-
const checkout = currentCheckout(rootDir);
|
|
1833
|
-
if (!gitCommitIsAncestor(rootDir, run.summary.git_commit, checkout.head)) {
|
|
1834
|
-
throw new WorkflowStateError(
|
|
1835
|
-
'WORKFLOW_GATE_STALE', `dev:${task.id} commit is not an ancestor of current HEAD`,
|
|
1836
|
-
);
|
|
1837
|
-
}
|
|
1838
|
-
if (task.completion_commit
|
|
1839
|
-
&& !gitCommitIsAncestor(rootDir, task.completion_commit, checkout.head)) {
|
|
1840
|
-
throw new WorkflowStateError(
|
|
1841
|
-
'WORKFLOW_GATE_STALE', `task ${task.id} completion commit is not an ancestor of current HEAD`,
|
|
1842
|
-
);
|
|
1843
|
-
}
|
|
1844
|
-
return run;
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
function assertDevelopmentComplete(db, changeId, rootDir, taskId = null) {
|
|
1848
|
-
const tasks = taskId
|
|
1849
|
-
? [ops.readTask(db, taskId)].filter((task) => task?.change_id === changeId)
|
|
1850
|
-
: changeTasks(db, changeId);
|
|
1851
|
-
if (tasks.length === 0) {
|
|
1852
|
-
throw new WorkflowStateError(
|
|
1853
|
-
'WORKFLOW_TASKS_REQUIRED',
|
|
1854
|
-
taskId ? `task ${taskId} is not owned by change ${changeId}` : `change ${changeId} has no tasks`,
|
|
1855
|
-
);
|
|
1856
|
-
}
|
|
1857
|
-
for (const task of tasks) {
|
|
1858
|
-
if (task.stale) {
|
|
1859
|
-
throw new WorkflowStateError(
|
|
1860
|
-
'WORKFLOW_GATE_STALE',
|
|
1861
|
-
`task ${task.id} was invalidated by newer change authority and must be replanned`,
|
|
1862
|
-
);
|
|
1863
|
-
}
|
|
1864
|
-
if (!['completed', 'expanded'].includes(task.status)) {
|
|
1865
|
-
throw new WorkflowStateError(
|
|
1866
|
-
'WORKFLOW_TASK_NOT_COMPLETED', `task ${task.id} is ${task.status}`,
|
|
1867
|
-
);
|
|
1868
|
-
}
|
|
1869
|
-
if (task.status === 'expanded') continue;
|
|
1870
|
-
const dev = latestCompletedWorkflow(
|
|
1871
|
-
db, { kind: 'dev', changeId, taskId: task.id }, rootDir,
|
|
1872
|
-
);
|
|
1873
|
-
assertCompletedDevelopmentEvidence(dev, task, rootDir);
|
|
1874
|
-
}
|
|
1875
|
-
return tasks;
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
function assertApprovedReview(db, changeId, taskId, rootDir, expectedMode = taskId ? 'task' : 'change') {
|
|
1879
|
-
const review = latestCompletedWorkflow(
|
|
1880
|
-
db, { kind: 'review', changeId, taskId }, rootDir,
|
|
1881
|
-
);
|
|
1882
|
-
assertGateCheckoutCurrent(review, rootDir, `review:${taskId || changeId}`);
|
|
1883
|
-
if (review.summary.verdict !== 'APPROVE'
|
|
1884
|
-
|| review.summary.axes?.spec_fidelity?.verdict !== 'PASS'
|
|
1885
|
-
|| review.summary.axes?.engineering_standards?.verdict !== 'PASS') {
|
|
1886
|
-
throw new WorkflowStateError(
|
|
1887
|
-
'WORKFLOW_REVIEW_NOT_APPROVED', `review for ${taskId || changeId} is not approved on both axes`,
|
|
1888
|
-
);
|
|
1889
|
-
}
|
|
1890
|
-
if (review.summary.mode !== expectedMode) {
|
|
1891
|
-
throw new WorkflowStateError(
|
|
1892
|
-
'WORKFLOW_REVIEW_MODE_MISMATCH',
|
|
1893
|
-
`review for ${taskId || changeId} uses ${review.summary.mode || 'missing'} mode; expected ${expectedMode}`,
|
|
1894
|
-
);
|
|
1895
|
-
}
|
|
1896
|
-
return review;
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
function validateDevCompletion(db, run, rootDir) {
|
|
1900
|
-
const task = ops.readTask(db, run.task_id);
|
|
1901
|
-
if (!task || task.status !== 'completed') {
|
|
1902
|
-
throw new WorkflowStateError(
|
|
1903
|
-
'WORKFLOW_TASK_NOT_COMPLETED', `task ${run.task_id} must be completed before dev workflow convergence`,
|
|
1904
|
-
);
|
|
1905
|
-
}
|
|
1906
|
-
const context = recordedContext(db, run, rootDir, { current: false });
|
|
1907
|
-
if (context.manifest.execution_contract?.public_seam !== task.public_seam
|
|
1908
|
-
|| context.manifest.execution_contract?.verification_command !== task.verification_command) {
|
|
1909
|
-
throw new WorkflowStateError(
|
|
1910
|
-
'WORKFLOW_CONTEXT_NOT_READY', `task ${task.id} has no current executable context contract`,
|
|
1911
|
-
);
|
|
1912
|
-
}
|
|
1913
|
-
const sessions = db.prepare(
|
|
1914
|
-
"SELECT sid FROM sessions WHERE task_id = ? AND status = 'running'",
|
|
1915
|
-
).all(task.id);
|
|
1916
|
-
if (sessions.length > 0) {
|
|
1917
|
-
throw new WorkflowStateError(
|
|
1918
|
-
'WORKFLOW_SESSION_ACTIVE', `task ${task.id} still has active sessions: ${sessions.map((row) => row.sid).join(', ')}`,
|
|
1919
|
-
);
|
|
1920
|
-
}
|
|
1921
|
-
const review = assertApprovedReview(db, run.change_id, task.id, rootDir);
|
|
1922
|
-
const checkout = currentCheckout(rootDir);
|
|
1923
|
-
return {
|
|
1924
|
-
change_id: run.change_id,
|
|
1925
|
-
task_id: task.id,
|
|
1926
|
-
outcome: task.outcome,
|
|
1927
|
-
public_seam: task.public_seam,
|
|
1928
|
-
verification_command: task.verification_command,
|
|
1929
|
-
context_path: context.output.path,
|
|
1930
|
-
context_digest: context.output.digest,
|
|
1931
|
-
context_warnings: context.warnings,
|
|
1932
|
-
git_commit: checkout.head,
|
|
1933
|
-
worktree_digest: checkout.digest,
|
|
1934
|
-
review_workflow_id: review.id,
|
|
1935
|
-
};
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
function validateChangeCompletion(db, run, rootDir) {
|
|
1939
|
-
const change = db.prepare(
|
|
1940
|
-
`SELECT id, kind, intent, contract_json, classification_json, research_disposition_json
|
|
1941
|
-
FROM changes WHERE id = ?`,
|
|
1942
|
-
).get(run.change_id);
|
|
1943
|
-
if (!change) {
|
|
1944
|
-
throw new WorkflowStateError(
|
|
1945
|
-
'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id || '(missing)'} does not exist`,
|
|
1946
|
-
);
|
|
1947
|
-
}
|
|
1948
|
-
const contract = parseJson(change.contract_json, 'changes.contract_json', {});
|
|
1949
|
-
const classification = parseJson(change.classification_json, 'changes.classification_json', {});
|
|
1950
|
-
const researchDisposition = parseJson(
|
|
1951
|
-
change.research_disposition_json, 'changes.research_disposition_json', {},
|
|
1952
|
-
);
|
|
1953
|
-
return {
|
|
1954
|
-
change_id: change.id,
|
|
1955
|
-
change_kind: change.kind,
|
|
1956
|
-
acceptance_ids: Array.isArray(contract.acceptance)
|
|
1957
|
-
? contract.acceptance.map((item) => item.id)
|
|
1958
|
-
: [],
|
|
1959
|
-
classification,
|
|
1960
|
-
research_disposition: researchDisposition,
|
|
1961
|
-
authority_basis: 'accepted_change_contract',
|
|
1962
|
-
};
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
1966
|
-
const tasks = assertDevelopmentComplete(db, run.change_id, rootDir);
|
|
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
|
-
}
|
|
1976
|
-
const test = latestCompletedWorkflow(
|
|
1977
|
-
db, { kind: 'test', changeId: run.change_id, taskId: null }, rootDir,
|
|
1978
|
-
);
|
|
1979
|
-
assertGateCheckoutCurrent(test, rootDir, `test:${run.change_id}`);
|
|
1980
|
-
if (test.summary.passed !== true) {
|
|
1981
|
-
throw new WorkflowStateError('WORKFLOW_TEST_NOT_PASSED', `test gate for ${run.change_id} did not pass`);
|
|
1982
|
-
}
|
|
1983
|
-
if (JSON.stringify([...(test.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
|
|
1984
|
-
throw new WorkflowStateError(
|
|
1985
|
-
'WORKFLOW_GATE_STALE', `test gate for ${run.change_id} does not cover the current task set`,
|
|
1986
|
-
);
|
|
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
|
-
}
|
|
2020
|
-
const review = assertApprovedReview(db, run.change_id, null, rootDir);
|
|
2021
|
-
if (JSON.stringify([...(review.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
|
|
2022
|
-
throw new WorkflowStateError(
|
|
2023
|
-
'WORKFLOW_GATE_STALE', `review gate for ${run.change_id} does not cover the current task set`,
|
|
2024
|
-
);
|
|
2025
|
-
}
|
|
2026
|
-
return {
|
|
2027
|
-
task_ids: taskIds,
|
|
2028
|
-
test_workflow_id: test.id,
|
|
2029
|
-
review_workflow_id: review.id,
|
|
2030
|
-
test_summary: test.summary,
|
|
2031
|
-
review_summary: review.summary,
|
|
2032
|
-
acceptance_ids: acceptanceIds,
|
|
2033
|
-
};
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
function assertBoundTaskSet(db, run, report) {
|
|
2037
|
-
if (!Array.isArray(report.task_ids)
|
|
2038
|
-
|| report.task_ids.some((id) => typeof id !== 'string' || !id.trim())) {
|
|
2039
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'report.task_ids must be an array of task ids');
|
|
2040
|
-
}
|
|
2041
|
-
const actual = [...report.task_ids].sort();
|
|
2042
|
-
const expected = expectedTaskIds(db, run);
|
|
2043
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
2044
|
-
throw new WorkflowStateError(
|
|
2045
|
-
'WORKFLOW_REPORT_TASK_SET_STALE',
|
|
2046
|
-
`report task set does not match ${run.change_id}: expected ${expected.join(', ') || '(none)'}`,
|
|
2047
|
-
);
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
function assertCurrentCheckout(report, rootDir) {
|
|
2052
|
-
const checkout = currentCheckout(rootDir);
|
|
2053
|
-
if (checkout.head && report.git_commit !== checkout.head && report.head !== checkout.head) {
|
|
2054
|
-
throw new WorkflowStateError(
|
|
2055
|
-
'WORKFLOW_REPORT_REVISION_STALE', `report revision does not match current HEAD ${checkout.head}`,
|
|
2056
|
-
);
|
|
2057
|
-
}
|
|
2058
|
-
if (checkout.digest && report.worktree_digest !== checkout.digest) {
|
|
2059
|
-
throw new WorkflowStateError(
|
|
2060
|
-
'WORKFLOW_REPORT_WORKTREE_STALE', 'report worktree digest does not match the current checkout',
|
|
2061
|
-
);
|
|
2062
|
-
}
|
|
2063
|
-
return checkout;
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
const VERIFICATION_DIMENSIONS = Object.freeze([
|
|
2067
|
-
'acceptance',
|
|
2068
|
-
'regression',
|
|
2069
|
-
'integration',
|
|
2070
|
-
'static_analysis',
|
|
2071
|
-
'build',
|
|
2072
|
-
'performance',
|
|
2073
|
-
'security',
|
|
2074
|
-
'recovery',
|
|
2075
|
-
]);
|
|
2076
|
-
|
|
2077
|
-
function validateVerificationProfile(report) {
|
|
2078
|
-
const profile = report.verification_profile;
|
|
2079
|
-
const selected = new Set(profile?.selected_dimensions || []);
|
|
2080
|
-
const dimensions = new Set(Object.keys(report.verification_dimensions || {}));
|
|
2081
|
-
const excludedItems = Array.isArray(profile?.excluded_dimensions)
|
|
2082
|
-
? profile.excluded_dimensions : [];
|
|
2083
|
-
const excluded = new Set(excludedItems.map((item) => item.dimension));
|
|
2084
|
-
const known = new Set(VERIFICATION_DIMENSIONS);
|
|
2085
|
-
const sameSet = (left, right) => left.size === right.size
|
|
2086
|
-
&& [...left].every((item) => right.has(item));
|
|
2087
|
-
const invalid = [...selected, ...dimensions, ...excluded].some((item) => !known.has(item));
|
|
2088
|
-
const overlap = [...selected].some((item) => excluded.has(item));
|
|
2089
|
-
const duplicateExclusion = excluded.size !== excludedItems.length;
|
|
2090
|
-
const completeDisposition = VERIFICATION_DIMENSIONS.every(
|
|
2091
|
-
(dimension) => selected.has(dimension) || excluded.has(dimension),
|
|
2092
|
-
);
|
|
2093
|
-
if (!profile || typeof profile.rationale !== 'string' || profile.rationale.trim().length < 3
|
|
2094
|
-
|| !selected.has('acceptance') || !sameSet(selected, dimensions) || invalid || overlap
|
|
2095
|
-
|| duplicateExclusion || !completeDisposition) {
|
|
2096
|
-
throw new WorkflowStateError(
|
|
2097
|
-
'WORKFLOW_VERIFICATION_PROFILE_INVALID',
|
|
2098
|
-
'verification profile must select exactly the recorded dimensions, include acceptance, and explain every excluded dimension',
|
|
2099
|
-
);
|
|
2100
|
-
}
|
|
2101
|
-
return {
|
|
2102
|
-
rationale: profile.rationale.trim(),
|
|
2103
|
-
selected_dimensions: [...selected],
|
|
2104
|
-
excluded_dimensions: excludedItems,
|
|
2105
|
-
};
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
function validateTestCompletion(db, run, rootDir) {
|
|
2109
|
-
assertDevelopmentComplete(db, run.change_id, rootDir, run.task_id);
|
|
2110
|
-
const context = recordedContext(db, run, rootDir, { current: true });
|
|
2111
|
-
const { value: report, output } = readOutputJson(
|
|
2112
|
-
db, run, 'write-report', rootDir, 'ultra-test-report-v1',
|
|
2113
|
-
);
|
|
2114
|
-
assertReportSchema(report, 'test');
|
|
2115
|
-
const verificationProfile = validateVerificationProfile(report);
|
|
2116
|
-
if (report.change_id !== run.change_id) {
|
|
2117
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'test report change_id is not bound to the workflow');
|
|
2118
|
-
}
|
|
2119
|
-
assertBoundTaskSet(db, run, report);
|
|
2120
|
-
const change = db.prepare('SELECT kind FROM changes WHERE id = ?').get(run.change_id);
|
|
2121
|
-
const requiresRegressionSignal = change?.kind === 'incident'
|
|
2122
|
-
|| changeTasks(db, run.change_id).some((task) => task.type === 'bugfix');
|
|
2123
|
-
if (requiresRegressionSignal) {
|
|
2124
|
-
const signal = report.regression_signal;
|
|
2125
|
-
if (!signal || signal.observed_red !== true || signal.observed_green !== true
|
|
2126
|
-
|| signal.deterministic !== true) {
|
|
2127
|
-
throw new WorkflowStateError(
|
|
2128
|
-
'WORKFLOW_REGRESSION_SIGNAL_MISSING',
|
|
2129
|
-
'incident and bugfix testing requires one deterministic observed red-to-green regression signal',
|
|
2130
|
-
);
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
const checkout = assertCurrentCheckout(report, rootDir);
|
|
2134
|
-
assertIsoTimestamp(report.timestamp, 'test report timestamp');
|
|
2135
|
-
if (!Number.isInteger(report.run_count) || report.run_count < 1) {
|
|
2136
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'test report run_count must be a positive integer');
|
|
2137
|
-
}
|
|
2138
|
-
for (const field of ['acceptance', 'commands', 'public_seams', 'failures', 'recovery', 'blocking_issues']) {
|
|
2139
|
-
if (!Array.isArray(report[field])) {
|
|
2140
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `test report ${field} must be an array`);
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
if (!String(report.context_digest || '').trim()) {
|
|
2144
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'test report context_digest is required');
|
|
2145
|
-
}
|
|
2146
|
-
if (report.context_digest !== context.output.digest) {
|
|
2147
|
-
throw new WorkflowStateError(
|
|
2148
|
-
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'test report context_digest does not match the checking context',
|
|
2149
|
-
);
|
|
2150
|
-
}
|
|
2151
|
-
if (report.commands.length === 0 || report.public_seams.length === 0) {
|
|
2152
|
-
throw new WorkflowStateError(
|
|
2153
|
-
'WORKFLOW_REPORT_INVALID', 'test report requires commands and public_seams evidence',
|
|
2154
|
-
);
|
|
2155
|
-
}
|
|
2156
|
-
const claimedPass = report.acceptance.length > 0
|
|
2157
|
-
&& report.acceptance.every((item) => item.status === 'pass')
|
|
2158
|
-
&& report.commands.every((item) => item.status === 'pass' && item.exit_code === 0)
|
|
2159
|
-
&& report.public_seams.every((item) => item?.status === 'pass')
|
|
2160
|
-
&& report.failures.every((item) => item.status === 'resolved')
|
|
2161
|
-
&& report.recovery.every((item) => item.status === 'pass')
|
|
2162
|
-
&& Object.values(report.verification_dimensions || {}).every((item) => item?.status === 'pass')
|
|
2163
|
-
&& report.blocking_issues.length === 0;
|
|
2164
|
-
if (typeof report.passed !== 'boolean' || report.passed !== claimedPass) {
|
|
2165
|
-
throw new WorkflowStateError(
|
|
2166
|
-
'WORKFLOW_REPORT_VERDICT_CONFLICT', 'test report passed conflicts with command, seam, or blocker evidence',
|
|
2167
|
-
);
|
|
2168
|
-
}
|
|
2169
|
-
return {
|
|
2170
|
-
change_id: run.change_id,
|
|
2171
|
-
task_ids: report.task_ids,
|
|
2172
|
-
acceptance_ids: report.acceptance.map((item) => item.id).sort(),
|
|
2173
|
-
passed: report.passed,
|
|
2174
|
-
git_commit: checkout.head,
|
|
2175
|
-
worktree_digest: checkout.digest,
|
|
2176
|
-
report_path: output.path,
|
|
2177
|
-
report_digest: output.digest,
|
|
2178
|
-
context_path: context.output.path,
|
|
2179
|
-
context_digest: context.output.digest,
|
|
2180
|
-
context_warnings: context.warnings,
|
|
2181
|
-
regression_signal: report.regression_signal,
|
|
2182
|
-
verification_profile: verificationProfile,
|
|
2183
|
-
verification_dimensions: report.verification_dimensions,
|
|
2184
|
-
blocking_issues: report.blocking_issues,
|
|
2185
|
-
};
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
const REVIEW_AXES = new Set(['spec_fidelity', 'engineering_standards']);
|
|
2189
|
-
const REVIEW_WORKERS = new Set([
|
|
2190
|
-
'review-spec',
|
|
2191
|
-
'review-code',
|
|
2192
|
-
'review-tests',
|
|
2193
|
-
'review-errors',
|
|
2194
|
-
'review-design',
|
|
2195
|
-
'review-comments',
|
|
2196
|
-
]);
|
|
2197
|
-
const REVIEW_SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
2198
|
-
const REVIEW_FINDING_FIELDS = [
|
|
2199
|
-
'id', 'axis', 'severity', 'category', 'title', 'file', 'line',
|
|
2200
|
-
'trigger', 'impact', 'evidence', 'suggestion',
|
|
2201
|
-
];
|
|
2202
|
-
|
|
2203
|
-
function validateReviewFinding(finding, axis, seenIds, label) {
|
|
2204
|
-
if (!finding || typeof finding !== 'object' || Array.isArray(finding)) {
|
|
2205
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding must be an object`);
|
|
2206
|
-
}
|
|
2207
|
-
for (const field of REVIEW_FINDING_FIELDS) {
|
|
2208
|
-
if (field === 'line') {
|
|
2209
|
-
if (!Number.isInteger(finding.line) || finding.line < 1) {
|
|
2210
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.line must be positive`);
|
|
2211
|
-
}
|
|
2212
|
-
} else if (!String(finding[field] || '').trim()) {
|
|
2213
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.${field} is required`);
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
if (finding.axis !== axis || !REVIEW_AXES.has(finding.axis)) {
|
|
2217
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding axis does not match ${axis}`);
|
|
2218
|
-
}
|
|
2219
|
-
if (!REVIEW_SEVERITIES.has(finding.severity)) {
|
|
2220
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding severity is invalid`);
|
|
2221
|
-
}
|
|
2222
|
-
if (finding.line_end !== undefined
|
|
2223
|
-
&& (!Number.isInteger(finding.line_end) || finding.line_end < finding.line)) {
|
|
2224
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.line_end is invalid`);
|
|
2225
|
-
}
|
|
2226
|
-
if (seenIds.has(finding.id)) {
|
|
2227
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `duplicate review finding id: ${finding.id}`);
|
|
2228
|
-
}
|
|
2229
|
-
seenIds.add(finding.id);
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
function readReviewSpecialists(db, run, rootDir) {
|
|
2233
|
-
const axes = [
|
|
2234
|
-
['review-specification', 'spec_fidelity'],
|
|
2235
|
-
['review-engineering', 'engineering_standards'],
|
|
2236
|
-
];
|
|
2237
|
-
const checkout = currentCheckout(rootDir);
|
|
2238
|
-
const expectedHead = checkout.head || 'workspace';
|
|
2239
|
-
const seenIds = new Set();
|
|
2240
|
-
const seenAgents = new Set();
|
|
2241
|
-
const artifacts = [];
|
|
2242
|
-
for (const [stepId, expectedAxis] of axes) {
|
|
2243
|
-
const outputs = run.steps.find((item) => item.step_id === stepId)?.outputs || [];
|
|
2244
|
-
if (outputs.length === 0) {
|
|
2245
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${stepId} has no specialist artifact`);
|
|
2246
|
-
}
|
|
2247
|
-
for (const output of outputs) {
|
|
2248
|
-
assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
|
|
2249
|
-
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
2250
|
-
let artifact;
|
|
2251
|
-
try { artifact = JSON.parse(resolved.text); }
|
|
2252
|
-
catch (error) {
|
|
2253
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`);
|
|
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
|
-
});
|
|
2270
|
-
if (artifact?.$schema !== 'ultra-review-findings-v2'
|
|
2271
|
-
|| artifact.axis !== expectedAxis || artifact.status !== 'complete'
|
|
2272
|
-
|| !String(artifact.agent || '').trim() || !String(artifact.session || '').trim()
|
|
2273
|
-
|| !String(artifact.timestamp || '').trim() || Number.isNaN(Date.parse(artifact.timestamp))
|
|
2274
|
-
|| !artifact.scope || typeof artifact.scope !== 'object'
|
|
2275
|
-
|| artifact.scope.head !== expectedHead || !String(artifact.scope.range || '').trim()
|
|
2276
|
-
|| !Array.isArray(artifact.scope.files_analyzed)
|
|
2277
|
-
|| artifact.scope.files_analyzed.some((file) => !String(file || '').trim())
|
|
2278
|
-
|| typeof artifact.scope.diff_only !== 'boolean'
|
|
2279
|
-
|| !Array.isArray(artifact.findings)
|
|
2280
|
-
|| !Array.isArray(artifact.positive_observations)
|
|
2281
|
-
|| !Array.isArray(artifact.limitations)) {
|
|
2282
|
-
throw new WorkflowStateError(
|
|
2283
|
-
'WORKFLOW_REPORT_INVALID', `${output.path} is not a complete ${expectedAxis} artifact`,
|
|
2284
|
-
);
|
|
2285
|
-
}
|
|
2286
|
-
if (!REVIEW_WORKERS.has(artifact.agent) || seenAgents.has(artifact.agent)) {
|
|
2287
|
-
throw new WorkflowStateError(
|
|
2288
|
-
'WORKFLOW_REPORT_INVALID',
|
|
2289
|
-
`${output.path} uses an unknown or duplicate review worker: ${artifact.agent}`,
|
|
2290
|
-
);
|
|
2291
|
-
}
|
|
2292
|
-
seenAgents.add(artifact.agent);
|
|
2293
|
-
for (const finding of artifact.findings) {
|
|
2294
|
-
validateReviewFinding(finding, expectedAxis, seenIds, output.path);
|
|
2295
|
-
}
|
|
2296
|
-
artifacts.push({ path: output.path, digest: output.digest, ...artifact });
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
return { artifacts, findings: artifacts.flatMap((artifact) => artifact.findings) };
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
function assertCoordinatedReviewEvidence(report, specialists) {
|
|
2303
|
-
const sameSet = (left, right) => left.size === right.size
|
|
2304
|
-
&& [...left].every((item) => right.has(item));
|
|
2305
|
-
const artifactPathsByAxis = Object.fromEntries([...REVIEW_AXES].map((axis) => [
|
|
2306
|
-
axis,
|
|
2307
|
-
specialists.artifacts.filter((artifact) => artifact.axis === axis).map((artifact) => artifact.path).sort(),
|
|
2308
|
-
]));
|
|
2309
|
-
for (const axis of REVIEW_AXES) {
|
|
2310
|
-
const refs = [...(report.axes?.[axis]?.evidence_refs || [])].sort();
|
|
2311
|
-
if (JSON.stringify(refs) !== JSON.stringify(artifactPathsByAxis[axis])) {
|
|
2312
|
-
throw new WorkflowStateError(
|
|
2313
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
2314
|
-
`review summary ${axis} evidence_refs do not match specialist outputs`,
|
|
2315
|
-
);
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
|
-
const sessions = new Set(specialists.artifacts.map((artifact) => artifact.session));
|
|
2319
|
-
if (sessions.size !== 1 || !sessions.has(report.session)) {
|
|
2320
|
-
throw new WorkflowStateError(
|
|
2321
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'review summary session does not match specialist artifacts',
|
|
2322
|
-
);
|
|
2323
|
-
}
|
|
2324
|
-
const completed = new Set(report.workers?.completed || []);
|
|
2325
|
-
const artifactAgents = new Set(specialists.artifacts.map((artifact) => artifact.agent));
|
|
2326
|
-
if (!sameSet(completed, artifactAgents)
|
|
2327
|
-
|| (report.workers?.failed || []).length > 0) {
|
|
2328
|
-
throw new WorkflowStateError(
|
|
2329
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
2330
|
-
'completed review workers must match specialist artifacts exactly',
|
|
2331
|
-
);
|
|
2332
|
-
}
|
|
2333
|
-
const selection = Array.isArray(report.worker_selection) ? report.worker_selection : [];
|
|
2334
|
-
const seenWorkers = new Set();
|
|
2335
|
-
const selected = new Set();
|
|
2336
|
-
const skipped = new Set();
|
|
2337
|
-
for (const item of selection) {
|
|
2338
|
-
const worker = String(item?.worker || '').trim();
|
|
2339
|
-
const status = item?.status;
|
|
2340
|
-
if (!worker || !['selected', 'skipped'].includes(status)
|
|
2341
|
-
|| !String(item?.rationale || '').trim() || seenWorkers.has(worker)) {
|
|
2342
|
-
throw new WorkflowStateError(
|
|
2343
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'review worker selection provenance is invalid',
|
|
2344
|
-
);
|
|
2345
|
-
}
|
|
2346
|
-
seenWorkers.add(worker);
|
|
2347
|
-
(status === 'selected' ? selected : skipped).add(worker);
|
|
2348
|
-
}
|
|
2349
|
-
const expectedSelected = new Set([
|
|
2350
|
-
...(report.workers?.completed || []), ...(report.workers?.failed || []),
|
|
2351
|
-
]);
|
|
2352
|
-
const expectedSkipped = new Set(report.workers?.skipped || []);
|
|
2353
|
-
if (!sameSet(seenWorkers, REVIEW_WORKERS)
|
|
2354
|
-
|| !sameSet(selected, expectedSelected)
|
|
2355
|
-
|| !sameSet(skipped, expectedSkipped)
|
|
2356
|
-
|| !selected.has('review-spec') || !completed.has('review-spec')) {
|
|
2357
|
-
throw new WorkflowStateError(
|
|
2358
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
2359
|
-
'review worker selection must disposition the complete worker roster and match completed, failed, and skipped state',
|
|
2360
|
-
);
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
const specialistById = new Map(specialists.findings.map((finding) => [finding.id, finding]));
|
|
2364
|
-
const summaryById = new Map((report.findings || []).map((finding) => [finding.id, finding]));
|
|
2365
|
-
if (specialistById.size !== summaryById.size
|
|
2366
|
-
|| [...specialistById.keys()].some((id) => !summaryById.has(id))) {
|
|
2367
|
-
throw new WorkflowStateError(
|
|
2368
|
-
'WORKFLOW_REVIEW_FINDINGS_MISMATCH', 'review summary must preserve every specialist finding',
|
|
2369
|
-
);
|
|
2370
|
-
}
|
|
2371
|
-
const identityFields = [...REVIEW_FINDING_FIELDS, 'line_end'];
|
|
2372
|
-
for (const [id, source] of specialistById) {
|
|
2373
|
-
const coordinated = summaryById.get(id);
|
|
2374
|
-
for (const field of identityFields) {
|
|
2375
|
-
if ((coordinated?.[field] ?? null) !== (source[field] ?? null)) {
|
|
2376
|
-
throw new WorkflowStateError(
|
|
2377
|
-
'WORKFLOW_REVIEW_FINDINGS_MISMATCH',
|
|
2378
|
-
`review summary changed ${id}.${field} from its specialist artifact`,
|
|
2379
|
-
);
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
const axes = Object.fromEntries([...REVIEW_AXES].map((axis) => {
|
|
2385
|
-
const hasBlocking = specialists.findings.some(
|
|
2386
|
-
(finding) => finding.axis === axis && ['P0', 'P1'].includes(finding.severity),
|
|
2387
|
-
);
|
|
2388
|
-
return [axis, {
|
|
2389
|
-
verdict: hasBlocking ? 'FAIL' : 'PASS',
|
|
2390
|
-
evidence_refs: artifactPathsByAxis[axis],
|
|
2391
|
-
}];
|
|
2392
|
-
}));
|
|
2393
|
-
return axes;
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
function validateReviewCompletion(db, run, rootDir) {
|
|
2397
|
-
const context = recordedContext(db, run, rootDir, { current: true });
|
|
2398
|
-
const specialists = readReviewSpecialists(db, run, rootDir);
|
|
2399
|
-
const { value: report, output } = readOutputJson(
|
|
2400
|
-
db, run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
|
|
2401
|
-
);
|
|
2402
|
-
if (report.change_id !== run.change_id) {
|
|
2403
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'review summary change_id is not bound to the workflow');
|
|
2404
|
-
}
|
|
2405
|
-
if (!['task', 'change', 'plan'].includes(report.mode)
|
|
2406
|
-
|| (run.task_id && report.mode !== 'task')
|
|
2407
|
-
|| (!run.task_id && report.mode === 'task')) {
|
|
2408
|
-
throw new WorkflowStateError(
|
|
2409
|
-
'WORKFLOW_REVIEW_MODE_MISMATCH', 'review summary mode does not match its workflow scope',
|
|
2410
|
-
);
|
|
2411
|
-
}
|
|
2412
|
-
assertBoundTaskSet(db, run, report);
|
|
2413
|
-
const checkout = assertCurrentCheckout(report, rootDir);
|
|
2414
|
-
if (report.status !== 'complete' || !report.axes || typeof report.axes !== 'object') {
|
|
2415
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary must be complete and contain both axes');
|
|
2416
|
-
}
|
|
2417
|
-
const spec = report.axes.spec_fidelity;
|
|
2418
|
-
const engineering = report.axes.engineering_standards;
|
|
2419
|
-
for (const [name, axis] of [['spec_fidelity', spec], ['engineering_standards', engineering]]) {
|
|
2420
|
-
if (!axis || !['PASS', 'FAIL', 'INCOMPLETE'].includes(axis.verdict)
|
|
2421
|
-
|| !Array.isArray(axis.evidence_refs) || axis.evidence_refs.length === 0) {
|
|
2422
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `review summary ${name} axis is incomplete`);
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
if (!Array.isArray(report.findings)) {
|
|
2426
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary findings must be an array');
|
|
2427
|
-
}
|
|
2428
|
-
if (!report.workers || typeof report.workers !== 'object'
|
|
2429
|
-
|| ['completed', 'failed', 'skipped'].some((field) => !Array.isArray(report.workers[field]))
|
|
2430
|
-
|| !Array.isArray(report.positive_observations) || !Array.isArray(report.limitations)) {
|
|
2431
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary worker and observation fields are incomplete');
|
|
2432
|
-
}
|
|
2433
|
-
const summaryFindingIds = new Set();
|
|
2434
|
-
for (const finding of report.findings) {
|
|
2435
|
-
validateReviewFinding(finding, finding?.axis, summaryFindingIds, 'review summary');
|
|
2436
|
-
}
|
|
2437
|
-
if (report.context_digest !== context.output.digest) {
|
|
2438
|
-
throw new WorkflowStateError(
|
|
2439
|
-
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'review summary context_digest does not match the review context',
|
|
2440
|
-
);
|
|
2441
|
-
}
|
|
2442
|
-
const derivedAxes = assertCoordinatedReviewEvidence(report, specialists);
|
|
2443
|
-
for (const axis of REVIEW_AXES) {
|
|
2444
|
-
if (report.axes[axis].verdict !== derivedAxes[axis].verdict) {
|
|
2445
|
-
throw new WorkflowStateError(
|
|
2446
|
-
'WORKFLOW_REPORT_VERDICT_CONFLICT',
|
|
2447
|
-
`review ${axis} verdict conflicts with specialist findings; expected ${derivedAxes[axis].verdict}`,
|
|
2448
|
-
);
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
const expectedVerdict = Object.values(derivedAxes).some((axis) => axis.verdict === 'FAIL')
|
|
2452
|
-
? 'REQUEST_CHANGES' : 'APPROVE';
|
|
2453
|
-
if (report.verdict !== expectedVerdict) {
|
|
2454
|
-
throw new WorkflowStateError(
|
|
2455
|
-
'WORKFLOW_REPORT_VERDICT_CONFLICT',
|
|
2456
|
-
`review verdict ${report.verdict} conflicts with evidence; expected ${expectedVerdict}`,
|
|
2457
|
-
);
|
|
2458
|
-
}
|
|
2459
|
-
return {
|
|
2460
|
-
change_id: run.change_id,
|
|
2461
|
-
task_ids: report.task_ids,
|
|
2462
|
-
mode: report.mode,
|
|
2463
|
-
worker_selection: report.worker_selection,
|
|
2464
|
-
verdict: report.verdict,
|
|
2465
|
-
axes: derivedAxes,
|
|
2466
|
-
findings: specialists.findings,
|
|
2467
|
-
git_commit: checkout.head,
|
|
2468
|
-
worktree_digest: checkout.digest,
|
|
2469
|
-
report_path: output.path,
|
|
2470
|
-
report_digest: output.digest,
|
|
2471
|
-
context_path: context.output.path,
|
|
2472
|
-
context_digest: context.output.digest,
|
|
2473
|
-
context_warnings: context.warnings,
|
|
2474
|
-
};
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
function validateDeliveryCompletion(db, run, rootDir) {
|
|
2478
|
-
const gates = validateDeliveryPrerequisites(db, run, rootDir);
|
|
2479
|
-
const context = recordedContext(db, run, rootDir, { current: true });
|
|
2480
|
-
const change = db.prepare('SELECT status FROM changes WHERE id = ?').get(run.change_id);
|
|
2481
|
-
if (change?.status !== 'archived') {
|
|
2482
|
-
throw new WorkflowStateError(
|
|
2483
|
-
'WORKFLOW_CHANGE_NOT_ARCHIVED', `change ${run.change_id} must be archived before delivery completion`,
|
|
2484
|
-
);
|
|
2485
|
-
}
|
|
2486
|
-
const { value: report, output } = readOutputJson(
|
|
2487
|
-
db, run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
|
|
2488
|
-
);
|
|
2489
|
-
if (report.release !== undefined) {
|
|
2490
|
-
throw new WorkflowStateError(
|
|
2491
|
-
'WORKFLOW_RELEASE_AUTHORITY_MISMATCH',
|
|
2492
|
-
'delivery reports cannot create release authority; record external effects through the host after explicit authorization',
|
|
2493
|
-
);
|
|
2494
|
-
}
|
|
2495
|
-
assertReportSchema(report, 'delivery');
|
|
2496
|
-
if (report.change_id !== run.change_id || report.archive_status !== 'archived') {
|
|
2497
|
-
throw new WorkflowStateError(
|
|
2498
|
-
'WORKFLOW_REPORT_CHANGE_MISMATCH', 'delivery report is not bound to the archived change',
|
|
2499
|
-
);
|
|
2500
|
-
}
|
|
2501
|
-
const baseline = run.baseline_id
|
|
2502
|
-
? db.prepare('SELECT id, status FROM baselines WHERE id = ?').get(run.baseline_id)
|
|
2503
|
-
: db.prepare("SELECT id, status FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC LIMIT 1").get();
|
|
2504
|
-
if (!baseline || report.baseline_id !== baseline.id || report.baseline_status !== baseline.status) {
|
|
2505
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_BASELINE_MISMATCH', 'delivery report baseline state is stale');
|
|
2506
|
-
}
|
|
2507
|
-
const checkout = assertCurrentCheckout(report, rootDir);
|
|
2508
|
-
assertIsoTimestamp(report.timestamp, 'delivery report timestamp');
|
|
2509
|
-
if (!Array.isArray(report.checks) || report.checks.length === 0
|
|
2510
|
-
|| report.checks.some((item) => item?.status !== 'pass')) {
|
|
2511
|
-
throw new WorkflowStateError('WORKFLOW_DELIVERY_CHECK_FAILED', 'delivery checks must all pass');
|
|
2512
|
-
}
|
|
2513
|
-
if (!String(report.rollback || '').trim()) {
|
|
2514
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'delivery report rollback guidance is required');
|
|
2515
|
-
}
|
|
2516
|
-
if (report.context_digest !== context.output.digest) {
|
|
2517
|
-
throw new WorkflowStateError(
|
|
2518
|
-
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'delivery report context_digest does not match the convergence context',
|
|
2519
|
-
);
|
|
2520
|
-
}
|
|
2521
|
-
return {
|
|
2522
|
-
change_id: run.change_id,
|
|
2523
|
-
archive_status: report.archive_status,
|
|
2524
|
-
baseline_id: baseline.id,
|
|
2525
|
-
baseline_status: baseline.status,
|
|
2526
|
-
git_commit: checkout.head,
|
|
2527
|
-
worktree_digest: checkout.digest,
|
|
2528
|
-
report_path: output.path,
|
|
2529
|
-
report_digest: output.digest,
|
|
2530
|
-
context_path: context.output.path,
|
|
2531
|
-
context_digest: context.output.digest,
|
|
2532
|
-
context_warnings: context.warnings,
|
|
2533
|
-
...gates,
|
|
2534
|
-
};
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
function validateStageCompletion(db, run, rootDir) {
|
|
2538
|
-
if (run.kind === 'plan') return validatePlanArtifact(db, run, rootDir);
|
|
2539
|
-
if (run.kind === 'change') return validateChangeCompletion(db, run, rootDir);
|
|
2540
|
-
if (run.kind === 'dev') return validateDevCompletion(db, run, rootDir);
|
|
2541
|
-
if (run.kind === 'test') return validateTestCompletion(db, run, rootDir);
|
|
2542
|
-
if (run.kind === 'review') return validateReviewCompletion(db, run, rootDir);
|
|
2543
|
-
if (run.kind === 'deliver') return validateDeliveryCompletion(db, run, rootDir);
|
|
2544
|
-
return {};
|
|
2545
|
-
}
|
|
2546
|
-
|
|
2547
|
-
function validatePlanArtifact(db, run, rootDir) {
|
|
2548
|
-
const context = recordedContext(db, run, rootDir, { current: false });
|
|
2549
|
-
const workflowStep = run.steps.find((item) => item.step_id === 'verify-plan');
|
|
2550
|
-
const outputs = workflowStep?.outputs || [];
|
|
2551
|
-
if (outputs.length !== 1) {
|
|
2552
|
-
throw new WorkflowStateError(
|
|
2553
|
-
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
2554
|
-
'verify-plan requires exactly one JSON execution-plan output',
|
|
2555
|
-
);
|
|
2556
|
-
}
|
|
2557
|
-
const output = outputs[0];
|
|
2558
|
-
const change = db.prepare('SELECT id, artifact_root FROM changes WHERE id = ?').get(run.change_id);
|
|
2559
|
-
const planStore = require('./plan-store.cjs');
|
|
2560
|
-
let actual;
|
|
2561
|
-
try {
|
|
2562
|
-
actual = planStore.loadChangePlanArtifact(rootDir, change, { db, strict: true });
|
|
2563
|
-
}
|
|
2564
|
-
catch (error) {
|
|
2565
|
-
throw new WorkflowStateError(
|
|
2566
|
-
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
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}`,
|
|
2576
|
-
);
|
|
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
|
-
});
|
|
2587
|
-
const tasks = changeTasks(db, run.change_id);
|
|
2588
|
-
const expected = planStore.buildPlan(tasks, { changeId: run.change_id });
|
|
2589
|
-
const { context: actualContext, ...actualPlan } = actual || {};
|
|
2590
|
-
if (JSON.stringify(actualPlan) !== JSON.stringify(expected)) {
|
|
2591
|
-
throw new WorkflowStateError(
|
|
2592
|
-
'WORKFLOW_PLAN_ARTIFACT_STALE',
|
|
2593
|
-
'execution-plan artifact does not match the current change task graph and contracts',
|
|
2594
|
-
{
|
|
2595
|
-
change_id: run.change_id,
|
|
2596
|
-
expected_task_ids: tasks.map((task) => task.id),
|
|
2597
|
-
actual_change_id: actual?.change_id || null,
|
|
2598
|
-
},
|
|
2599
|
-
);
|
|
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
|
-
}
|
|
2638
|
-
return {
|
|
2639
|
-
plan_path: output.path,
|
|
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,
|
|
2646
|
-
plan_schema_version: actual.schema_version,
|
|
2647
|
-
plan_wave_count: actual.waves.length,
|
|
2648
|
-
};
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
2652
|
-
const id = nonEmpty(input.id, 'id');
|
|
2653
|
-
return ops.tx(db, () => {
|
|
2654
|
-
const run = readWorkflow(db, id, { rootDir });
|
|
2655
|
-
if (!run) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
2656
|
-
if (run.status === 'completed') return run;
|
|
2657
|
-
if (run.status !== 'ready') {
|
|
2658
|
-
throw new WorkflowStateError('WORKFLOW_NOT_READY', `workflow ${id} is ${run.status}`);
|
|
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
|
-
}
|
|
2666
|
-
decisionDialogue.assertDecisionGate(db, {
|
|
2667
|
-
workflow_run_id: run.id,
|
|
2668
|
-
change_id: run.change_id,
|
|
2669
|
-
baseline_id: run.baseline_id,
|
|
2670
|
-
}, { rootDir });
|
|
2671
|
-
if (run.artifact_health.status !== 'pass') {
|
|
2672
|
-
throw new WorkflowStateError('WORKFLOW_OUTPUT_STALE', 'workflow outputs are missing or stale', run.artifact_health);
|
|
2673
|
-
}
|
|
2674
|
-
const approval = input.approval === undefined ? null : input.approval;
|
|
2675
|
-
if (approval !== null && (!approval || typeof approval !== 'object' || Array.isArray(approval))) {
|
|
2676
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'approval must be an object');
|
|
2677
|
-
}
|
|
2678
|
-
const suppliedSummary = input.summary === undefined ? {} : input.summary;
|
|
2679
|
-
if (!suppliedSummary || typeof suppliedSummary !== 'object' || Array.isArray(suppliedSummary)) {
|
|
2680
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'summary must be an object');
|
|
2681
|
-
}
|
|
2682
|
-
const unauthorizedSummaryFields = Object.keys(suppliedSummary);
|
|
2683
|
-
if (unauthorizedSummaryFields.length > 0) {
|
|
2684
|
-
throw new WorkflowStateError(
|
|
2685
|
-
'WORKFLOW_SUMMARY_AUTHORITY_VIOLATION',
|
|
2686
|
-
`workflow ${run.kind} summary is derived from DB-backed evidence; unsupported fields: ${unauthorizedSummaryFields.join(', ')}`,
|
|
2687
|
-
);
|
|
2688
|
-
}
|
|
2689
|
-
if (run.kind !== 'plan' && approval !== null) {
|
|
2690
|
-
throw new WorkflowStateError(
|
|
2691
|
-
'WORKFLOW_APPROVAL_AUTHORITY_VIOLATION',
|
|
2692
|
-
`workflow ${run.kind} does not accept completion approval`,
|
|
2693
|
-
);
|
|
2694
|
-
}
|
|
2695
|
-
let summary = {};
|
|
2696
|
-
if (run.kind === 'plan') {
|
|
2697
|
-
const contract = validatePlanContract(db, run, rootDir);
|
|
2698
|
-
summary = {
|
|
2699
|
-
task_ids: contract.task_ids,
|
|
2700
|
-
task_contract_digests: contract.task_contract_digests,
|
|
2701
|
-
profile: contract.profile,
|
|
2702
|
-
coverage: contract.coverage,
|
|
2703
|
-
authority_basis: 'accepted_change_contract',
|
|
2704
|
-
};
|
|
2705
|
-
if (approval !== null) validatePlanApproval(approval);
|
|
2706
|
-
}
|
|
2707
|
-
if (run.kind === 'dev' && ops.readTask(db, run.task_id)?.status !== 'completed') {
|
|
2708
|
-
throw new WorkflowStateError(
|
|
2709
|
-
'WORKFLOW_TASK_NOT_COMPLETED', `task ${run.task_id} must be completed before dev workflow convergence`,
|
|
2710
|
-
);
|
|
2711
|
-
}
|
|
2712
|
-
summary = { ...summary, ...validateStageCompletion(db, run, rootDir) };
|
|
2713
|
-
const ts = nowIso();
|
|
2714
|
-
db.prepare(
|
|
2715
|
-
`UPDATE workflow_runs SET status = 'completed', approval_json = ?, summary_json = ?,
|
|
2716
|
-
blockers_json = '[]', current_step = NULL, completed_at = ?, updated_at = ? WHERE id = ?`,
|
|
2717
|
-
).run(approval === null ? null : JSON.stringify(approval), JSON.stringify(summary), ts, ts, id);
|
|
2718
|
-
ops.appendEventInTx(db, {
|
|
2719
|
-
type: 'workflow_completed', task_id: run.task_id, change_id: run.change_id,
|
|
2720
|
-
payload: { workflow_id: id, kind: run.kind, baseline_id: run.baseline_id },
|
|
2721
|
-
});
|
|
2722
|
-
if (run.kind === 'plan' && approval !== null) {
|
|
2723
|
-
ops.appendEventInTx(db, {
|
|
2724
|
-
type: 'plan_approved', change_id: run.change_id,
|
|
2725
|
-
payload: {
|
|
2726
|
-
workflow_id: id,
|
|
2727
|
-
approved_by: approval.approved_by.trim(),
|
|
2728
|
-
approved_at: approval.approved_at || ts,
|
|
2729
|
-
plan_path: summary.plan_path,
|
|
2730
|
-
plan_digest: summary.plan_digest,
|
|
2731
|
-
task_ids: summary.task_ids,
|
|
2732
|
-
},
|
|
2733
|
-
});
|
|
2734
|
-
}
|
|
2735
|
-
return readWorkflow(db, id, { rootDir });
|
|
2736
|
-
});
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
function validatePlanApproval(approval) {
|
|
2740
|
-
const approvedBy = typeof approval?.approved_by === 'string' ? approval.approved_by.trim() : '';
|
|
2741
|
-
const note = typeof approval?.approval_note === 'string' ? approval.approval_note.trim() : '';
|
|
2742
|
-
if (!approvedBy || note.length < 3) {
|
|
2743
|
-
throw new WorkflowStateError(
|
|
2744
|
-
'WORKFLOW_APPROVAL_REQUIRED',
|
|
2745
|
-
'plan completion requires approved_by and an approval_note for the accepted scope',
|
|
2746
|
-
);
|
|
2747
|
-
}
|
|
2748
|
-
if (approval.approved_at !== undefined
|
|
2749
|
-
&& (typeof approval.approved_at !== 'string' || Number.isNaN(Date.parse(approval.approved_at)))) {
|
|
2750
|
-
throw new WorkflowStateError('VALIDATION_ERROR', 'approval.approved_at must be an ISO-8601 timestamp');
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
|
|
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(', ');
|
|
2765
|
-
return db.prepare(
|
|
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) => (
|
|
2769
|
-
parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
|
|
2770
|
-
));
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2773
|
-
function validateTaskTrace(task, acceptedSources, rootDir) {
|
|
2774
|
-
const trace = String(task.trace_to || '').trim();
|
|
2775
|
-
if (acceptedSources.has(trace)) return;
|
|
2776
|
-
try {
|
|
2777
|
-
sourceRefFile(rootDir, trace, `task ${task.id}.trace_to`);
|
|
2778
|
-
} catch (error) {
|
|
2779
|
-
throw new WorkflowStateError(
|
|
2780
|
-
'WORKFLOW_PLAN_ORPHAN_TASK',
|
|
2781
|
-
`task ${task.id} trace_to does not resolve to an accepted requirement or source anchor: ${trace}`,
|
|
2782
|
-
);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
function validatePlanContract(db, run, rootDir = process.cwd()) {
|
|
2787
|
-
const taskIds = changeTasks(db, run.change_id).map((task) => task.id);
|
|
2788
|
-
if (taskIds.length === 0) {
|
|
2789
|
-
throw new WorkflowStateError('WORKFLOW_TASKS_REQUIRED', 'plan completion requires task_ids');
|
|
2790
|
-
}
|
|
2791
|
-
const tasks = taskIds.map((id) => {
|
|
2792
|
-
const task = ops.readTask(db, id);
|
|
2793
|
-
if (!task) throw new WorkflowStateError('TASK_NOT_FOUND', `planned task ${id} does not exist`);
|
|
2794
|
-
if (run.change_id && task.change_id !== run.change_id) {
|
|
2795
|
-
throw new WorkflowStateError(
|
|
2796
|
-
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
2797
|
-
`planned task ${id} is not owned by change ${run.change_id}`,
|
|
2798
|
-
);
|
|
2799
|
-
}
|
|
2800
|
-
ops.assertTaskExecutionContract(task);
|
|
2801
|
-
if (task.stale) {
|
|
2802
|
-
throw new WorkflowStateError(
|
|
2803
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
2804
|
-
`task ${id} was invalidated by newer change authority and must be reconciled`,
|
|
2805
|
-
);
|
|
2806
|
-
}
|
|
2807
|
-
return task;
|
|
2808
|
-
});
|
|
2809
|
-
const changeRow = db.prepare(
|
|
2810
|
-
'SELECT kind, contract_json FROM changes WHERE id = ?',
|
|
2811
|
-
).get(run.change_id);
|
|
2812
|
-
const profile = changeRow?.kind || 'standard';
|
|
2813
|
-
if (profile === 'quick' && tasks.length !== 1) {
|
|
2814
|
-
throw new WorkflowStateError(
|
|
2815
|
-
'WORKFLOW_QUICK_PLAN_TOO_LARGE', 'quick changes require exactly one executable task',
|
|
2816
|
-
);
|
|
2817
|
-
}
|
|
2818
|
-
const changeContract = parseJson(changeRow?.contract_json || '{}', 'changes.contract_json', {});
|
|
2819
|
-
const requiredAcceptance = (changeContract.acceptance || []).map((item) => item.id);
|
|
2820
|
-
const coveredAcceptance = new Set(tasks.flatMap((task) => (
|
|
2821
|
-
(task.acceptance || []).map((item) => item.id)
|
|
2822
|
-
)));
|
|
2823
|
-
const missingAcceptance = requiredAcceptance.filter((id) => !coveredAcceptance.has(id));
|
|
2824
|
-
if (missingAcceptance.length > 0) {
|
|
2825
|
-
throw new WorkflowStateError(
|
|
2826
|
-
'WORKFLOW_PLAN_COVERAGE_INCOMPLETE',
|
|
2827
|
-
`change acceptance has no executable task coverage: ${missingAcceptance.join(', ')}`,
|
|
2828
|
-
);
|
|
2829
|
-
}
|
|
2830
|
-
const semantics = completedResearchSemantics(db, run.change_id);
|
|
2831
|
-
const acceptedSources = new Set([
|
|
2832
|
-
...requiredAcceptance,
|
|
2833
|
-
...semantics.map((record) => record.id),
|
|
2834
|
-
...semantics.map((record) => record.source_ref),
|
|
2835
|
-
]);
|
|
2836
|
-
for (const task of tasks) validateTaskTrace(task, acceptedSources, rootDir);
|
|
2837
|
-
const planned = new Set(taskIds);
|
|
2838
|
-
for (const task of tasks) {
|
|
2839
|
-
for (const dep of task.deps || []) {
|
|
2840
|
-
if (!ops.readTask(db, dep)) {
|
|
2841
|
-
throw new WorkflowStateError('TASK_DEPENDENCY_MISSING', `task ${task.id} depends on missing task ${dep}`);
|
|
2842
|
-
}
|
|
2843
|
-
}
|
|
2844
|
-
}
|
|
2845
|
-
const visiting = new Set();
|
|
2846
|
-
const visited = new Set();
|
|
2847
|
-
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
2848
|
-
function visit(id) {
|
|
2849
|
-
if (visiting.has(id)) throw new WorkflowStateError('TASK_DEPENDENCY_CYCLE', `planned task dependency cycle includes ${id}`);
|
|
2850
|
-
if (visited.has(id)) return;
|
|
2851
|
-
visiting.add(id);
|
|
2852
|
-
for (const dep of byId.get(id)?.deps || []) if (planned.has(dep)) visit(dep);
|
|
2853
|
-
visiting.delete(id);
|
|
2854
|
-
visited.add(id);
|
|
2855
|
-
}
|
|
2856
|
-
for (const id of taskIds) visit(id);
|
|
2857
|
-
return {
|
|
2858
|
-
task_ids: taskIds,
|
|
2859
|
-
profile,
|
|
2860
|
-
coverage: {
|
|
2861
|
-
required_acceptance_ids: requiredAcceptance,
|
|
2862
|
-
covered_acceptance_ids: requiredAcceptance.filter((id) => coveredAcceptance.has(id)),
|
|
2863
|
-
orphan_task_ids: [],
|
|
2864
|
-
},
|
|
2865
|
-
task_contract_digests: Object.fromEntries(
|
|
2866
|
-
tasks.map((task) => [task.id, taskPlanDigest(task)]),
|
|
2867
|
-
),
|
|
2868
|
-
};
|
|
2869
|
-
}
|
|
2870
|
-
|
|
2871
|
-
function inspectWorkflowHealth(db, { rootDir = process.cwd() } = {}) {
|
|
2872
|
-
const all = listWorkflows(db, { limit: 500 }, { rootDir });
|
|
2873
|
-
const active = all
|
|
2874
|
-
.filter((run) => ['active', 'blocked', 'ready'].includes(run.status));
|
|
2875
|
-
const stale = active.filter((run) => run.artifact_health.status !== 'pass');
|
|
2876
|
-
const historicalStale = all.filter(
|
|
2877
|
-
(run) => run.status === 'completed' && run.artifact_health.status !== 'pass',
|
|
2878
|
-
);
|
|
2879
|
-
const terminalAuthority = db.prepare(
|
|
2880
|
-
`SELECT wr.id, wr.kind, wr.status, wr.change_id, c.status AS change_status
|
|
2881
|
-
FROM workflow_runs wr JOIN changes c ON c.id = wr.change_id
|
|
2882
|
-
WHERE wr.status IN ('active', 'blocked', 'ready')
|
|
2883
|
-
AND c.status IN ('archived', 'cancelled')
|
|
2884
|
-
ORDER BY wr.updated_at DESC`,
|
|
2885
|
-
).all();
|
|
2886
|
-
const untrackedChanges = db.prepare(
|
|
2887
|
-
`SELECT c.id FROM changes c
|
|
2888
|
-
WHERE c.status IN ('active', 'blocked', 'ready')
|
|
2889
|
-
AND NOT EXISTS (SELECT 1 FROM workflow_runs wr WHERE wr.kind = 'change' AND wr.change_id = c.id)
|
|
2890
|
-
ORDER BY c.updated_at DESC`,
|
|
2891
|
-
).all().map((row) => row.id);
|
|
2892
|
-
const structuralAuthorityFailure = terminalAuthority.length > 0 || untrackedChanges.length > 0;
|
|
2893
|
-
return {
|
|
2894
|
-
status: stale.length === 0 && !structuralAuthorityFailure ? 'pass' : 'fail',
|
|
2895
|
-
active: active.length,
|
|
2896
|
-
blocked: active.filter((run) => run.status === 'blocked').length,
|
|
2897
|
-
ready: active.filter((run) => run.status === 'ready').length,
|
|
2898
|
-
stale_outputs: stale.map((run) => ({ id: run.id, blockers: run.artifact_health.blockers })),
|
|
2899
|
-
historical_stale_outputs: historicalStale.map(
|
|
2900
|
-
(run) => ({ id: run.id, blockers: run.artifact_health.blockers }),
|
|
2901
|
-
),
|
|
2902
|
-
terminal_authority_runs: terminalAuthority,
|
|
2903
|
-
untracked_active_changes: untrackedChanges,
|
|
2904
|
-
};
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
function recoverUntrackedChangeWorkflows(db, { rootDir = process.cwd() } = {}) {
|
|
2908
|
-
const changes = db.prepare(
|
|
2909
|
-
`SELECT id, title FROM changes c
|
|
2910
|
-
WHERE c.status IN ('active', 'blocked', 'ready')
|
|
2911
|
-
AND NOT EXISTS (
|
|
2912
|
-
SELECT 1 FROM workflow_runs wr WHERE wr.kind = 'change' AND wr.change_id = c.id
|
|
2913
|
-
)
|
|
2914
|
-
ORDER BY c.updated_at ASC, c.id ASC`,
|
|
2915
|
-
).all();
|
|
2916
|
-
const baseline = db.prepare(
|
|
2917
|
-
"SELECT id FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC, rowid DESC LIMIT 1",
|
|
2918
|
-
).get();
|
|
2919
|
-
const result = { found: changes.length, created: 0, failed: 0, items: [] };
|
|
2920
|
-
for (const change of changes) {
|
|
2921
|
-
let run = null;
|
|
2922
|
-
try {
|
|
2923
|
-
run = startWorkflow(db, {
|
|
2924
|
-
kind: 'change', baseline_id: baseline?.id || null, change_id: change.id,
|
|
2925
|
-
subject: `Recover durable workflow provenance for ${change.title}.`,
|
|
2926
|
-
metadata: { recovery: 'legacy_untracked_change' },
|
|
2927
|
-
}, { rootDir });
|
|
2928
|
-
run = recordWorkflowStep(db, {
|
|
2929
|
-
id: run.id, step_id: 'bind-baseline', status: 'blocked',
|
|
2930
|
-
blockers: ['LEGACY_CHANGE_PROVENANCE_REQUIRED'],
|
|
2931
|
-
}, { rootDir });
|
|
2932
|
-
result.created += 1;
|
|
2933
|
-
result.items.push({ change_id: change.id, workflow_id: run.id, status: run.status });
|
|
2934
|
-
} catch (error) {
|
|
2935
|
-
if (run?.id) db.prepare('DELETE FROM workflow_runs WHERE id = ?').run(run.id);
|
|
2936
|
-
result.failed += 1;
|
|
2937
|
-
result.items.push({ change_id: change.id, status: 'failed', error: error.code || error.message });
|
|
2938
|
-
}
|
|
2939
|
-
}
|
|
2940
|
-
return result;
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
module.exports = {
|
|
2944
|
-
DEFINITION_VERSION,
|
|
2945
|
-
WORKFLOW_DEFINITIONS,
|
|
2946
|
-
RUN_KINDS,
|
|
2947
|
-
RUN_STATUSES,
|
|
2948
|
-
WorkflowStateError,
|
|
2949
|
-
insertWorkflowInTx,
|
|
2950
|
-
startWorkflow,
|
|
2951
|
-
readWorkflow,
|
|
2952
|
-
listWorkflows,
|
|
2953
|
-
recordWorkflowStep,
|
|
2954
|
-
reviseWorkflow,
|
|
2955
|
-
supersedeWorkflow,
|
|
2956
|
-
completeWorkflow,
|
|
2957
|
-
inspectWorkflowHealth,
|
|
2958
|
-
recoverUntrackedChangeWorkflows,
|
|
2959
|
-
validateDeliveryPrerequisites,
|
|
2960
|
-
validatePlanContract,
|
|
2961
|
-
assertCurrentPlan,
|
|
2962
|
-
assertApprovedReview,
|
|
2963
|
-
isConsumableWorkflowAuthority,
|
|
2964
|
-
resolveProjectSourceRef: sourceRefFile,
|
|
2965
|
-
};
|