switchman-dev 0.1.6 → 0.1.7
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/README.md +171 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2801 -332
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
package/src/core/pipeline.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'child_process';
|
|
2
|
-
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { basename, join } from 'path';
|
|
4
5
|
|
|
5
|
-
import { completeLeaseTask, createTask, failLeaseTask, getTaskSpec, listAuditEvents, listLeases, listTasks, listWorktrees, logAuditEvent, retryTask, startTaskLease, upsertTaskSpec } from './db.js';
|
|
6
|
+
import { completeLeaseTask, createTask, createTempResource, failLeaseTask, finishOperationJournalEntry, getTaskSpec, listAuditEvents, listBoundaryValidationStates, listDependencyInvalidations, listLeases, listMergeQueue, listPolicyOverrides, listTasks, listTempResources, listWorktrees, logAuditEvent, retryTask, startOperationJournalEntry, startTaskLease, updateTempResource, upsertTaskSpec } from './db.js';
|
|
6
7
|
import { scanAllWorktrees } from './detector.js';
|
|
7
8
|
import { runAiMergeGate } from './merge-gate.js';
|
|
8
9
|
import { evaluateTaskOutcome } from './outcome.js';
|
|
10
|
+
import { loadChangePolicy } from './policy.js';
|
|
9
11
|
import { buildTaskSpec, planPipelineTasks } from './planner.js';
|
|
10
|
-
import { getWorktreeBranch } from './git.js';
|
|
12
|
+
import { getWorktreeBranch, gitBranchExists, gitMaterializeIntegrationBranch, gitPrepareIntegrationRecoveryWorktree, gitRemoveWorktree, gitRevParse, listGitWorktrees } from './git.js';
|
|
11
13
|
|
|
12
14
|
function sleepSync(ms) {
|
|
13
15
|
if (ms > 0) {
|
|
@@ -37,6 +39,10 @@ function parseDependencies(description) {
|
|
|
37
39
|
.filter(Boolean);
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
function stringifyResourceDetails(details) {
|
|
43
|
+
return JSON.stringify(details);
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
function getPipelineMetadata(db, pipelineId) {
|
|
41
47
|
const events = listAuditEvents(db, { eventType: 'pipeline_created', limit: 500 });
|
|
42
48
|
for (const event of events) {
|
|
@@ -52,6 +58,276 @@ function getPipelineMetadata(db, pipelineId) {
|
|
|
52
58
|
return null;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
function parseAuditDetails(details) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(details || '{}');
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pipelineOwnsAuditEvent(event, pipelineId) {
|
|
70
|
+
if (event.task_id?.startsWith(`${pipelineId}-`)) return true;
|
|
71
|
+
const details = parseAuditDetails(event.details);
|
|
72
|
+
if (details.pipeline_id === pipelineId) return true;
|
|
73
|
+
if (details.source_pipeline_id === pipelineId) return true;
|
|
74
|
+
if (Array.isArray(details.task_ids) && details.task_ids.some((taskId) => String(taskId).startsWith(`${pipelineId}-`))) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildPipelineTrustAudit(db, pipelineId, { limit = 8 } = {}) {
|
|
81
|
+
const interestingEventTypes = new Set([
|
|
82
|
+
'boundary_validation_state',
|
|
83
|
+
'dependency_invalidations_updated',
|
|
84
|
+
'pipeline_followups_created',
|
|
85
|
+
'policy_override_created',
|
|
86
|
+
'policy_override_revoked',
|
|
87
|
+
'task_retried',
|
|
88
|
+
'pipeline_task_retry_scheduled',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const events = listAuditEvents(db, { limit: 2000 })
|
|
92
|
+
.filter((event) => interestingEventTypes.has(event.event_type))
|
|
93
|
+
.filter((event) => pipelineOwnsAuditEvent(event, pipelineId))
|
|
94
|
+
.slice(0, limit);
|
|
95
|
+
|
|
96
|
+
const mappedEvents = events.map((event) => {
|
|
97
|
+
const details = parseAuditDetails(event.details);
|
|
98
|
+
|
|
99
|
+
switch (event.event_type) {
|
|
100
|
+
case 'boundary_validation_state': {
|
|
101
|
+
const missing = details.missing_task_types || [];
|
|
102
|
+
const summary = details.status === 'satisfied'
|
|
103
|
+
? 'Policy validation requirements are currently satisfied.'
|
|
104
|
+
: missing.length > 0
|
|
105
|
+
? `Policy validation is waiting on ${missing.join(', ')}.`
|
|
106
|
+
: `Policy validation state changed to ${details.status || 'pending'}.`;
|
|
107
|
+
return {
|
|
108
|
+
category: 'policy',
|
|
109
|
+
created_at: event.created_at,
|
|
110
|
+
event_type: event.event_type,
|
|
111
|
+
status: event.status,
|
|
112
|
+
reason_code: event.reason_code || null,
|
|
113
|
+
summary,
|
|
114
|
+
missing_task_types: missing,
|
|
115
|
+
next_action: missing.length > 0 ? `switchman pipeline review ${pipelineId}` : `switchman pipeline status ${pipelineId}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
case 'dependency_invalidations_updated':
|
|
119
|
+
{
|
|
120
|
+
const reasonTypes = details.reason_types || [];
|
|
121
|
+
const revalidationSets = details.revalidation_sets || [];
|
|
122
|
+
const reasonSummary = revalidationSets.length > 0
|
|
123
|
+
? ` across ${revalidationSets.join(', ')} revalidation`
|
|
124
|
+
: reasonTypes.length > 0
|
|
125
|
+
? ` across ${reasonTypes.join(', ')}`
|
|
126
|
+
: '';
|
|
127
|
+
return {
|
|
128
|
+
category: 'stale',
|
|
129
|
+
created_at: event.created_at,
|
|
130
|
+
event_type: event.event_type,
|
|
131
|
+
status: event.status,
|
|
132
|
+
reason_code: event.reason_code || null,
|
|
133
|
+
summary: `A shared change marked ${details.stale_count || 0} dependent task${details.stale_count === 1 ? '' : 's'} stale${reasonSummary}.`,
|
|
134
|
+
affected_task_ids: details.affected_task_ids || [],
|
|
135
|
+
next_action: `switchman explain stale --pipeline ${pipelineId}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
case 'pipeline_followups_created':
|
|
139
|
+
return {
|
|
140
|
+
category: 'remediation',
|
|
141
|
+
created_at: event.created_at,
|
|
142
|
+
event_type: event.event_type,
|
|
143
|
+
status: event.status,
|
|
144
|
+
reason_code: event.reason_code || null,
|
|
145
|
+
summary: details.created_count > 0
|
|
146
|
+
? `Created ${details.created_count} follow-up task${details.created_count === 1 ? '' : 's'} to satisfy validation or policy work.`
|
|
147
|
+
: 'No new follow-up tasks were needed after review.',
|
|
148
|
+
created_count: details.created_count || 0,
|
|
149
|
+
next_action: details.created_count > 0 ? `switchman pipeline status ${pipelineId}` : `switchman pipeline status ${pipelineId}`,
|
|
150
|
+
};
|
|
151
|
+
case 'policy_override_created':
|
|
152
|
+
return {
|
|
153
|
+
category: 'policy',
|
|
154
|
+
created_at: event.created_at,
|
|
155
|
+
event_type: event.event_type,
|
|
156
|
+
status: event.status,
|
|
157
|
+
reason_code: event.reason_code || null,
|
|
158
|
+
summary: `Policy override ${details.override_id || 'unknown'} was approved${details.task_types?.length ? ` for ${details.task_types.join(', ')}` : ''}${details.reason ? `: ${details.reason}` : ''}.`,
|
|
159
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
160
|
+
};
|
|
161
|
+
case 'policy_override_revoked':
|
|
162
|
+
return {
|
|
163
|
+
category: 'policy',
|
|
164
|
+
created_at: event.created_at,
|
|
165
|
+
event_type: event.event_type,
|
|
166
|
+
status: event.status,
|
|
167
|
+
reason_code: event.reason_code || null,
|
|
168
|
+
summary: `Policy override ${details.override_id || 'unknown'} was revoked${details.revoked_reason ? `: ${details.revoked_reason}` : ''}.`,
|
|
169
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
170
|
+
};
|
|
171
|
+
case 'task_retried':
|
|
172
|
+
case 'pipeline_task_retry_scheduled':
|
|
173
|
+
return {
|
|
174
|
+
category: 'remediation',
|
|
175
|
+
created_at: event.created_at,
|
|
176
|
+
event_type: event.event_type,
|
|
177
|
+
status: event.status,
|
|
178
|
+
reason_code: event.reason_code || null,
|
|
179
|
+
summary: `Scheduled revalidation for ${event.task_id || 'a pipeline task'}.`,
|
|
180
|
+
task_id: event.task_id || null,
|
|
181
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
182
|
+
};
|
|
183
|
+
default:
|
|
184
|
+
return {
|
|
185
|
+
category: 'trust',
|
|
186
|
+
created_at: event.created_at,
|
|
187
|
+
event_type: event.event_type,
|
|
188
|
+
status: event.status,
|
|
189
|
+
reason_code: event.reason_code || null,
|
|
190
|
+
summary: event.event_type,
|
|
191
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const staleClusterEntries = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId })
|
|
197
|
+
.map((cluster) => ({
|
|
198
|
+
category: 'stale',
|
|
199
|
+
created_at: cluster.invalidations[0]?.created_at || null,
|
|
200
|
+
event_type: 'stale_cluster_active',
|
|
201
|
+
status: cluster.severity === 'block' ? 'denied' : 'warn',
|
|
202
|
+
reason_code: 'dependent_work_stale',
|
|
203
|
+
summary: `${cluster.title}.`,
|
|
204
|
+
affected_task_ids: cluster.affected_task_ids,
|
|
205
|
+
next_action: cluster.next_action,
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const staleWaveEntries = summarizeStaleCausalWaves(
|
|
209
|
+
buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId }),
|
|
210
|
+
).map((wave) => ({
|
|
211
|
+
category: 'stale',
|
|
212
|
+
created_at: null,
|
|
213
|
+
event_type: 'stale_wave_active',
|
|
214
|
+
status: 'warn',
|
|
215
|
+
reason_code: 'dependent_work_stale',
|
|
216
|
+
summary: `${wave.summary}. Affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} across ${wave.cluster_count} stale cluster${wave.cluster_count === 1 ? '' : 's'}.`,
|
|
217
|
+
affected_pipeline_ids: wave.affected_pipeline_ids,
|
|
218
|
+
next_action: `switchman explain stale --pipeline ${pipelineId}`,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
return [...mappedEvents, ...staleWaveEntries, ...staleClusterEntries]
|
|
222
|
+
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
|
|
223
|
+
.slice(0, limit);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildPipelinePolicyEvidence(status, requiredTaskTypes = [], { completedLeaseByTask = new Map(), completionAuditByTask = new Map() } = {}) {
|
|
227
|
+
const completedTasks = (status.tasks || []).filter((task) => task.status === 'done' && task.task_spec?.task_type);
|
|
228
|
+
const pipelineId = status.pipeline_id;
|
|
229
|
+
const evidenceObjects = [];
|
|
230
|
+
const evidenceByTaskType = {};
|
|
231
|
+
|
|
232
|
+
for (const taskType of requiredTaskTypes) {
|
|
233
|
+
evidenceByTaskType[taskType] = completedTasks
|
|
234
|
+
.filter((task) => task.task_spec?.task_type === taskType)
|
|
235
|
+
.map((task) => {
|
|
236
|
+
const lease = completedLeaseByTask.get(task.id) || null;
|
|
237
|
+
const auditEvent = completionAuditByTask.get(task.id) || null;
|
|
238
|
+
const evidence = {
|
|
239
|
+
evidence_id: `policy-evidence:${pipelineId}:${taskType}:${task.id}`,
|
|
240
|
+
schema_version: 1,
|
|
241
|
+
requirement_key: `completed_task_type:${taskType}`,
|
|
242
|
+
requirement_type: 'completed_task_type',
|
|
243
|
+
pipeline_id: pipelineId,
|
|
244
|
+
task_id: task.id,
|
|
245
|
+
title: task.title,
|
|
246
|
+
task_type: task.task_spec?.task_type || null,
|
|
247
|
+
worktree: task.worktree || task.suggested_worktree || null,
|
|
248
|
+
artifact_path: task.task_spec?.primary_output_path || null,
|
|
249
|
+
subsystem_tags: task.task_spec?.subsystem_tags || [],
|
|
250
|
+
satisfied_at: task.completed_at || auditEvent?.created_at || null,
|
|
251
|
+
satisfied_by: {
|
|
252
|
+
actor_type: lease?.agent ? 'agent' : 'task_completion',
|
|
253
|
+
agent: lease?.agent || null,
|
|
254
|
+
worktree: lease?.worktree || task.worktree || task.suggested_worktree || null,
|
|
255
|
+
lease_id: lease?.id || null,
|
|
256
|
+
audit_event_id: auditEvent?.id || null,
|
|
257
|
+
audit_event_type: auditEvent?.event_type || null,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
evidenceObjects.push(evidence);
|
|
261
|
+
return evidence;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
evidence_by_task_type: evidenceByTaskType,
|
|
267
|
+
evidence_objects: evidenceObjects,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildPipelinePolicySatisfactionHistory(policyState) {
|
|
272
|
+
return (policyState.evidence_objects || [])
|
|
273
|
+
.map((evidence) => ({
|
|
274
|
+
category: 'policy',
|
|
275
|
+
created_at: evidence.satisfied_at || null,
|
|
276
|
+
event_type: 'policy_requirement_satisfied',
|
|
277
|
+
status: 'allowed',
|
|
278
|
+
reason_code: null,
|
|
279
|
+
summary: `Policy requirement ${evidence.requirement_key} is satisfied by ${evidence.task_id}.`,
|
|
280
|
+
requirement_key: evidence.requirement_key,
|
|
281
|
+
evidence_id: evidence.evidence_id,
|
|
282
|
+
task_id: evidence.task_id,
|
|
283
|
+
satisfied_by: evidence.satisfied_by,
|
|
284
|
+
next_action: `switchman pipeline status ${policyState.pipeline_id || '<pipelineId>'}`,
|
|
285
|
+
}))
|
|
286
|
+
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildPipelinePolicyOverrideState(db, pipelineId, requiredTaskTypes = []) {
|
|
290
|
+
if (!db || !pipelineId) {
|
|
291
|
+
return {
|
|
292
|
+
active_overrides: [],
|
|
293
|
+
active_task_types: [],
|
|
294
|
+
active_requirement_keys: [],
|
|
295
|
+
override_history: [],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const overrides = listPolicyOverrides(db, { pipelineId, limit: 200 });
|
|
300
|
+
const activeOverrides = overrides.filter((entry) => entry.status === 'active');
|
|
301
|
+
const activeTaskTypes = uniq(activeOverrides.flatMap((entry) => entry.task_types || []));
|
|
302
|
+
const activeRequirementKeys = uniq(activeOverrides.flatMap((entry) => entry.requirement_keys || []));
|
|
303
|
+
|
|
304
|
+
const overrideHistory = overrides.map((entry) => ({
|
|
305
|
+
category: 'policy',
|
|
306
|
+
created_at: entry.status === 'active' ? entry.created_at : (entry.revoked_at || entry.created_at),
|
|
307
|
+
event_type: entry.status === 'active' ? 'policy_override_active' : 'policy_override_revoked',
|
|
308
|
+
status: entry.status === 'active' ? 'warn' : 'info',
|
|
309
|
+
reason_code: entry.status === 'active' ? 'policy_override' : 'policy_override_revoked',
|
|
310
|
+
override_id: entry.id,
|
|
311
|
+
task_types: entry.task_types || [],
|
|
312
|
+
requirement_keys: entry.requirement_keys || [],
|
|
313
|
+
approved_by: entry.approved_by || null,
|
|
314
|
+
summary: entry.status === 'active'
|
|
315
|
+
? `Policy override ${entry.id} allows ${entry.task_types?.join(', ') || 'governed requirements'} for pipeline ${pipelineId}.`
|
|
316
|
+
: `Policy override ${entry.id} was revoked${entry.revoked_reason ? `: ${entry.revoked_reason}` : ''}.`,
|
|
317
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
const missingButOverridden = requiredTaskTypes.filter((taskType) => activeTaskTypes.includes(taskType));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
active_overrides: activeOverrides,
|
|
324
|
+
active_task_types: activeTaskTypes,
|
|
325
|
+
active_requirement_keys: activeRequirementKeys,
|
|
326
|
+
missing_but_overridden: missingButOverridden,
|
|
327
|
+
override_history: overrideHistory,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
55
331
|
function withTaskSpec(db, task) {
|
|
56
332
|
return {
|
|
57
333
|
...task,
|
|
@@ -67,6 +343,373 @@ function nextPipelineTaskId(tasks, pipelineId) {
|
|
|
67
343
|
return `${pipelineId}-${String(nextNumber).padStart(2, '0')}`;
|
|
68
344
|
}
|
|
69
345
|
|
|
346
|
+
function rankEnforcement(level = 'none') {
|
|
347
|
+
return level === 'blocked' ? 2 : level === 'warn' ? 1 : 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function normalizeDependencyInvalidation(item) {
|
|
351
|
+
const details = item.details || {};
|
|
352
|
+
const objectNames = Array.isArray(details.object_names) ? details.object_names : [];
|
|
353
|
+
const contractNames = Array.isArray(details.contract_names) ? details.contract_names : [];
|
|
354
|
+
const modulePaths = Array.isArray(details.module_paths) ? details.module_paths : [];
|
|
355
|
+
return {
|
|
356
|
+
...item,
|
|
357
|
+
severity: item.severity || details.severity || (item.reason_type === 'semantic_contract_drift' ? 'blocked' : 'warn'),
|
|
358
|
+
details,
|
|
359
|
+
revalidation_set: details.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
|
|
360
|
+
stale_area: item.reason_type === 'subsystem_overlap'
|
|
361
|
+
? `subsystem:${item.subsystem_tag}`
|
|
362
|
+
: item.reason_type === 'semantic_contract_drift'
|
|
363
|
+
? `contract:${contractNames.join('|') || 'unknown'}`
|
|
364
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
365
|
+
? `object:${objectNames.join('|') || 'unknown'}`
|
|
366
|
+
: item.reason_type === 'shared_module_drift'
|
|
367
|
+
? `module:${modulePaths.join('|') || 'unknown'}`
|
|
368
|
+
: `${item.source_scope_pattern} ↔ ${item.affected_scope_pattern}`,
|
|
369
|
+
summary: item.reason_type === 'semantic_contract_drift'
|
|
370
|
+
? `${details?.source_task_title || item.source_task_id} changed shared contract ${contractNames.join(', ') || 'unknown'}`
|
|
371
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
372
|
+
? `${details?.source_task_title || item.source_task_id} changed shared exported object ${objectNames.join(', ') || 'unknown'}`
|
|
373
|
+
: item.reason_type === 'shared_module_drift'
|
|
374
|
+
? `${details?.source_task_title || item.source_task_id} changed shared module ${modulePaths.join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || item.affected_task_id}`
|
|
375
|
+
: `${details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildStaleClusters(invalidations = [], { pipelineId = null } = {}) {
|
|
380
|
+
const clusters = new Map();
|
|
381
|
+
for (const invalidation of invalidations.map(normalizeDependencyInvalidation)) {
|
|
382
|
+
if (pipelineId && invalidation.affected_pipeline_id !== pipelineId) continue;
|
|
383
|
+
const clusterKey = invalidation.affected_pipeline_id
|
|
384
|
+
? `pipeline:${invalidation.affected_pipeline_id}`
|
|
385
|
+
: `task:${invalidation.affected_task_id}`;
|
|
386
|
+
if (!clusters.has(clusterKey)) {
|
|
387
|
+
clusters.set(clusterKey, {
|
|
388
|
+
key: clusterKey,
|
|
389
|
+
affected_pipeline_id: invalidation.affected_pipeline_id || null,
|
|
390
|
+
affected_task_ids: new Set(),
|
|
391
|
+
source_task_titles: new Set(),
|
|
392
|
+
source_worktrees: new Set(),
|
|
393
|
+
stale_areas: new Set(),
|
|
394
|
+
revalidation_sets: new Set(),
|
|
395
|
+
dependent_files: new Set(),
|
|
396
|
+
dependent_areas: new Set(),
|
|
397
|
+
module_paths: new Set(),
|
|
398
|
+
invalidations: [],
|
|
399
|
+
severity: 'warn',
|
|
400
|
+
highest_affected_priority: 0,
|
|
401
|
+
highest_source_priority: 0,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const cluster = clusters.get(clusterKey);
|
|
405
|
+
cluster.invalidations.push(invalidation);
|
|
406
|
+
cluster.affected_task_ids.add(invalidation.affected_task_id);
|
|
407
|
+
if (invalidation.details?.source_task_title) cluster.source_task_titles.add(invalidation.details.source_task_title);
|
|
408
|
+
if (invalidation.source_worktree) cluster.source_worktrees.add(invalidation.source_worktree);
|
|
409
|
+
cluster.stale_areas.add(invalidation.stale_area);
|
|
410
|
+
if (invalidation.revalidation_set) cluster.revalidation_sets.add(invalidation.revalidation_set);
|
|
411
|
+
for (const filePath of invalidation.details?.dependent_files || []) cluster.dependent_files.add(filePath);
|
|
412
|
+
for (const area of invalidation.details?.dependent_areas || []) cluster.dependent_areas.add(area);
|
|
413
|
+
for (const modulePath of invalidation.details?.module_paths || []) cluster.module_paths.add(modulePath);
|
|
414
|
+
if (invalidation.severity === 'blocked') cluster.severity = 'block';
|
|
415
|
+
cluster.highest_affected_priority = Math.max(cluster.highest_affected_priority, Number(invalidation.details?.affected_task_priority || 0));
|
|
416
|
+
cluster.highest_source_priority = Math.max(cluster.highest_source_priority, Number(invalidation.details?.source_task_priority || 0));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const clusterEntries = [...clusters.values()].map((cluster) => ({
|
|
420
|
+
key: cluster.key,
|
|
421
|
+
affected_pipeline_id: cluster.affected_pipeline_id,
|
|
422
|
+
affected_task_ids: [...cluster.affected_task_ids],
|
|
423
|
+
invalidation_count: cluster.invalidations.length,
|
|
424
|
+
source_task_ids: [...new Set(cluster.invalidations.map((item) => item.source_task_id).filter(Boolean))],
|
|
425
|
+
source_pipeline_ids: [...new Set(cluster.invalidations.map((item) => item.source_pipeline_id).filter(Boolean))],
|
|
426
|
+
source_task_titles: [...cluster.source_task_titles],
|
|
427
|
+
source_worktrees: [...cluster.source_worktrees],
|
|
428
|
+
stale_areas: [...cluster.stale_areas],
|
|
429
|
+
revalidation_sets: [...cluster.revalidation_sets],
|
|
430
|
+
dependent_files: [...cluster.dependent_files],
|
|
431
|
+
dependent_areas: [...cluster.dependent_areas],
|
|
432
|
+
module_paths: [...cluster.module_paths],
|
|
433
|
+
revalidation_set_type: cluster.revalidation_sets.has('contract')
|
|
434
|
+
? 'contract'
|
|
435
|
+
: cluster.revalidation_sets.has('shared_module')
|
|
436
|
+
? 'shared_module'
|
|
437
|
+
: cluster.revalidation_sets.has('semantic_object')
|
|
438
|
+
? 'semantic_object'
|
|
439
|
+
: cluster.revalidation_sets.has('subsystem')
|
|
440
|
+
? 'subsystem'
|
|
441
|
+
: 'scope',
|
|
442
|
+
rerun_priority: cluster.severity === 'block'
|
|
443
|
+
? (cluster.revalidation_sets.has('contract') || cluster.highest_affected_priority >= 8 ? 'urgent' : 'high')
|
|
444
|
+
: cluster.revalidation_sets.has('shared_module') && cluster.dependent_files.size >= 3
|
|
445
|
+
? 'high'
|
|
446
|
+
: cluster.highest_affected_priority >= 8
|
|
447
|
+
? 'high'
|
|
448
|
+
: cluster.highest_affected_priority >= 5
|
|
449
|
+
? 'medium'
|
|
450
|
+
: 'low',
|
|
451
|
+
rerun_priority_score: (cluster.severity === 'block' ? 100 : 0)
|
|
452
|
+
+ (cluster.revalidation_sets.has('contract') ? 30 : cluster.revalidation_sets.has('shared_module') ? 20 : cluster.revalidation_sets.has('semantic_object') ? 15 : 0)
|
|
453
|
+
+ (cluster.highest_affected_priority * 3)
|
|
454
|
+
+ (cluster.dependent_files.size * 4)
|
|
455
|
+
+ (cluster.dependent_areas.size * 2)
|
|
456
|
+
+ cluster.module_paths.size
|
|
457
|
+
+ cluster.invalidations.length,
|
|
458
|
+
rerun_breadth_score: (cluster.dependent_files.size * 4) + (cluster.dependent_areas.size * 2) + cluster.module_paths.size,
|
|
459
|
+
highest_affected_priority: cluster.highest_affected_priority,
|
|
460
|
+
highest_source_priority: cluster.highest_source_priority,
|
|
461
|
+
severity: cluster.severity,
|
|
462
|
+
invalidations: cluster.invalidations,
|
|
463
|
+
title: cluster.affected_pipeline_id
|
|
464
|
+
? `Pipeline ${cluster.affected_pipeline_id} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`
|
|
465
|
+
: `${[...cluster.affected_task_ids][0]} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`,
|
|
466
|
+
detail: `${[...cluster.source_task_titles][0] || cluster.invalidations[0]?.source_task_id || 'unknown source'} (${[...cluster.stale_areas].join(', ')})`,
|
|
467
|
+
next_action: cluster.affected_pipeline_id
|
|
468
|
+
? `switchman task retry-stale --pipeline ${cluster.affected_pipeline_id}`
|
|
469
|
+
: `switchman task retry ${[...cluster.affected_task_ids][0]}`,
|
|
470
|
+
}));
|
|
471
|
+
|
|
472
|
+
const causeGroups = new Map();
|
|
473
|
+
for (const cluster of clusterEntries) {
|
|
474
|
+
const primary = cluster.invalidations[0] || {};
|
|
475
|
+
const details = primary.details || {};
|
|
476
|
+
const causeKey = cluster.revalidation_set_type === 'contract'
|
|
477
|
+
? `contract:${(details.contract_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
478
|
+
: cluster.revalidation_set_type === 'shared_module'
|
|
479
|
+
? `shared_module:${(details.module_paths || cluster.module_paths || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
480
|
+
: cluster.revalidation_set_type === 'semantic_object'
|
|
481
|
+
? `semantic_object:${(details.object_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
482
|
+
: `dependency:${cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`;
|
|
483
|
+
if (!causeGroups.has(causeKey)) causeGroups.set(causeKey, []);
|
|
484
|
+
causeGroups.get(causeKey).push(cluster);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (const [causeKey, relatedClusters] of causeGroups.entries()) {
|
|
488
|
+
const relatedPipelines = [...new Set(relatedClusters.map((cluster) => cluster.affected_pipeline_id).filter(Boolean))];
|
|
489
|
+
const primary = relatedClusters[0];
|
|
490
|
+
const details = primary.invalidations[0]?.details || {};
|
|
491
|
+
const causeSummary = primary.revalidation_set_type === 'contract'
|
|
492
|
+
? `shared contract drift in ${(details.contract_names || []).join(', ') || 'unknown contract'}`
|
|
493
|
+
: primary.revalidation_set_type === 'shared_module'
|
|
494
|
+
? `shared module drift in ${(details.module_paths || primary.module_paths || []).join(', ') || 'unknown module'}`
|
|
495
|
+
: primary.revalidation_set_type === 'semantic_object'
|
|
496
|
+
? `shared exported object drift in ${(details.object_names || []).join(', ') || 'unknown object'}`
|
|
497
|
+
: `shared dependency drift across ${primary.stale_areas.join(', ')}`;
|
|
498
|
+
for (let index = 0; index < relatedClusters.length; index += 1) {
|
|
499
|
+
relatedClusters[index].causal_group_id = `cause-${causeKey}`;
|
|
500
|
+
relatedClusters[index].causal_group_size = relatedClusters.length;
|
|
501
|
+
relatedClusters[index].causal_group_rank = index + 1;
|
|
502
|
+
relatedClusters[index].causal_group_summary = causeSummary;
|
|
503
|
+
relatedClusters[index].related_affected_pipelines = relatedPipelines;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return clusterEntries.sort((a, b) =>
|
|
508
|
+
b.rerun_priority_score - a.rerun_priority_score
|
|
509
|
+
|| (a.severity === 'block' ? -1 : 1) - (b.severity === 'block' ? -1 : 1)
|
|
510
|
+
|| (a.revalidation_set_type === 'contract' ? -1 : 1) - (b.revalidation_set_type === 'contract' ? -1 : 1)
|
|
511
|
+
|| (a.revalidation_set_type === 'shared_module' ? -1 : 1) - (b.revalidation_set_type === 'shared_module' ? -1 : 1)
|
|
512
|
+
|| b.invalidation_count - a.invalidation_count);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function summarizeStaleCausalWaves(staleClusters = []) {
|
|
516
|
+
const grouped = new Map();
|
|
517
|
+
for (const cluster of staleClusters) {
|
|
518
|
+
if (!cluster.causal_group_id) continue;
|
|
519
|
+
if (!grouped.has(cluster.causal_group_id)) {
|
|
520
|
+
grouped.set(cluster.causal_group_id, {
|
|
521
|
+
causal_group_id: cluster.causal_group_id,
|
|
522
|
+
summary: cluster.causal_group_summary || cluster.title,
|
|
523
|
+
revalidation_set_type: cluster.revalidation_set_type,
|
|
524
|
+
source_task_ids: [...(cluster.source_task_ids || [])],
|
|
525
|
+
source_pipeline_ids: [...(cluster.source_pipeline_ids || [])],
|
|
526
|
+
related_affected_pipelines: new Set(cluster.related_affected_pipelines || []),
|
|
527
|
+
affected_pipeline_ids: new Set(),
|
|
528
|
+
cluster_count: 0,
|
|
529
|
+
invalidation_count: 0,
|
|
530
|
+
highest_rerun_priority_score: 0,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const entry = grouped.get(cluster.causal_group_id);
|
|
534
|
+
if (cluster.affected_pipeline_id) entry.affected_pipeline_ids.add(cluster.affected_pipeline_id);
|
|
535
|
+
for (const pipelineId of cluster.related_affected_pipelines || []) entry.related_affected_pipelines.add(pipelineId);
|
|
536
|
+
entry.cluster_count += 1;
|
|
537
|
+
entry.invalidation_count += cluster.invalidation_count || 0;
|
|
538
|
+
entry.highest_rerun_priority_score = Math.max(entry.highest_rerun_priority_score, cluster.rerun_priority_score || 0);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return [...grouped.values()]
|
|
542
|
+
.map((entry) => ({
|
|
543
|
+
...entry,
|
|
544
|
+
affected_pipeline_ids: [...entry.affected_pipeline_ids],
|
|
545
|
+
related_affected_pipelines: [...entry.related_affected_pipelines],
|
|
546
|
+
}))
|
|
547
|
+
.sort((a, b) =>
|
|
548
|
+
b.highest_rerun_priority_score - a.highest_rerun_priority_score
|
|
549
|
+
|| b.cluster_count - a.cluster_count
|
|
550
|
+
|| b.invalidation_count - a.invalidation_count
|
|
551
|
+
|| String(a.summary).localeCompare(String(b.summary)));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function getPipelineStaleWaveContext(db, pipelineId) {
|
|
555
|
+
if (!pipelineId) {
|
|
556
|
+
return {
|
|
557
|
+
stale_clusters: [],
|
|
558
|
+
stale_causal_waves: [],
|
|
559
|
+
shared_wave_count: 0,
|
|
560
|
+
largest_wave_size: 0,
|
|
561
|
+
primary_wave: null,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const staleClusters = buildStaleClusters(listDependencyInvalidations(db), {});
|
|
566
|
+
const staleCausalWaves = summarizeStaleCausalWaves(staleClusters)
|
|
567
|
+
.filter((wave) => wave.affected_pipeline_ids.includes(pipelineId));
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
stale_clusters: staleClusters.filter((cluster) => cluster.affected_pipeline_id === pipelineId),
|
|
571
|
+
stale_causal_waves: staleCausalWaves,
|
|
572
|
+
shared_wave_count: staleCausalWaves.length,
|
|
573
|
+
largest_wave_size: staleCausalWaves.reduce((max, wave) => Math.max(max, Number(wave.cluster_count || 0)), 0),
|
|
574
|
+
primary_wave: staleCausalWaves[0] || null,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function summarizePipelinePolicyState(db, status, changePolicy, boundaryValidations = []) {
|
|
579
|
+
const tasks = status.tasks || [];
|
|
580
|
+
const implementationTasks = tasks.filter((task) => task.task_spec?.task_type === 'implementation');
|
|
581
|
+
const completedTasks = tasks.filter((task) => task.status === 'done' && task.task_spec?.task_type);
|
|
582
|
+
const completedLeaseByTask = new Map();
|
|
583
|
+
for (const lease of status.leases || []) {
|
|
584
|
+
if (lease.status !== 'completed') continue;
|
|
585
|
+
if (!completedLeaseByTask.has(lease.task_id)) {
|
|
586
|
+
completedLeaseByTask.set(lease.task_id, lease);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const completionAuditByTask = new Map();
|
|
590
|
+
for (const event of status.audit_events || []) {
|
|
591
|
+
if (event.event_type !== 'task_completed' || !event.task_id) continue;
|
|
592
|
+
if (!completionAuditByTask.has(event.task_id)) {
|
|
593
|
+
completionAuditByTask.set(event.task_id, event);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const completedTaskTypes = new Set(completedTasks.map((task) => task.task_spec?.task_type).filter(Boolean));
|
|
597
|
+
const governedDomains = uniq(
|
|
598
|
+
implementationTasks
|
|
599
|
+
.flatMap((task) => task.task_spec?.subsystem_tags || [])
|
|
600
|
+
.filter((domain) => changePolicy.domain_rules?.[domain]),
|
|
601
|
+
);
|
|
602
|
+
const activeRules = governedDomains.map((domain) => ({
|
|
603
|
+
domain,
|
|
604
|
+
...(changePolicy.domain_rules?.[domain] || { required_completed_task_types: [], enforcement: 'none', rationale: [] }),
|
|
605
|
+
}));
|
|
606
|
+
const requiredTaskTypes = uniq([
|
|
607
|
+
...implementationTasks.flatMap((task) => task.task_spec?.validation_rules?.required_completed_task_types || []),
|
|
608
|
+
...activeRules.flatMap((rule) => rule.required_completed_task_types || []),
|
|
609
|
+
]);
|
|
610
|
+
const rawMissingTaskTypes = uniq([
|
|
611
|
+
...boundaryValidations.flatMap((validation) => validation.missing_task_types || []),
|
|
612
|
+
...requiredTaskTypes.filter((taskType) => !completedTaskTypes.has(taskType)),
|
|
613
|
+
]);
|
|
614
|
+
const overrideState = buildPipelinePolicyOverrideState(db, status.pipeline_id, rawMissingTaskTypes);
|
|
615
|
+
const missingTaskTypes = rawMissingTaskTypes.filter((taskType) => !overrideState.active_task_types.includes(taskType));
|
|
616
|
+
const overriddenTaskTypes = rawMissingTaskTypes.filter((taskType) => overrideState.active_task_types.includes(taskType));
|
|
617
|
+
const satisfiedTaskTypes = requiredTaskTypes.filter((taskType) => completedTaskTypes.has(taskType));
|
|
618
|
+
const evidenceState = buildPipelinePolicyEvidence(status, requiredTaskTypes, {
|
|
619
|
+
completedLeaseByTask,
|
|
620
|
+
completionAuditByTask,
|
|
621
|
+
});
|
|
622
|
+
const evidenceByTaskType = evidenceState.evidence_by_task_type;
|
|
623
|
+
const requirementStatus = requiredTaskTypes.map((taskType) => ({
|
|
624
|
+
task_type: taskType,
|
|
625
|
+
satisfied: satisfiedTaskTypes.includes(taskType),
|
|
626
|
+
overridden: overriddenTaskTypes.includes(taskType),
|
|
627
|
+
override_ids: overrideState.active_overrides
|
|
628
|
+
.filter((entry) => (entry.task_types || []).includes(taskType))
|
|
629
|
+
.map((entry) => entry.id),
|
|
630
|
+
evidence: evidenceByTaskType[taskType] || [],
|
|
631
|
+
}));
|
|
632
|
+
const rationale = uniq([
|
|
633
|
+
...implementationTasks.flatMap((task) => task.task_spec?.validation_rules?.rationale || []),
|
|
634
|
+
...activeRules.flatMap((rule) => rule.rationale || []),
|
|
635
|
+
...boundaryValidations.flatMap((validation) => validation.rationale || validation.details?.rationale || []),
|
|
636
|
+
]);
|
|
637
|
+
const enforcement = [...implementationTasks.map((task) => task.task_spec?.validation_rules?.enforcement || 'none'), ...activeRules.map((rule) => rule.enforcement || 'none')]
|
|
638
|
+
.reduce((highest, current) => rankEnforcement(current) > rankEnforcement(highest) ? current : highest, 'none');
|
|
639
|
+
|
|
640
|
+
const policyState = {
|
|
641
|
+
active: governedDomains.length > 0 || boundaryValidations.length > 0,
|
|
642
|
+
domains: governedDomains,
|
|
643
|
+
active_rules: activeRules,
|
|
644
|
+
required_task_types: requiredTaskTypes,
|
|
645
|
+
satisfied_task_types: satisfiedTaskTypes,
|
|
646
|
+
raw_missing_task_types: rawMissingTaskTypes,
|
|
647
|
+
missing_task_types: missingTaskTypes,
|
|
648
|
+
overridden_task_types: overriddenTaskTypes,
|
|
649
|
+
requirement_status: requirementStatus,
|
|
650
|
+
evidence_by_task_type: evidenceByTaskType,
|
|
651
|
+
evidence_schema_version: 1,
|
|
652
|
+
evidence_objects: evidenceState.evidence_objects,
|
|
653
|
+
satisfaction_history: [],
|
|
654
|
+
overrides: overrideState.active_overrides,
|
|
655
|
+
override_history: overrideState.override_history,
|
|
656
|
+
pipeline_id: status.pipeline_id,
|
|
657
|
+
enforcement,
|
|
658
|
+
rationale,
|
|
659
|
+
blocked_validations: boundaryValidations.filter((validation) => validation.severity === 'blocked').length,
|
|
660
|
+
warned_validations: boundaryValidations.filter((validation) => validation.severity !== 'blocked').length,
|
|
661
|
+
summary: governedDomains.length === 0
|
|
662
|
+
? 'No explicit governed-domain policy requirements are active for this pipeline.'
|
|
663
|
+
: missingTaskTypes.length > 0
|
|
664
|
+
? `Governed domains ${governedDomains.join(', ')} still require ${missingTaskTypes.join(', ')} before landing.`
|
|
665
|
+
: overriddenTaskTypes.length > 0
|
|
666
|
+
? `Governed domains ${governedDomains.join(', ')} are currently allowed to proceed with overrides for ${overriddenTaskTypes.join(', ')}.`
|
|
667
|
+
: `Governed domains ${governedDomains.join(', ')} have satisfied their current policy requirements.`,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
policyState.satisfaction_history = [
|
|
671
|
+
...buildPipelinePolicySatisfactionHistory(policyState),
|
|
672
|
+
...overrideState.override_history,
|
|
673
|
+
].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
674
|
+
return policyState;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function buildPipelinePolicyRemediationCommand(status, policyState) {
|
|
678
|
+
if (policyState.missing_task_types.length === 0) {
|
|
679
|
+
return `switchman pipeline status ${status.pipeline_id}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return `switchman pipeline review ${status.pipeline_id}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export async function evaluatePipelinePolicyGate(db, repoRoot, pipelineId) {
|
|
686
|
+
const status = getPipelineStatus(db, pipelineId);
|
|
687
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
688
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
689
|
+
const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
|
|
690
|
+
const blocked = policyState.active
|
|
691
|
+
&& policyState.enforcement === 'blocked'
|
|
692
|
+
&& policyState.missing_task_types.length > 0;
|
|
693
|
+
const nextAction = blocked ? buildPipelinePolicyRemediationCommand(status, policyState) : null;
|
|
694
|
+
const overrideApplied = policyState.overridden_task_types.length > 0;
|
|
695
|
+
const overrideSummary = overrideApplied
|
|
696
|
+
? `Policy override active for ${policyState.overridden_task_types.join(', ')} by ${policyState.overrides.map((entry) => entry.approved_by || 'unknown').join(', ')}.`
|
|
697
|
+
: null;
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
ok: !blocked,
|
|
701
|
+
pipeline_id: pipelineId,
|
|
702
|
+
reason_code: blocked ? 'policy_requirements_incomplete' : null,
|
|
703
|
+
summary: blocked
|
|
704
|
+
? `Policy blocked landing for governed domains ${policyState.domains.join(', ')} because ${policyState.missing_task_types.join(', ')} is still required.`
|
|
705
|
+
: policyState.summary,
|
|
706
|
+
next_action: nextAction,
|
|
707
|
+
policy_state: policyState,
|
|
708
|
+
override_applied: overrideApplied,
|
|
709
|
+
override_summary: overrideSummary,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
70
713
|
export function startPipeline(db, { title, description = null, priority = 5, pipelineId = null, maxTasks = 5 }) {
|
|
71
714
|
const resolvedPipelineId = pipelineId || makePipelineId();
|
|
72
715
|
const registeredWorktrees = listWorktrees(db);
|
|
@@ -151,6 +794,8 @@ export function getPipelineStatus(db, pipelineId) {
|
|
|
151
794
|
description: metadata?.description || null,
|
|
152
795
|
priority: metadata?.priority || tasks[0].priority,
|
|
153
796
|
counts,
|
|
797
|
+
leases: listLeases(db),
|
|
798
|
+
audit_events: listAuditEvents(db, { limit: 2000 }).filter((event) => pipelineOwnsAuditEvent(event, pipelineId)),
|
|
154
799
|
tasks: tasks.map((task) => {
|
|
155
800
|
const dependencies = parseDependencies(task.description);
|
|
156
801
|
const blockedBy = dependencies.filter((dependencyId) =>
|
|
@@ -174,6 +819,35 @@ export function getPipelineStatus(db, pipelineId) {
|
|
|
174
819
|
};
|
|
175
820
|
}
|
|
176
821
|
|
|
822
|
+
export function inferPipelineIdFromBranch(db, branch) {
|
|
823
|
+
const normalizedBranch = String(branch || '').trim();
|
|
824
|
+
if (!normalizedBranch) return null;
|
|
825
|
+
|
|
826
|
+
const landingPrefix = 'switchman/pipeline-landing/';
|
|
827
|
+
if (normalizedBranch.startsWith(landingPrefix)) {
|
|
828
|
+
return normalizedBranch.slice(landingPrefix.length) || null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const worktreesByName = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
|
|
832
|
+
const matchingPipelines = new Set();
|
|
833
|
+
|
|
834
|
+
for (const task of listTasks(db)) {
|
|
835
|
+
const taskSpec = getTaskSpec(db, task.id);
|
|
836
|
+
const pipelineId = taskSpec?.pipeline_id || null;
|
|
837
|
+
if (!pipelineId || !task.worktree) continue;
|
|
838
|
+
const worktree = worktreesByName.get(task.worktree);
|
|
839
|
+
if (worktree?.branch === normalizedBranch) {
|
|
840
|
+
matchingPipelines.add(pipelineId);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (matchingPipelines.size === 1) {
|
|
845
|
+
return [...matchingPipelines][0];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
|
|
177
851
|
function parseTaskFailure(description) {
|
|
178
852
|
const lines = String(description || '')
|
|
179
853
|
.split('\n')
|
|
@@ -593,6 +1267,7 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
593
1267
|
const status = getPipelineStatus(db, pipelineId);
|
|
594
1268
|
const report = await scanAllWorktrees(db, repoRoot);
|
|
595
1269
|
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
1270
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
596
1271
|
const allLeases = listLeases(db);
|
|
597
1272
|
const ciGateOk = report.conflicts.length === 0
|
|
598
1273
|
&& report.fileConflicts.length === 0
|
|
@@ -629,6 +1304,15 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
629
1304
|
}));
|
|
630
1305
|
const changedFiles = uniq(worktreeChanges.flatMap((entry) => entry.files));
|
|
631
1306
|
const subsystemTags = uniq(completedTasks.flatMap((task) => task.task_spec?.subsystem_tags || []));
|
|
1307
|
+
const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
|
|
1308
|
+
const policyOverrideSummary = policyState.overridden_task_types.length > 0
|
|
1309
|
+
? `Landing currently relies on policy overrides for ${policyState.overridden_task_types.join(', ')}.`
|
|
1310
|
+
: null;
|
|
1311
|
+
const staleClusters = buildStaleClusters(aiGate.dependency_invalidations || [], { pipelineId });
|
|
1312
|
+
const staleCausalWaves = summarizeStaleCausalWaves(staleClusters);
|
|
1313
|
+
const trustAudit = [...policyState.satisfaction_history, ...buildPipelineTrustAudit(db, pipelineId)]
|
|
1314
|
+
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
|
|
1315
|
+
.slice(0, 8);
|
|
632
1316
|
const riskNotes = [];
|
|
633
1317
|
if (!ciGateOk) riskNotes.push('Repo gate is blocked by conflicts, unmanaged changes, or stale worktrees.');
|
|
634
1318
|
if (aiGate.status !== 'pass') riskNotes.push(aiGate.summary);
|
|
@@ -647,10 +1331,15 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
647
1331
|
const reviewerChecklist = [
|
|
648
1332
|
ciGateOk ? 'Repo gate passed' : 'Resolve repo gate failures before merge',
|
|
649
1333
|
aiGate.status === 'pass' ? 'AI merge gate passed' : `Review AI merge gate findings: ${aiGate.summary}`,
|
|
1334
|
+
policyState.active
|
|
1335
|
+
? (policyState.missing_task_types.length > 0
|
|
1336
|
+
? `Complete policy requirements: ${policyState.missing_task_types.join(', ')}`
|
|
1337
|
+
: `Confirm governed domains remain satisfied: ${policyState.domains.join(', ')}`)
|
|
1338
|
+
: null,
|
|
650
1339
|
completedTasks.some((task) => task.task_spec?.risk_level === 'high')
|
|
651
1340
|
? 'Confirm high-risk tasks have the expected tests and docs'
|
|
652
1341
|
: 'Review changed files and task outcomes',
|
|
653
|
-
];
|
|
1342
|
+
].filter(Boolean);
|
|
654
1343
|
const prTitle = status.title.startsWith('Implement:')
|
|
655
1344
|
? status.title.replace(/^Implement:\s*/i, '')
|
|
656
1345
|
: status.title;
|
|
@@ -663,6 +1352,33 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
663
1352
|
'## Validation',
|
|
664
1353
|
`- Repo gate: ${ciGateOk ? 'pass' : 'blocked'}`,
|
|
665
1354
|
`- AI merge gate: ${aiGate.status}`,
|
|
1355
|
+
...(policyState.active
|
|
1356
|
+
? [
|
|
1357
|
+
`- Policy domains: ${policyState.domains.join(', ')}`,
|
|
1358
|
+
`- Policy enforcement: ${policyState.enforcement}`,
|
|
1359
|
+
`- Policy requirements: ${policyState.required_task_types.join(', ') || 'none'}`,
|
|
1360
|
+
`- Policy evidence: ${policyState.satisfied_task_types.map((taskType) => `${taskType}:${(policyState.evidence_by_task_type?.[taskType] || []).map((entry) => entry.task_id).join(',')}`).join(' | ') || 'none'}`,
|
|
1361
|
+
`- Policy evidence objects: ${policyState.evidence_objects.map((entry) => `${entry.evidence_id}:${entry.satisfied_by.agent || entry.satisfied_by.worktree || 'task_completion'}`).join(' | ') || 'none'}`,
|
|
1362
|
+
`- Policy missing: ${policyState.missing_task_types.join(', ') || 'none'}`,
|
|
1363
|
+
`- Policy overrides: ${policyState.overrides.map((entry) => `${entry.id}:${(entry.task_types || []).join(',') || 'all'}:${entry.approved_by || 'unknown'}`).join(' | ') || 'none'}`,
|
|
1364
|
+
...(policyOverrideSummary ? [`- Policy override effect: ${policyOverrideSummary}`] : []),
|
|
1365
|
+
]
|
|
1366
|
+
: []),
|
|
1367
|
+
...(staleClusters.length > 0
|
|
1368
|
+
? [
|
|
1369
|
+
`- Stale clusters: ${staleClusters.map((cluster) => `${cluster.affected_pipeline_id || cluster.affected_task_ids[0]}:${cluster.invalidation_count}`).join(' | ')}`,
|
|
1370
|
+
]
|
|
1371
|
+
: []),
|
|
1372
|
+
...(staleCausalWaves.length > 0
|
|
1373
|
+
? [
|
|
1374
|
+
`- Stale waves: ${staleCausalWaves.map((wave) => `${wave.summary}:${wave.affected_pipeline_ids.join(',') || 'unknown'}`).join(' | ')}`,
|
|
1375
|
+
]
|
|
1376
|
+
: []),
|
|
1377
|
+
...(trustAudit.length > 0
|
|
1378
|
+
? [
|
|
1379
|
+
`- Trust audit: ${trustAudit.slice(0, 3).map((entry) => `${entry.category}:${entry.summary}`).join(' | ')}`,
|
|
1380
|
+
]
|
|
1381
|
+
: []),
|
|
666
1382
|
'',
|
|
667
1383
|
'## Reviewer Checklist',
|
|
668
1384
|
...reviewerChecklist.map((item) => `- ${item}`),
|
|
@@ -702,6 +1418,45 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
702
1418
|
'## Reviewer Notes',
|
|
703
1419
|
...reviewerChecklist.map((item) => `- ${item}`),
|
|
704
1420
|
'',
|
|
1421
|
+
...(policyState.active
|
|
1422
|
+
? [
|
|
1423
|
+
'## Policy Requirements',
|
|
1424
|
+
`- Domains: ${policyState.domains.join(', ')}`,
|
|
1425
|
+
`- Enforcement: ${policyState.enforcement}`,
|
|
1426
|
+
`- Required task types: ${policyState.required_task_types.join(', ') || 'none'}`,
|
|
1427
|
+
`- Satisfied task types: ${policyState.satisfied_task_types.join(', ') || 'none'}`,
|
|
1428
|
+
`- Missing task types: ${policyState.missing_task_types.join(', ') || 'none'}`,
|
|
1429
|
+
`- Overridden task types: ${policyState.overridden_task_types.join(', ') || 'none'}`,
|
|
1430
|
+
...policyState.requirement_status
|
|
1431
|
+
.filter((requirement) => requirement.evidence.length > 0)
|
|
1432
|
+
.map((requirement) => `- Evidence for ${requirement.task_type}: ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path}, by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'})` : `${entry.task_id} (by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'})`).join(', ')}`),
|
|
1433
|
+
...policyState.overrides.slice(0, 5).map((entry) => `- Override ${entry.id}: ${(entry.task_types || []).join(', ') || 'all requirements'} by ${entry.approved_by || 'unknown'} (${entry.reason})`),
|
|
1434
|
+
...policyState.satisfaction_history.slice(0, 5).map((entry) => `- Satisfaction history: ${entry.summary}`),
|
|
1435
|
+
...policyState.rationale.slice(0, 5).map((item) => `- ${item}`),
|
|
1436
|
+
'',
|
|
1437
|
+
]
|
|
1438
|
+
: []),
|
|
1439
|
+
...(staleClusters.length > 0
|
|
1440
|
+
? [
|
|
1441
|
+
'## Stale Clusters',
|
|
1442
|
+
...staleClusters.map((cluster) => `- ${cluster.title}: ${cluster.detail} -> ${cluster.next_action}`),
|
|
1443
|
+
'',
|
|
1444
|
+
]
|
|
1445
|
+
: []),
|
|
1446
|
+
...(staleCausalWaves.length > 0
|
|
1447
|
+
? [
|
|
1448
|
+
'## Stale Waves',
|
|
1449
|
+
...staleCausalWaves.map((wave) => `- ${wave.summary}: affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} -> ${wave.cluster_count} cluster(s), ${wave.invalidation_count} invalidation(s)`),
|
|
1450
|
+
'',
|
|
1451
|
+
]
|
|
1452
|
+
: []),
|
|
1453
|
+
...(trustAudit.length > 0
|
|
1454
|
+
? [
|
|
1455
|
+
'## Policy & Stale Audit',
|
|
1456
|
+
...trustAudit.map((entry) => `- ${entry.created_at}: [${entry.category}] ${entry.summary} -> ${entry.next_action}`),
|
|
1457
|
+
'',
|
|
1458
|
+
]
|
|
1459
|
+
: []),
|
|
705
1460
|
'## Provenance',
|
|
706
1461
|
...provenance.map((entry) => `- ${entry.task_id}: ${entry.title} (${entry.task_type || 'unknown'}, ${entry.worktree || 'unassigned'}, lease ${entry.lease_id || 'none'})`),
|
|
707
1462
|
...(provenance.length === 0 ? ['- No completed task provenance yet'] : []),
|
|
@@ -736,6 +1491,11 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
736
1491
|
risk_notes: riskNotes,
|
|
737
1492
|
changed_files: changedFiles,
|
|
738
1493
|
subsystem_tags: subsystemTags,
|
|
1494
|
+
policy_state: policyState,
|
|
1495
|
+
policy_override_summary: policyOverrideSummary,
|
|
1496
|
+
stale_clusters: staleClusters,
|
|
1497
|
+
stale_causal_waves: staleCausalWaves,
|
|
1498
|
+
trust_audit: trustAudit,
|
|
739
1499
|
},
|
|
740
1500
|
counts: status.counts,
|
|
741
1501
|
ci_gate: {
|
|
@@ -747,23 +1507,210 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
|
|
|
747
1507
|
status: aiGate.status,
|
|
748
1508
|
summary: aiGate.summary,
|
|
749
1509
|
},
|
|
1510
|
+
policy_state: policyState,
|
|
1511
|
+
stale_clusters: staleClusters,
|
|
1512
|
+
stale_causal_waves: staleCausalWaves,
|
|
1513
|
+
trust_audit: trustAudit,
|
|
750
1514
|
worktree_changes: worktreeChanges,
|
|
751
1515
|
markdown,
|
|
752
1516
|
};
|
|
753
1517
|
}
|
|
754
1518
|
|
|
1519
|
+
export async function buildPipelineLandingSummary(db, repoRoot, pipelineId) {
|
|
1520
|
+
const status = getPipelineStatus(db, pipelineId);
|
|
1521
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
1522
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
1523
|
+
const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
|
|
1524
|
+
const policyOverrideSummary = policyState.overridden_task_types.length > 0
|
|
1525
|
+
? `Landing currently relies on policy overrides for ${policyState.overridden_task_types.join(', ')}.`
|
|
1526
|
+
: null;
|
|
1527
|
+
const staleClusters = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId });
|
|
1528
|
+
const staleCausalWaves = summarizeStaleCausalWaves(staleClusters);
|
|
1529
|
+
const trustAudit = [...policyState.satisfaction_history, ...buildPipelineTrustAudit(db, pipelineId)]
|
|
1530
|
+
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
|
|
1531
|
+
.slice(0, 8);
|
|
1532
|
+
let landing;
|
|
1533
|
+
let landingError = null;
|
|
1534
|
+
try {
|
|
1535
|
+
landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
1536
|
+
requireCompleted: false,
|
|
1537
|
+
});
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
landingError = String(err.message || 'Landing branch is not ready yet.');
|
|
1540
|
+
landing = {
|
|
1541
|
+
branch: null,
|
|
1542
|
+
strategy: 'unavailable',
|
|
1543
|
+
synthetic: false,
|
|
1544
|
+
component_branches: [],
|
|
1545
|
+
stale: false,
|
|
1546
|
+
last_failure: null,
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const readyToQueue = status.counts.failed === 0
|
|
1550
|
+
&& status.counts.pending === 0
|
|
1551
|
+
&& status.counts.in_progress === 0
|
|
1552
|
+
&& status.counts.done > 0
|
|
1553
|
+
&& !landingError
|
|
1554
|
+
&& !landing.stale
|
|
1555
|
+
&& staleClusters.length === 0
|
|
1556
|
+
&& !landing.last_failure;
|
|
1557
|
+
const nextAction = staleClusters[0]?.next_action
|
|
1558
|
+
|| (landingError
|
|
1559
|
+
? `switchman pipeline status ${pipelineId}`
|
|
1560
|
+
: landing.last_failure?.command
|
|
1561
|
+
|| (landing.stale
|
|
1562
|
+
? `switchman pipeline land ${pipelineId} --refresh`
|
|
1563
|
+
: `switchman queue add --pipeline ${pipelineId}`));
|
|
1564
|
+
const queueItems = listMergeQueue(db)
|
|
1565
|
+
.filter((item) => item.source_pipeline_id === pipelineId)
|
|
1566
|
+
.sort((a, b) => Number.parseInt(b.id.slice(1), 10) - Number.parseInt(a.id.slice(1), 10));
|
|
1567
|
+
const currentQueueItem = queueItems[0] || null;
|
|
1568
|
+
const queueState = {
|
|
1569
|
+
status: currentQueueItem?.status || 'not_queued',
|
|
1570
|
+
item_id: currentQueueItem?.id || null,
|
|
1571
|
+
target_branch: currentQueueItem?.target_branch || 'main',
|
|
1572
|
+
merged_commit: currentQueueItem?.merged_commit || null,
|
|
1573
|
+
next_action: currentQueueItem?.next_action || (currentQueueItem
|
|
1574
|
+
? null
|
|
1575
|
+
: (readyToQueue ? `switchman queue add --pipeline ${pipelineId}` : nextAction)),
|
|
1576
|
+
last_error_code: currentQueueItem?.last_error_code || null,
|
|
1577
|
+
last_error_summary: currentQueueItem?.last_error_summary || null,
|
|
1578
|
+
policy_override_summary: policyOverrideSummary,
|
|
1579
|
+
};
|
|
1580
|
+
const recoveryState = landing.last_recovery
|
|
1581
|
+
? {
|
|
1582
|
+
status: landing.last_recovery.state?.status || 'prepared',
|
|
1583
|
+
recovery_path: landing.last_recovery.recovery_path || null,
|
|
1584
|
+
inspect_command: landing.last_recovery.inspect_command || null,
|
|
1585
|
+
resume_command: landing.last_recovery.resume_command || null,
|
|
1586
|
+
}
|
|
1587
|
+
: null;
|
|
1588
|
+
|
|
1589
|
+
const markdown = [
|
|
1590
|
+
`# Pipeline Landing Summary: ${status.title}`,
|
|
1591
|
+
'',
|
|
1592
|
+
`- Pipeline: \`${pipelineId}\``,
|
|
1593
|
+
`- Ready to queue: ${readyToQueue ? 'yes' : 'no'}`,
|
|
1594
|
+
`- Landing branch: ${landing.branch ? `\`${landing.branch}\`` : 'not resolved yet'}`,
|
|
1595
|
+
`- Strategy: ${landing.strategy}`,
|
|
1596
|
+
`- Synthetic: ${landing.synthetic ? 'yes' : 'no'}`,
|
|
1597
|
+
'',
|
|
1598
|
+
'## Component Branches',
|
|
1599
|
+
...(landing.component_branches.length > 0
|
|
1600
|
+
? landing.component_branches.map((branch) => `- ${branch}`)
|
|
1601
|
+
: ['- None inferred yet']),
|
|
1602
|
+
'',
|
|
1603
|
+
'## Landing State',
|
|
1604
|
+
...(landingError
|
|
1605
|
+
? [`- ${landingError}`]
|
|
1606
|
+
: landing.last_failure
|
|
1607
|
+
? [
|
|
1608
|
+
`- Failure: ${landing.last_failure.reason_code || 'landing_branch_materialization_failed'}`,
|
|
1609
|
+
...(landing.last_failure.failed_branch ? [`- Failed branch: ${landing.last_failure.failed_branch}`] : []),
|
|
1610
|
+
...(landing.last_failure.conflicting_files?.length ? [`- Conflicts: ${landing.last_failure.conflicting_files.join(', ')}`] : []),
|
|
1611
|
+
]
|
|
1612
|
+
: landing.stale
|
|
1613
|
+
? landing.stale_reasons.map((reason) => `- Stale: ${reason.summary}`)
|
|
1614
|
+
: staleClusters.length > 0
|
|
1615
|
+
? staleClusters.map((cluster) => `- Stale cluster: ${cluster.title}`)
|
|
1616
|
+
: ['- Current and ready for queueing']),
|
|
1617
|
+
'',
|
|
1618
|
+
'## Recovery State',
|
|
1619
|
+
...(recoveryState
|
|
1620
|
+
? [
|
|
1621
|
+
`- Status: ${recoveryState.status}`,
|
|
1622
|
+
...(recoveryState.recovery_path ? [`- Path: ${recoveryState.recovery_path}`] : []),
|
|
1623
|
+
...(recoveryState.resume_command ? [`- Resume: ${recoveryState.resume_command}`] : []),
|
|
1624
|
+
]
|
|
1625
|
+
: ['- No active recovery worktree']),
|
|
1626
|
+
'',
|
|
1627
|
+
...(staleClusters.length > 0
|
|
1628
|
+
? [
|
|
1629
|
+
'## Stale Clusters',
|
|
1630
|
+
...staleClusters.map((cluster) => `- ${cluster.title}: ${cluster.detail} -> ${cluster.next_action}`),
|
|
1631
|
+
'',
|
|
1632
|
+
]
|
|
1633
|
+
: []),
|
|
1634
|
+
...(staleCausalWaves.length > 0
|
|
1635
|
+
? [
|
|
1636
|
+
'## Stale Waves',
|
|
1637
|
+
...staleCausalWaves.map((wave) => `- ${wave.summary}: affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} -> ${wave.cluster_count} cluster(s), ${wave.invalidation_count} invalidation(s)`),
|
|
1638
|
+
'',
|
|
1639
|
+
]
|
|
1640
|
+
: []),
|
|
1641
|
+
...(trustAudit.length > 0
|
|
1642
|
+
? [
|
|
1643
|
+
'## Policy & Stale Audit',
|
|
1644
|
+
...trustAudit.map((entry) => `- ${entry.created_at}: [${entry.category}] ${entry.summary} -> ${entry.next_action}`),
|
|
1645
|
+
'',
|
|
1646
|
+
]
|
|
1647
|
+
: []),
|
|
1648
|
+
'',
|
|
1649
|
+
...(policyState.active
|
|
1650
|
+
? [
|
|
1651
|
+
'## Policy Record',
|
|
1652
|
+
`- Domains: ${policyState.domains.join(', ')}`,
|
|
1653
|
+
`- Enforcement: ${policyState.enforcement}`,
|
|
1654
|
+
`- Required task types: ${policyState.required_task_types.join(', ') || 'none'}`,
|
|
1655
|
+
`- Missing task types: ${policyState.missing_task_types.join(', ') || 'none'}`,
|
|
1656
|
+
`- Overridden task types: ${policyState.overridden_task_types.join(', ') || 'none'}`,
|
|
1657
|
+
...policyState.requirement_status
|
|
1658
|
+
.filter((requirement) => requirement.evidence.length > 0)
|
|
1659
|
+
.map((requirement) => `- Evidence for ${requirement.task_type}: ${requirement.evidence.map((entry) => `${entry.task_id} by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'}`).join(', ')}`),
|
|
1660
|
+
...policyState.overrides.slice(0, 5).map((entry) => `- Override ${entry.id}: ${(entry.task_types || []).join(', ') || 'all requirements'} by ${entry.approved_by || 'unknown'} (${entry.reason})`),
|
|
1661
|
+
...(policyOverrideSummary ? [`- Override effect: ${policyOverrideSummary}`] : []),
|
|
1662
|
+
'',
|
|
1663
|
+
]
|
|
1664
|
+
: []),
|
|
1665
|
+
'',
|
|
1666
|
+
'## Queue State',
|
|
1667
|
+
`- Status: ${queueState.status}`,
|
|
1668
|
+
...(queueState.item_id ? [`- Item: ${queueState.item_id}`] : []),
|
|
1669
|
+
`- Target branch: ${queueState.target_branch}`,
|
|
1670
|
+
...(queueState.merged_commit ? [`- Merged commit: ${queueState.merged_commit}`] : []),
|
|
1671
|
+
...(queueState.last_error_summary ? [`- Queue error: ${queueState.last_error_summary}`] : []),
|
|
1672
|
+
...(queueState.policy_override_summary ? [`- Policy override: ${queueState.policy_override_summary}`] : []),
|
|
1673
|
+
'',
|
|
1674
|
+
`## Next Action`,
|
|
1675
|
+
`- ${queueState.next_action || nextAction}`,
|
|
1676
|
+
].join('\n');
|
|
1677
|
+
|
|
1678
|
+
return {
|
|
1679
|
+
pipeline_id: pipelineId,
|
|
1680
|
+
title: status.title,
|
|
1681
|
+
ready_to_queue: readyToQueue,
|
|
1682
|
+
counts: status.counts,
|
|
1683
|
+
policy_state: policyState,
|
|
1684
|
+
landing,
|
|
1685
|
+
recovery_state: recoveryState,
|
|
1686
|
+
stale_clusters: staleClusters,
|
|
1687
|
+
stale_causal_waves: staleCausalWaves,
|
|
1688
|
+
trust_audit: trustAudit,
|
|
1689
|
+
queue_state: queueState,
|
|
1690
|
+
policy_override_summary: policyOverrideSummary,
|
|
1691
|
+
landing_error: landingError,
|
|
1692
|
+
next_action: queueState.next_action || nextAction,
|
|
1693
|
+
markdown,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
|
|
755
1697
|
export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir = null) {
|
|
756
1698
|
const summary = await buildPipelinePrSummary(db, repoRoot, pipelineId);
|
|
1699
|
+
const landingSummary = await buildPipelineLandingSummary(db, repoRoot, pipelineId);
|
|
757
1700
|
const bundleDir = outputDir || join(repoRoot, '.switchman', 'pipelines', pipelineId);
|
|
758
1701
|
mkdirSync(bundleDir, { recursive: true });
|
|
759
1702
|
|
|
760
1703
|
const summaryJsonPath = join(bundleDir, 'pr-summary.json');
|
|
761
1704
|
const summaryMarkdownPath = join(bundleDir, 'pr-summary.md');
|
|
762
1705
|
const prBodyPath = join(bundleDir, 'pr-body.md');
|
|
1706
|
+
const landingSummaryJsonPath = join(bundleDir, 'pipeline-landing-summary.json');
|
|
1707
|
+
const landingSummaryMarkdownPath = join(bundleDir, 'pipeline-landing-summary.md');
|
|
763
1708
|
|
|
764
1709
|
writeFileSync(summaryJsonPath, `${JSON.stringify(summary, null, 2)}\n`);
|
|
765
1710
|
writeFileSync(summaryMarkdownPath, `${summary.markdown}\n`);
|
|
766
1711
|
writeFileSync(prBodyPath, `${summary.pr_artifact.body}\n`);
|
|
1712
|
+
writeFileSync(landingSummaryJsonPath, `${JSON.stringify(landingSummary, null, 2)}\n`);
|
|
1713
|
+
writeFileSync(landingSummaryMarkdownPath, `${landingSummary.markdown}\n`);
|
|
767
1714
|
|
|
768
1715
|
logAuditEvent(db, {
|
|
769
1716
|
eventType: 'pipeline_pr_bundle_exported',
|
|
@@ -772,7 +1719,7 @@ export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir
|
|
|
772
1719
|
details: JSON.stringify({
|
|
773
1720
|
pipeline_id: pipelineId,
|
|
774
1721
|
output_dir: bundleDir,
|
|
775
|
-
files: [summaryJsonPath, summaryMarkdownPath, prBodyPath],
|
|
1722
|
+
files: [summaryJsonPath, summaryMarkdownPath, prBodyPath, landingSummaryJsonPath, landingSummaryMarkdownPath],
|
|
776
1723
|
}),
|
|
777
1724
|
});
|
|
778
1725
|
|
|
@@ -783,90 +1730,1310 @@ export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir
|
|
|
783
1730
|
summary_json: summaryJsonPath,
|
|
784
1731
|
summary_markdown: summaryMarkdownPath,
|
|
785
1732
|
pr_body_markdown: prBodyPath,
|
|
1733
|
+
landing_summary_json: landingSummaryJsonPath,
|
|
1734
|
+
landing_summary_markdown: landingSummaryMarkdownPath,
|
|
786
1735
|
},
|
|
787
1736
|
summary,
|
|
1737
|
+
landing_summary: landingSummary,
|
|
788
1738
|
};
|
|
789
1739
|
}
|
|
790
1740
|
|
|
791
|
-
function
|
|
792
|
-
|
|
1741
|
+
function resolvePipelineBranchForTask(worktreesByName, task) {
|
|
1742
|
+
const worktreeName = task.worktree || task.suggested_worktree || null;
|
|
1743
|
+
const branch = worktreeName ? worktreesByName.get(worktreeName)?.branch || null : null;
|
|
1744
|
+
return branch && branch !== 'main' && branch !== 'unknown' ? branch : null;
|
|
1745
|
+
}
|
|
793
1746
|
|
|
1747
|
+
function collectPipelineLandingCandidates(db, pipelineStatus) {
|
|
794
1748
|
const worktreesByName = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1749
|
+
const orderedBranches = [];
|
|
1750
|
+
const branchToWorktree = new Map();
|
|
1751
|
+
|
|
1752
|
+
for (const task of pipelineStatus.tasks) {
|
|
1753
|
+
const branch = resolvePipelineBranchForTask(worktreesByName, task);
|
|
1754
|
+
if (!branch) continue;
|
|
1755
|
+
if (!branchToWorktree.has(branch) && task.worktree) {
|
|
1756
|
+
branchToWorktree.set(branch, task.worktree);
|
|
1757
|
+
}
|
|
1758
|
+
if (!orderedBranches.includes(branch)) {
|
|
1759
|
+
orderedBranches.push(branch);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
800
1762
|
|
|
801
1763
|
const implementationBranches = uniq(
|
|
802
1764
|
pipelineStatus.tasks
|
|
803
1765
|
.filter((task) => task.task_spec?.task_type === 'implementation')
|
|
804
|
-
.map(
|
|
1766
|
+
.map((task) => resolvePipelineBranchForTask(worktreesByName, task))
|
|
805
1767
|
.filter(Boolean),
|
|
806
1768
|
);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1769
|
+
const candidateBranches = uniq(orderedBranches);
|
|
1770
|
+
const prioritizedBranches = [
|
|
1771
|
+
...implementationBranches,
|
|
1772
|
+
...candidateBranches.filter((branch) => !implementationBranches.includes(branch)),
|
|
1773
|
+
];
|
|
810
1774
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1775
|
+
return {
|
|
1776
|
+
implementationBranches,
|
|
1777
|
+
candidateBranches,
|
|
1778
|
+
prioritizedBranches,
|
|
1779
|
+
branchToWorktree,
|
|
1780
|
+
worktreesByName,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
816
1783
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1784
|
+
function getPipelineLandingBranchName(pipelineId, landingBranch = null) {
|
|
1785
|
+
return landingBranch || `switchman/pipeline-landing/${pipelineId}`;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function listPipelineLandingEvents(db, pipelineId, branch) {
|
|
1789
|
+
return listAuditEvents(db, {
|
|
1790
|
+
eventType: 'pipeline_landing_branch_materialized',
|
|
1791
|
+
status: 'allowed',
|
|
1792
|
+
limit: 500,
|
|
1793
|
+
}).flatMap((event) => {
|
|
1794
|
+
try {
|
|
1795
|
+
const details = JSON.parse(event.details || '{}');
|
|
1796
|
+
if (details.pipeline_id !== pipelineId || details.branch !== branch) {
|
|
1797
|
+
return [];
|
|
1798
|
+
}
|
|
1799
|
+
return [{
|
|
1800
|
+
audit_id: event.id,
|
|
1801
|
+
created_at: event.created_at,
|
|
1802
|
+
...details,
|
|
1803
|
+
}];
|
|
1804
|
+
} catch {
|
|
1805
|
+
return [];
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function getLatestLandingResolvedEvent(db, pipelineId, branch) {
|
|
1811
|
+
return listAuditEvents(db, {
|
|
1812
|
+
eventType: 'pipeline_landing_recovery_resumed',
|
|
1813
|
+
status: 'allowed',
|
|
1814
|
+
limit: 200,
|
|
1815
|
+
}).flatMap((event) => {
|
|
1816
|
+
try {
|
|
1817
|
+
const details = JSON.parse(event.details || '{}');
|
|
1818
|
+
if (details.pipeline_id !== pipelineId || details.branch !== branch) {
|
|
1819
|
+
return [];
|
|
1820
|
+
}
|
|
1821
|
+
return [{
|
|
1822
|
+
audit_id: event.id,
|
|
1823
|
+
created_at: event.created_at,
|
|
1824
|
+
...details,
|
|
1825
|
+
}];
|
|
1826
|
+
} catch {
|
|
1827
|
+
return [];
|
|
1828
|
+
}
|
|
1829
|
+
})[0] || null;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function getLatestLandingRecoveryPrepared(db, pipelineId, branch) {
|
|
1833
|
+
return listAuditEvents(db, {
|
|
1834
|
+
eventType: 'pipeline_landing_recovery_prepared',
|
|
1835
|
+
status: 'allowed',
|
|
1836
|
+
limit: 200,
|
|
1837
|
+
}).flatMap((event) => {
|
|
1838
|
+
try {
|
|
1839
|
+
const details = JSON.parse(event.details || '{}');
|
|
1840
|
+
if (details.pipeline_id !== pipelineId || details.branch !== branch) {
|
|
1841
|
+
return [];
|
|
1842
|
+
}
|
|
1843
|
+
return [{
|
|
1844
|
+
audit_id: event.id,
|
|
1845
|
+
created_at: event.created_at,
|
|
1846
|
+
...details,
|
|
1847
|
+
}];
|
|
1848
|
+
} catch {
|
|
1849
|
+
return [];
|
|
1850
|
+
}
|
|
1851
|
+
})[0] || null;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function getLatestLandingRecoveryCleared(db, pipelineId, branch) {
|
|
1855
|
+
return listAuditEvents(db, {
|
|
1856
|
+
eventType: 'pipeline_landing_recovery_cleared',
|
|
1857
|
+
status: 'allowed',
|
|
1858
|
+
limit: 200,
|
|
1859
|
+
}).flatMap((event) => {
|
|
1860
|
+
try {
|
|
1861
|
+
const details = JSON.parse(event.details || '{}');
|
|
1862
|
+
if (details.pipeline_id !== pipelineId || details.branch !== branch) {
|
|
1863
|
+
return [];
|
|
1864
|
+
}
|
|
1865
|
+
return [{
|
|
1866
|
+
audit_id: event.id,
|
|
1867
|
+
created_at: event.created_at,
|
|
1868
|
+
...details,
|
|
1869
|
+
}];
|
|
1870
|
+
} catch {
|
|
1871
|
+
return [];
|
|
1872
|
+
}
|
|
1873
|
+
})[0] || null;
|
|
1874
|
+
}
|
|
820
1875
|
|
|
821
|
-
|
|
822
|
-
if (
|
|
823
|
-
|
|
1876
|
+
function buildRecoveryState(repoRoot, branch, recoveryRecord, latestResolved) {
|
|
1877
|
+
if (!recoveryRecord) return null;
|
|
1878
|
+
const recoveryPath = recoveryRecord.recovery_path || null;
|
|
1879
|
+
const pathExists = recoveryPath ? existsSync(recoveryPath) : false;
|
|
1880
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1881
|
+
const normalizedRecoveryPath = recoveryPath
|
|
1882
|
+
? (pathExists ? realpathSync(recoveryPath) : recoveryPath)
|
|
1883
|
+
: null;
|
|
1884
|
+
const tracked = recoveryPath
|
|
1885
|
+
? gitWorktrees.some((worktree) => worktree.path === recoveryPath || worktree.path === normalizedRecoveryPath)
|
|
1886
|
+
: false;
|
|
1887
|
+
const branchWorktree = branch
|
|
1888
|
+
? gitWorktrees.find((worktree) => worktree.branch === branch) || null
|
|
1889
|
+
: null;
|
|
1890
|
+
const resolved = Boolean(latestResolved && latestResolved.audit_id > recoveryRecord.audit_id);
|
|
1891
|
+
let status;
|
|
1892
|
+
|
|
1893
|
+
if (resolved) {
|
|
1894
|
+
if (tracked) {
|
|
1895
|
+
status = 'resolved';
|
|
1896
|
+
} else if (branchWorktree) {
|
|
1897
|
+
status = 'resolved_moved';
|
|
1898
|
+
} else if (pathExists) {
|
|
1899
|
+
status = 'resolved_untracked';
|
|
1900
|
+
} else {
|
|
1901
|
+
status = 'resolved_missing';
|
|
1902
|
+
}
|
|
1903
|
+
} else if (tracked) {
|
|
1904
|
+
status = 'active';
|
|
1905
|
+
} else if (branchWorktree) {
|
|
1906
|
+
status = 'moved';
|
|
1907
|
+
} else if (pathExists) {
|
|
1908
|
+
status = 'untracked';
|
|
1909
|
+
} else {
|
|
1910
|
+
status = 'missing';
|
|
824
1911
|
}
|
|
825
1912
|
|
|
826
|
-
return
|
|
1913
|
+
return {
|
|
1914
|
+
path: recoveryPath,
|
|
1915
|
+
exists: pathExists,
|
|
1916
|
+
tracked,
|
|
1917
|
+
branch_worktree_path: branchWorktree?.path || null,
|
|
1918
|
+
status,
|
|
1919
|
+
};
|
|
827
1920
|
}
|
|
828
1921
|
|
|
829
|
-
|
|
1922
|
+
function getLatestLandingFailure(db, pipelineId, branch) {
|
|
1923
|
+
return listAuditEvents(db, {
|
|
1924
|
+
eventType: 'pipeline_landing_branch_materialized',
|
|
1925
|
+
status: 'denied',
|
|
1926
|
+
limit: 200,
|
|
1927
|
+
}).flatMap((event) => {
|
|
1928
|
+
try {
|
|
1929
|
+
const details = JSON.parse(event.details || '{}');
|
|
1930
|
+
if (details.pipeline_id !== pipelineId || details.branch !== branch) {
|
|
1931
|
+
return [];
|
|
1932
|
+
}
|
|
1933
|
+
return [{
|
|
1934
|
+
audit_id: event.id,
|
|
1935
|
+
created_at: event.created_at,
|
|
1936
|
+
reason_code: event.reason_code || null,
|
|
1937
|
+
...details,
|
|
1938
|
+
}];
|
|
1939
|
+
} catch {
|
|
1940
|
+
return [];
|
|
1941
|
+
}
|
|
1942
|
+
})[0] || null;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function collectBranchHeadCommits(repoRoot, branches) {
|
|
1946
|
+
return Object.fromEntries(
|
|
1947
|
+
branches.map((branch) => [branch, gitRevParse(repoRoot, branch)]),
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
export function getPipelineLandingBranchStatus(
|
|
830
1952
|
db,
|
|
831
1953
|
repoRoot,
|
|
832
1954
|
pipelineId,
|
|
833
1955
|
{
|
|
834
1956
|
baseBranch = 'main',
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
ghCommand = 'gh',
|
|
838
|
-
outputDir = null,
|
|
1957
|
+
landingBranch = null,
|
|
1958
|
+
requireCompleted = true,
|
|
839
1959
|
} = {},
|
|
840
1960
|
) {
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1961
|
+
const pipelineStatus = getPipelineStatus(db, pipelineId);
|
|
1962
|
+
if (requireCompleted) {
|
|
1963
|
+
const unfinishedTasks = pipelineStatus.tasks.filter((task) => task.status !== 'done');
|
|
1964
|
+
if (unfinishedTasks.length > 0) {
|
|
1965
|
+
throw new Error(`Pipeline ${pipelineId} is not ready to land. Complete remaining tasks first: ${unfinishedTasks.map((task) => task.id).join(', ')}.`);
|
|
1966
|
+
}
|
|
847
1967
|
}
|
|
848
1968
|
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
'--base',
|
|
853
|
-
baseBranch,
|
|
854
|
-
'--head',
|
|
855
|
-
resolvedHeadBranch,
|
|
856
|
-
'--title',
|
|
857
|
-
bundle.summary.pr_artifact.title,
|
|
858
|
-
'--body-file',
|
|
859
|
-
bundle.files.pr_body_markdown,
|
|
860
|
-
];
|
|
861
|
-
|
|
862
|
-
if (draft) {
|
|
863
|
-
args.push('--draft');
|
|
1969
|
+
const { candidateBranches, prioritizedBranches } = collectPipelineLandingCandidates(db, pipelineStatus);
|
|
1970
|
+
if (candidateBranches.length === 0) {
|
|
1971
|
+
throw new Error(`Pipeline ${pipelineId} has no landed worktree branch to materialize.`);
|
|
864
1972
|
}
|
|
865
1973
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1974
|
+
if (candidateBranches.length === 1) {
|
|
1975
|
+
const branch = candidateBranches[0];
|
|
1976
|
+
return {
|
|
1977
|
+
pipeline_id: pipelineId,
|
|
1978
|
+
branch,
|
|
1979
|
+
base_branch: baseBranch,
|
|
1980
|
+
synthetic: false,
|
|
1981
|
+
branch_exists: Boolean(gitRevParse(repoRoot, branch)),
|
|
1982
|
+
branch_head_commit: gitRevParse(repoRoot, branch),
|
|
1983
|
+
component_branches: [branch],
|
|
1984
|
+
component_commits: { [branch]: gitRevParse(repoRoot, branch) },
|
|
1985
|
+
strategy: 'single_branch',
|
|
1986
|
+
stale: false,
|
|
1987
|
+
stale_reasons: [],
|
|
1988
|
+
last_materialized: null,
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const resolvedLandingBranch = getPipelineLandingBranchName(pipelineId, landingBranch);
|
|
1993
|
+
const branchExists = gitBranchExists(repoRoot, resolvedLandingBranch);
|
|
1994
|
+
const branchHeadCommit = branchExists ? gitRevParse(repoRoot, resolvedLandingBranch) : null;
|
|
1995
|
+
const baseCommit = gitRevParse(repoRoot, baseBranch);
|
|
1996
|
+
const componentCommits = collectBranchHeadCommits(repoRoot, prioritizedBranches);
|
|
1997
|
+
const latestMaterialized = listPipelineLandingEvents(db, pipelineId, resolvedLandingBranch)[0] || null;
|
|
1998
|
+
const latestResolved = getLatestLandingResolvedEvent(db, pipelineId, resolvedLandingBranch);
|
|
1999
|
+
const latestRecoveryPreparedCandidate = getLatestLandingRecoveryPrepared(db, pipelineId, resolvedLandingBranch);
|
|
2000
|
+
const latestRecoveryCleared = getLatestLandingRecoveryCleared(db, pipelineId, resolvedLandingBranch);
|
|
2001
|
+
const latestRecoveryPrepared = latestRecoveryPreparedCandidate
|
|
2002
|
+
&& (!latestRecoveryCleared || latestRecoveryPreparedCandidate.audit_id > latestRecoveryCleared.audit_id)
|
|
2003
|
+
? latestRecoveryPreparedCandidate
|
|
2004
|
+
: null;
|
|
2005
|
+
const recoveryState = buildRecoveryState(repoRoot, resolvedLandingBranch, latestRecoveryPrepared, latestResolved);
|
|
2006
|
+
const latestEvent = latestResolved && (!latestMaterialized || latestResolved.audit_id > latestMaterialized.audit_id)
|
|
2007
|
+
? latestResolved
|
|
2008
|
+
: latestMaterialized;
|
|
2009
|
+
const latestFailureCandidate = getLatestLandingFailure(db, pipelineId, resolvedLandingBranch);
|
|
2010
|
+
const latestFailure = latestFailureCandidate && (!latestEvent || latestFailureCandidate.audit_id > latestEvent.audit_id)
|
|
2011
|
+
? latestFailureCandidate
|
|
2012
|
+
: null;
|
|
2013
|
+
const staleReasons = [];
|
|
2014
|
+
|
|
2015
|
+
if (!latestEvent) {
|
|
2016
|
+
staleReasons.push({
|
|
2017
|
+
code: 'not_materialized',
|
|
2018
|
+
summary: 'Landing branch has not been materialized yet.',
|
|
2019
|
+
});
|
|
2020
|
+
} else {
|
|
2021
|
+
if (!branchExists) {
|
|
2022
|
+
staleReasons.push({
|
|
2023
|
+
code: 'landing_branch_missing',
|
|
2024
|
+
summary: `Landing branch ${resolvedLandingBranch} no longer exists.`,
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const recordedComponents = Array.isArray(latestEvent.component_branches)
|
|
2029
|
+
? latestEvent.component_branches
|
|
2030
|
+
: [];
|
|
2031
|
+
if (recordedComponents.join('\n') !== prioritizedBranches.join('\n')) {
|
|
2032
|
+
staleReasons.push({
|
|
2033
|
+
code: 'component_set_changed',
|
|
2034
|
+
summary: 'The pipeline now resolves to a different set of component branches.',
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
if (latestEvent.base_commit && baseCommit && latestEvent.base_commit !== baseCommit) {
|
|
2039
|
+
staleReasons.push({
|
|
2040
|
+
code: 'base_branch_moved',
|
|
2041
|
+
summary: `${baseBranch} moved from ${latestEvent.base_commit.slice(0, 12)} to ${baseCommit.slice(0, 12)}.`,
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const recordedCommits = latestEvent.component_commits || {};
|
|
2046
|
+
for (const branch of prioritizedBranches) {
|
|
2047
|
+
const previousCommit = recordedCommits[branch] || null;
|
|
2048
|
+
const currentCommit = componentCommits[branch] || null;
|
|
2049
|
+
if (previousCommit && currentCommit && previousCommit !== currentCommit) {
|
|
2050
|
+
staleReasons.push({
|
|
2051
|
+
code: 'component_branch_moved',
|
|
2052
|
+
branch,
|
|
2053
|
+
summary: `${branch} moved from ${previousCommit.slice(0, 12)} to ${currentCommit.slice(0, 12)}.`,
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if (
|
|
2059
|
+
latestEvent.head_commit &&
|
|
2060
|
+
branchHeadCommit &&
|
|
2061
|
+
latestEvent.head_commit !== branchHeadCommit
|
|
2062
|
+
) {
|
|
2063
|
+
staleReasons.push({
|
|
2064
|
+
code: 'landing_branch_drifted',
|
|
2065
|
+
summary: `Landing branch head changed from ${latestEvent.head_commit.slice(0, 12)} to ${branchHeadCommit.slice(0, 12)} outside Switchman.`,
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
return {
|
|
2071
|
+
pipeline_id: pipelineId,
|
|
2072
|
+
branch: resolvedLandingBranch,
|
|
2073
|
+
base_branch: baseBranch,
|
|
2074
|
+
synthetic: true,
|
|
2075
|
+
branch_exists: branchExists,
|
|
2076
|
+
branch_head_commit: branchHeadCommit,
|
|
2077
|
+
component_branches: prioritizedBranches,
|
|
2078
|
+
component_commits: componentCommits,
|
|
2079
|
+
strategy: 'synthetic_integration_branch',
|
|
2080
|
+
stale: staleReasons.some((reason) => reason.code !== 'not_materialized'),
|
|
2081
|
+
stale_reasons: staleReasons.filter((reason) => reason.code !== 'not_materialized'),
|
|
2082
|
+
last_failure: latestFailure ? {
|
|
2083
|
+
audit_id: latestFailure.audit_id,
|
|
2084
|
+
created_at: latestFailure.created_at,
|
|
2085
|
+
reason_code: latestFailure.reason_code || null,
|
|
2086
|
+
failed_branch: latestFailure.failed_branch || null,
|
|
2087
|
+
conflicting_files: Array.isArray(latestFailure.conflicting_files) ? latestFailure.conflicting_files : [],
|
|
2088
|
+
output: latestFailure.output || null,
|
|
2089
|
+
command: latestFailure.command || null,
|
|
2090
|
+
next_action: latestFailure.next_action || null,
|
|
2091
|
+
} : null,
|
|
2092
|
+
last_recovery: latestRecoveryPrepared ? {
|
|
2093
|
+
audit_id: latestRecoveryPrepared.audit_id,
|
|
2094
|
+
created_at: latestRecoveryPrepared.created_at,
|
|
2095
|
+
recovery_path: latestRecoveryPrepared.recovery_path || null,
|
|
2096
|
+
failed_branch: latestRecoveryPrepared.failed_branch || null,
|
|
2097
|
+
conflicting_files: Array.isArray(latestRecoveryPrepared.conflicting_files) ? latestRecoveryPrepared.conflicting_files : [],
|
|
2098
|
+
inspect_command: latestRecoveryPrepared.inspect_command || null,
|
|
2099
|
+
resume_command: latestRecoveryPrepared.resume_command || null,
|
|
2100
|
+
state: recoveryState,
|
|
2101
|
+
} : null,
|
|
2102
|
+
last_materialized: latestEvent ? {
|
|
2103
|
+
audit_id: latestEvent.audit_id,
|
|
2104
|
+
created_at: latestEvent.created_at,
|
|
2105
|
+
head_commit: latestEvent.head_commit || null,
|
|
2106
|
+
base_commit: latestEvent.base_commit || null,
|
|
2107
|
+
component_branches: Array.isArray(latestEvent.component_branches) ? latestEvent.component_branches : [],
|
|
2108
|
+
component_commits: latestEvent.component_commits || {},
|
|
2109
|
+
} : null,
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
export function resolvePipelineLandingTarget(
|
|
2114
|
+
db,
|
|
2115
|
+
repoRoot,
|
|
2116
|
+
pipelineStatus,
|
|
2117
|
+
{
|
|
2118
|
+
explicitHeadBranch = null,
|
|
2119
|
+
requireCompleted = false,
|
|
2120
|
+
allowCurrentBranchFallback = true,
|
|
2121
|
+
} = {},
|
|
2122
|
+
) {
|
|
2123
|
+
if (explicitHeadBranch) {
|
|
2124
|
+
return {
|
|
2125
|
+
branch: explicitHeadBranch,
|
|
2126
|
+
worktree: null,
|
|
2127
|
+
strategy: 'explicit',
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (requireCompleted) {
|
|
2132
|
+
const unfinishedTasks = pipelineStatus.tasks.filter((task) => task.status !== 'done');
|
|
2133
|
+
if (unfinishedTasks.length > 0) {
|
|
2134
|
+
throw new Error(`Pipeline ${pipelineStatus.pipeline_id} is not ready to queue. Complete remaining tasks first: ${unfinishedTasks.map((task) => task.id).join(', ')}.`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const { implementationBranches, candidateBranches, branchToWorktree } = collectPipelineLandingCandidates(db, pipelineStatus);
|
|
2139
|
+
if (implementationBranches.length === 1) {
|
|
2140
|
+
const branch = implementationBranches[0];
|
|
2141
|
+
const worktree = branchToWorktree.get(branch) || null;
|
|
2142
|
+
return { branch, worktree, strategy: 'implementation_branch' };
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
if (candidateBranches.length === 1) {
|
|
2146
|
+
const branch = candidateBranches[0];
|
|
2147
|
+
const worktree = branchToWorktree.get(branch) || null;
|
|
2148
|
+
return { branch, worktree, strategy: 'single_branch' };
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (allowCurrentBranchFallback) {
|
|
2152
|
+
const currentBranch = getWorktreeBranch(repoRoot);
|
|
2153
|
+
if (currentBranch && currentBranch !== 'main') {
|
|
2154
|
+
return { branch: currentBranch, worktree: null, strategy: 'current_branch' };
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
throw new Error(`Pipeline ${pipelineStatus.pipeline_id} spans multiple branches (${candidateBranches.join(', ') || 'none inferred'}). Queue a branch or worktree explicitly.`);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export function materializePipelineLandingBranch(
|
|
2162
|
+
db,
|
|
2163
|
+
repoRoot,
|
|
2164
|
+
pipelineId,
|
|
2165
|
+
{
|
|
2166
|
+
baseBranch = 'main',
|
|
2167
|
+
landingBranch = null,
|
|
2168
|
+
requireCompleted = true,
|
|
2169
|
+
refresh = false,
|
|
2170
|
+
} = {},
|
|
2171
|
+
) {
|
|
2172
|
+
const pipelineStatus = getPipelineStatus(db, pipelineId);
|
|
2173
|
+
const { candidateBranches, prioritizedBranches, branchToWorktree } = collectPipelineLandingCandidates(db, pipelineStatus);
|
|
2174
|
+
const landingStatus = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2175
|
+
baseBranch,
|
|
2176
|
+
landingBranch,
|
|
2177
|
+
requireCompleted,
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
if (candidateBranches.length === 1) {
|
|
2181
|
+
const branch = candidateBranches[0];
|
|
2182
|
+
return {
|
|
2183
|
+
pipeline_id: pipelineId,
|
|
2184
|
+
branch,
|
|
2185
|
+
base_branch: baseBranch,
|
|
2186
|
+
worktree: branchToWorktree.get(branch) || null,
|
|
2187
|
+
synthetic: false,
|
|
2188
|
+
component_branches: [branch],
|
|
2189
|
+
strategy: 'single_branch',
|
|
2190
|
+
head_commit: null,
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
if (landingStatus.last_materialized && !landingStatus.stale) {
|
|
2195
|
+
return {
|
|
2196
|
+
pipeline_id: pipelineId,
|
|
2197
|
+
branch: landingStatus.branch,
|
|
2198
|
+
base_branch: baseBranch,
|
|
2199
|
+
worktree: null,
|
|
2200
|
+
synthetic: true,
|
|
2201
|
+
component_branches: landingStatus.component_branches,
|
|
2202
|
+
component_commits: landingStatus.component_commits,
|
|
2203
|
+
strategy: 'synthetic_integration_branch',
|
|
2204
|
+
head_commit: landingStatus.branch_head_commit,
|
|
2205
|
+
refreshed: false,
|
|
2206
|
+
reused_existing: true,
|
|
2207
|
+
stale: false,
|
|
2208
|
+
stale_reasons: [],
|
|
2209
|
+
last_materialized: landingStatus.last_materialized,
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (landingStatus.stale && !refresh) {
|
|
2214
|
+
const summaries = landingStatus.stale_reasons.map((reason) => reason.summary).join(' ');
|
|
2215
|
+
throw new Error(`Landing branch ${landingStatus.branch} is stale. ${summaries} Run \`switchman pipeline land ${pipelineId} --refresh${landingBranch ? ` --branch ${landingBranch}` : ''}\` to rebuild it.`);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
const resolvedLandingBranch = getPipelineLandingBranchName(pipelineId, landingBranch);
|
|
2219
|
+
const tempWorktreePath = join(tmpdir(), `switchman-landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
2220
|
+
let materialized;
|
|
2221
|
+
const landingOperation = startOperationJournalEntry(db, {
|
|
2222
|
+
scopeType: 'pipeline',
|
|
2223
|
+
scopeId: pipelineId,
|
|
2224
|
+
operationType: 'landing_materialize',
|
|
2225
|
+
details: JSON.stringify({
|
|
2226
|
+
pipeline_id: pipelineId,
|
|
2227
|
+
branch: resolvedLandingBranch,
|
|
2228
|
+
base_branch: baseBranch,
|
|
2229
|
+
component_branches: prioritizedBranches,
|
|
2230
|
+
refresh: Boolean(refresh),
|
|
2231
|
+
}),
|
|
2232
|
+
});
|
|
2233
|
+
const landingTempResource = createTempResource(db, {
|
|
2234
|
+
scopeType: 'pipeline',
|
|
2235
|
+
scopeId: pipelineId,
|
|
2236
|
+
operationId: landingOperation.id,
|
|
2237
|
+
resourceType: 'landing_temp_worktree',
|
|
2238
|
+
path: tempWorktreePath,
|
|
2239
|
+
branch: resolvedLandingBranch,
|
|
2240
|
+
details: stringifyResourceDetails({
|
|
2241
|
+
pipeline_id: pipelineId,
|
|
2242
|
+
branch: resolvedLandingBranch,
|
|
2243
|
+
base_branch: baseBranch,
|
|
2244
|
+
component_branches: prioritizedBranches,
|
|
2245
|
+
operation_type: 'landing_materialize',
|
|
2246
|
+
}),
|
|
2247
|
+
});
|
|
2248
|
+
try {
|
|
2249
|
+
materialized = gitMaterializeIntegrationBranch(repoRoot, {
|
|
2250
|
+
branch: resolvedLandingBranch,
|
|
2251
|
+
baseBranch,
|
|
2252
|
+
mergeBranches: prioritizedBranches,
|
|
2253
|
+
tempWorktreePath,
|
|
2254
|
+
});
|
|
2255
|
+
finishOperationJournalEntry(db, landingOperation.id, {
|
|
2256
|
+
status: 'completed',
|
|
2257
|
+
details: JSON.stringify({
|
|
2258
|
+
pipeline_id: pipelineId,
|
|
2259
|
+
branch: resolvedLandingBranch,
|
|
2260
|
+
base_branch: baseBranch,
|
|
2261
|
+
component_branches: prioritizedBranches,
|
|
2262
|
+
head_commit: materialized.head_commit,
|
|
2263
|
+
refresh: Boolean(refresh),
|
|
2264
|
+
}),
|
|
2265
|
+
});
|
|
2266
|
+
} catch (err) {
|
|
2267
|
+
finishOperationJournalEntry(db, landingOperation.id, {
|
|
2268
|
+
status: 'failed',
|
|
2269
|
+
details: JSON.stringify({
|
|
2270
|
+
pipeline_id: pipelineId,
|
|
2271
|
+
branch: resolvedLandingBranch,
|
|
2272
|
+
base_branch: baseBranch,
|
|
2273
|
+
component_branches: prioritizedBranches,
|
|
2274
|
+
reason_code: err?.code || 'landing_branch_materialization_failed',
|
|
2275
|
+
failed_branch: err?.details?.failed_branch || null,
|
|
2276
|
+
error: String(err?.message || err),
|
|
2277
|
+
}),
|
|
2278
|
+
});
|
|
2279
|
+
const reasonCode = err?.code || 'landing_branch_materialization_failed';
|
|
2280
|
+
const nextAction = reasonCode === 'landing_branch_merge_conflict'
|
|
2281
|
+
? `open a recovery worktree with switchman pipeline land ${pipelineId} --recover`
|
|
2282
|
+
: reasonCode === 'landing_branch_missing_component'
|
|
2283
|
+
? `restore the missing branch and rerun switchman pipeline land ${pipelineId} --refresh`
|
|
2284
|
+
: reasonCode === 'landing_branch_missing_base'
|
|
2285
|
+
? `restore ${baseBranch} and rerun switchman pipeline land ${pipelineId} --refresh`
|
|
2286
|
+
: `inspect the landing failure and rerun switchman pipeline land ${pipelineId} --refresh`;
|
|
2287
|
+
logAuditEvent(db, {
|
|
2288
|
+
eventType: 'pipeline_landing_branch_materialized',
|
|
2289
|
+
status: 'denied',
|
|
2290
|
+
reasonCode,
|
|
2291
|
+
details: JSON.stringify({
|
|
2292
|
+
pipeline_id: pipelineId,
|
|
2293
|
+
branch: resolvedLandingBranch,
|
|
2294
|
+
base_branch: baseBranch,
|
|
2295
|
+
component_branches: prioritizedBranches,
|
|
2296
|
+
failed_branch: err?.details?.failed_branch || null,
|
|
2297
|
+
conflicting_files: err?.details?.conflicting_files || [],
|
|
2298
|
+
output: err?.details?.output || String(err.message || '').slice(0, 1000),
|
|
2299
|
+
command: reasonCode === 'landing_branch_merge_conflict'
|
|
2300
|
+
? `switchman pipeline land ${pipelineId} --recover`
|
|
2301
|
+
: `switchman pipeline land ${pipelineId} --refresh`,
|
|
2302
|
+
next_action: nextAction,
|
|
2303
|
+
}),
|
|
2304
|
+
});
|
|
2305
|
+
const wrapped = new Error(`${String(err.message || 'Landing branch materialization failed.')}\nnext: switchman explain landing ${pipelineId}`);
|
|
2306
|
+
wrapped.code = reasonCode;
|
|
2307
|
+
throw wrapped;
|
|
2308
|
+
} finally {
|
|
2309
|
+
updateTempResource(db, landingTempResource.id, {
|
|
2310
|
+
status: existsSync(tempWorktreePath) ? 'active' : 'released',
|
|
2311
|
+
details: stringifyResourceDetails({
|
|
2312
|
+
pipeline_id: pipelineId,
|
|
2313
|
+
branch: resolvedLandingBranch,
|
|
2314
|
+
base_branch: baseBranch,
|
|
2315
|
+
component_branches: prioritizedBranches,
|
|
2316
|
+
operation_type: 'landing_materialize',
|
|
2317
|
+
released_by: existsSync(tempWorktreePath) ? null : 'materialize_cleanup',
|
|
2318
|
+
}),
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
const baseCommit = gitRevParse(repoRoot, baseBranch);
|
|
2322
|
+
const componentCommits = collectBranchHeadCommits(repoRoot, prioritizedBranches);
|
|
2323
|
+
|
|
2324
|
+
logAuditEvent(db, {
|
|
2325
|
+
eventType: 'pipeline_landing_branch_materialized',
|
|
2326
|
+
status: 'allowed',
|
|
2327
|
+
details: JSON.stringify({
|
|
2328
|
+
pipeline_id: pipelineId,
|
|
2329
|
+
branch: resolvedLandingBranch,
|
|
2330
|
+
base_branch: baseBranch,
|
|
2331
|
+
base_commit: baseCommit,
|
|
2332
|
+
component_branches: prioritizedBranches,
|
|
2333
|
+
component_commits: componentCommits,
|
|
2334
|
+
head_commit: materialized.head_commit,
|
|
2335
|
+
}),
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
return {
|
|
2339
|
+
pipeline_id: pipelineId,
|
|
2340
|
+
branch: resolvedLandingBranch,
|
|
2341
|
+
base_branch: baseBranch,
|
|
2342
|
+
worktree: null,
|
|
2343
|
+
synthetic: true,
|
|
2344
|
+
component_branches: prioritizedBranches,
|
|
2345
|
+
component_commits: componentCommits,
|
|
2346
|
+
strategy: 'synthetic_integration_branch',
|
|
2347
|
+
head_commit: materialized.head_commit,
|
|
2348
|
+
refreshed: refresh || Boolean(landingStatus.last_materialized),
|
|
2349
|
+
reused_existing: false,
|
|
2350
|
+
stale: false,
|
|
2351
|
+
stale_reasons: [],
|
|
2352
|
+
last_materialized: {
|
|
2353
|
+
head_commit: materialized.head_commit,
|
|
2354
|
+
base_commit: baseCommit,
|
|
2355
|
+
component_branches: prioritizedBranches,
|
|
2356
|
+
component_commits: componentCommits,
|
|
2357
|
+
},
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
export function getPipelineLandingExplainReport(
|
|
2362
|
+
db,
|
|
2363
|
+
repoRoot,
|
|
2364
|
+
pipelineId,
|
|
2365
|
+
options = {},
|
|
2366
|
+
) {
|
|
2367
|
+
const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2368
|
+
requireCompleted: false,
|
|
2369
|
+
...options,
|
|
2370
|
+
});
|
|
2371
|
+
const nextAction = landing.last_failure?.next_action
|
|
2372
|
+
|| (landing.stale
|
|
2373
|
+
? `switchman pipeline land ${pipelineId} --refresh`
|
|
2374
|
+
: landing.synthetic
|
|
2375
|
+
? `switchman queue add --pipeline ${pipelineId}`
|
|
2376
|
+
: `switchman queue add ${landing.branch}`);
|
|
2377
|
+
return {
|
|
2378
|
+
pipeline_id: pipelineId,
|
|
2379
|
+
landing,
|
|
2380
|
+
next_action: nextAction,
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
export function preparePipelineLandingRecovery(
|
|
2385
|
+
db,
|
|
2386
|
+
repoRoot,
|
|
2387
|
+
pipelineId,
|
|
2388
|
+
{
|
|
2389
|
+
baseBranch = 'main',
|
|
2390
|
+
landingBranch = null,
|
|
2391
|
+
recoveryPath = null,
|
|
2392
|
+
replaceExisting = false,
|
|
2393
|
+
} = {},
|
|
2394
|
+
) {
|
|
2395
|
+
const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2396
|
+
baseBranch,
|
|
2397
|
+
landingBranch,
|
|
2398
|
+
requireCompleted: true,
|
|
2399
|
+
});
|
|
2400
|
+
if (!landing.synthetic) {
|
|
2401
|
+
throw new Error(`Pipeline ${pipelineId} does not need a synthetic landing recovery worktree.`);
|
|
2402
|
+
}
|
|
2403
|
+
if (landing.last_failure?.reason_code !== 'landing_branch_merge_conflict') {
|
|
2404
|
+
if (
|
|
2405
|
+
!replaceExisting
|
|
2406
|
+
&& landing.last_recovery?.state?.status === 'active'
|
|
2407
|
+
&& landing.last_recovery?.recovery_path
|
|
2408
|
+
) {
|
|
2409
|
+
return {
|
|
2410
|
+
pipeline_id: pipelineId,
|
|
2411
|
+
branch: landing.branch,
|
|
2412
|
+
base_branch: baseBranch,
|
|
2413
|
+
recovery_path: landing.last_recovery.recovery_path,
|
|
2414
|
+
failed_branch: landing.last_recovery.failed_branch || null,
|
|
2415
|
+
conflicting_files: landing.last_recovery.conflicting_files || [],
|
|
2416
|
+
inspect_command: landing.last_recovery.inspect_command || `git -C ${JSON.stringify(landing.last_recovery.recovery_path)} status`,
|
|
2417
|
+
resume_command: landing.last_recovery.resume_command || `switchman queue add --pipeline ${pipelineId}`,
|
|
2418
|
+
reused_existing: true,
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
throw new Error(`Pipeline ${pipelineId} does not have a merge-conflict landing failure to recover.`);
|
|
2422
|
+
}
|
|
2423
|
+
if (landing.last_recovery?.state?.status && !replaceExisting) {
|
|
2424
|
+
if (landing.last_recovery.state.status === 'active' && landing.last_recovery.recovery_path) {
|
|
2425
|
+
return {
|
|
2426
|
+
pipeline_id: pipelineId,
|
|
2427
|
+
branch: landing.branch,
|
|
2428
|
+
base_branch: baseBranch,
|
|
2429
|
+
recovery_path: landing.last_recovery.recovery_path,
|
|
2430
|
+
failed_branch: landing.last_recovery.failed_branch || null,
|
|
2431
|
+
conflicting_files: landing.last_recovery.conflicting_files || [],
|
|
2432
|
+
inspect_command: landing.last_recovery.inspect_command || `git -C ${JSON.stringify(landing.last_recovery.recovery_path)} status`,
|
|
2433
|
+
resume_command: landing.last_recovery.resume_command || `switchman queue add --pipeline ${pipelineId}`,
|
|
2434
|
+
reused_existing: true,
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
throw new Error(`Recovery worktree already exists for ${pipelineId} at ${landing.last_recovery.recovery_path}. Reuse it or rerun with \`switchman pipeline land ${pipelineId} --recover --replace-recovery\`.`);
|
|
2438
|
+
}
|
|
2439
|
+
if (landing.last_recovery?.state?.path && replaceExisting) {
|
|
2440
|
+
cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
|
|
2441
|
+
baseBranch,
|
|
2442
|
+
landingBranch,
|
|
2443
|
+
recoveryPath: landing.last_recovery.state.path,
|
|
2444
|
+
reason: 'replaced',
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
const resolvedRecoveryPath = recoveryPath || join(
|
|
2449
|
+
tmpdir(),
|
|
2450
|
+
`${basename(repoRoot)}-landing-recover-${pipelineId}-${Date.now()}`,
|
|
2451
|
+
);
|
|
2452
|
+
const recoveryPrepareOperation = startOperationJournalEntry(db, {
|
|
2453
|
+
scopeType: 'pipeline',
|
|
2454
|
+
scopeId: pipelineId,
|
|
2455
|
+
operationType: 'landing_recovery_prepare',
|
|
2456
|
+
details: JSON.stringify({
|
|
2457
|
+
pipeline_id: pipelineId,
|
|
2458
|
+
branch: landing.branch,
|
|
2459
|
+
base_branch: baseBranch,
|
|
2460
|
+
recovery_path: resolvedRecoveryPath,
|
|
2461
|
+
}),
|
|
2462
|
+
});
|
|
2463
|
+
const recoveryResource = createTempResource(db, {
|
|
2464
|
+
scopeType: 'pipeline',
|
|
2465
|
+
scopeId: pipelineId,
|
|
2466
|
+
operationId: recoveryPrepareOperation.id,
|
|
2467
|
+
resourceType: 'landing_recovery_worktree',
|
|
2468
|
+
path: resolvedRecoveryPath,
|
|
2469
|
+
branch: landing.branch,
|
|
2470
|
+
details: stringifyResourceDetails({
|
|
2471
|
+
pipeline_id: pipelineId,
|
|
2472
|
+
branch: landing.branch,
|
|
2473
|
+
base_branch: baseBranch,
|
|
2474
|
+
operation_type: 'landing_recovery_prepare',
|
|
2475
|
+
recovery_path: resolvedRecoveryPath,
|
|
2476
|
+
}),
|
|
2477
|
+
});
|
|
2478
|
+
const prepared = gitPrepareIntegrationRecoveryWorktree(repoRoot, {
|
|
2479
|
+
branch: landing.branch,
|
|
2480
|
+
baseBranch,
|
|
2481
|
+
mergeBranches: landing.component_branches,
|
|
2482
|
+
recoveryPath: resolvedRecoveryPath,
|
|
2483
|
+
});
|
|
2484
|
+
if (prepared.ok) {
|
|
2485
|
+
try {
|
|
2486
|
+
gitRemoveWorktree(repoRoot, resolvedRecoveryPath);
|
|
2487
|
+
} catch {
|
|
2488
|
+
// Best-effort cleanup; repair will reconcile any leftover tracked resource.
|
|
2489
|
+
}
|
|
2490
|
+
updateTempResource(db, recoveryResource.id, {
|
|
2491
|
+
status: existsSync(resolvedRecoveryPath) ? 'active' : 'released',
|
|
2492
|
+
details: stringifyResourceDetails({
|
|
2493
|
+
pipeline_id: pipelineId,
|
|
2494
|
+
branch: landing.branch,
|
|
2495
|
+
base_branch: baseBranch,
|
|
2496
|
+
operation_type: 'landing_recovery_prepare',
|
|
2497
|
+
recovery_path: resolvedRecoveryPath,
|
|
2498
|
+
released_by: existsSync(resolvedRecoveryPath) ? null : 'recovery_prepare_cleanup',
|
|
2499
|
+
reason: 'conflict_already_resolved',
|
|
2500
|
+
}),
|
|
2501
|
+
});
|
|
2502
|
+
finishOperationJournalEntry(db, recoveryPrepareOperation.id, {
|
|
2503
|
+
status: 'failed',
|
|
2504
|
+
details: JSON.stringify({
|
|
2505
|
+
pipeline_id: pipelineId,
|
|
2506
|
+
branch: landing.branch,
|
|
2507
|
+
base_branch: baseBranch,
|
|
2508
|
+
recovery_path: resolvedRecoveryPath,
|
|
2509
|
+
error: 'Recovery preparation no longer needed because the landing merge conflict is already resolved.',
|
|
2510
|
+
}),
|
|
2511
|
+
});
|
|
2512
|
+
throw new Error(`Pipeline ${pipelineId} no longer has an unresolved landing merge conflict to recover.`);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const inspectCommand = `git -C ${JSON.stringify(prepared.recovery_path)} status`;
|
|
2516
|
+
const resumeCommand = `switchman queue add --pipeline ${pipelineId}`;
|
|
2517
|
+
|
|
2518
|
+
logAuditEvent(db, {
|
|
2519
|
+
eventType: 'pipeline_landing_recovery_prepared',
|
|
2520
|
+
status: 'allowed',
|
|
2521
|
+
details: JSON.stringify({
|
|
2522
|
+
pipeline_id: pipelineId,
|
|
2523
|
+
branch: landing.branch,
|
|
2524
|
+
base_branch: baseBranch,
|
|
2525
|
+
recovery_path: prepared.recovery_path,
|
|
2526
|
+
failed_branch: prepared.failed_branch,
|
|
2527
|
+
conflicting_files: prepared.conflicting_files,
|
|
2528
|
+
inspect_command: inspectCommand,
|
|
2529
|
+
resume_command: resumeCommand,
|
|
2530
|
+
}),
|
|
2531
|
+
});
|
|
2532
|
+
finishOperationJournalEntry(db, recoveryPrepareOperation.id, {
|
|
2533
|
+
status: 'completed',
|
|
2534
|
+
details: JSON.stringify({
|
|
2535
|
+
pipeline_id: pipelineId,
|
|
2536
|
+
branch: landing.branch,
|
|
2537
|
+
base_branch: baseBranch,
|
|
2538
|
+
recovery_path: prepared.recovery_path,
|
|
2539
|
+
failed_branch: prepared.failed_branch,
|
|
2540
|
+
conflicting_files: prepared.conflicting_files,
|
|
2541
|
+
}),
|
|
2542
|
+
});
|
|
2543
|
+
updateTempResource(db, recoveryResource.id, {
|
|
2544
|
+
status: 'active',
|
|
2545
|
+
details: stringifyResourceDetails({
|
|
2546
|
+
pipeline_id: pipelineId,
|
|
2547
|
+
branch: landing.branch,
|
|
2548
|
+
base_branch: baseBranch,
|
|
2549
|
+
operation_type: 'landing_recovery_prepare',
|
|
2550
|
+
recovery_path: prepared.recovery_path,
|
|
2551
|
+
failed_branch: prepared.failed_branch,
|
|
2552
|
+
conflicting_files: prepared.conflicting_files,
|
|
2553
|
+
}),
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
return {
|
|
2557
|
+
pipeline_id: pipelineId,
|
|
2558
|
+
branch: landing.branch,
|
|
2559
|
+
base_branch: baseBranch,
|
|
2560
|
+
recovery_path: prepared.recovery_path,
|
|
2561
|
+
failed_branch: prepared.failed_branch,
|
|
2562
|
+
conflicting_files: prepared.conflicting_files,
|
|
2563
|
+
inspect_command: inspectCommand,
|
|
2564
|
+
resume_command: resumeCommand,
|
|
2565
|
+
reused_existing: false,
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
export function resumePipelineLandingRecovery(
|
|
2570
|
+
db,
|
|
2571
|
+
repoRoot,
|
|
2572
|
+
pipelineId,
|
|
2573
|
+
{
|
|
2574
|
+
baseBranch = 'main',
|
|
2575
|
+
landingBranch = null,
|
|
2576
|
+
recoveryPath = null,
|
|
2577
|
+
} = {},
|
|
2578
|
+
) {
|
|
2579
|
+
const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2580
|
+
baseBranch,
|
|
2581
|
+
landingBranch,
|
|
2582
|
+
requireCompleted: true,
|
|
2583
|
+
});
|
|
2584
|
+
if (!landing.synthetic) {
|
|
2585
|
+
throw new Error(`Pipeline ${pipelineId} does not need a synthetic landing recovery worktree.`);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
const resolvedRecoveryPath = recoveryPath || landing.last_recovery?.recovery_path || null;
|
|
2589
|
+
if (!landing.last_failure && landing.last_materialized && landing.branch_head_commit) {
|
|
2590
|
+
return {
|
|
2591
|
+
pipeline_id: pipelineId,
|
|
2592
|
+
branch: landing.branch,
|
|
2593
|
+
base_branch: baseBranch,
|
|
2594
|
+
recovery_path: resolvedRecoveryPath,
|
|
2595
|
+
head_commit: landing.branch_head_commit,
|
|
2596
|
+
resume_command: `switchman queue add --pipeline ${pipelineId}`,
|
|
2597
|
+
already_resumed: true,
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
if (!resolvedRecoveryPath) {
|
|
2601
|
+
throw new Error(`No recovery worktree is recorded for ${pipelineId}. Run \`switchman pipeline land ${pipelineId} --recover\` first.`);
|
|
2602
|
+
}
|
|
2603
|
+
const recoveryResumeOperation = startOperationJournalEntry(db, {
|
|
2604
|
+
scopeType: 'pipeline',
|
|
2605
|
+
scopeId: pipelineId,
|
|
2606
|
+
operationType: 'landing_recovery_resume',
|
|
2607
|
+
details: JSON.stringify({
|
|
2608
|
+
pipeline_id: pipelineId,
|
|
2609
|
+
branch: landing.branch,
|
|
2610
|
+
recovery_path: resolvedRecoveryPath,
|
|
2611
|
+
}),
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
const currentBranch = getWorktreeBranch(resolvedRecoveryPath);
|
|
2615
|
+
if (currentBranch !== landing.branch) {
|
|
2616
|
+
finishOperationJournalEntry(db, recoveryResumeOperation.id, {
|
|
2617
|
+
status: 'failed',
|
|
2618
|
+
details: JSON.stringify({
|
|
2619
|
+
pipeline_id: pipelineId,
|
|
2620
|
+
branch: landing.branch,
|
|
2621
|
+
recovery_path: resolvedRecoveryPath,
|
|
2622
|
+
error: `Recovery worktree is on ${currentBranch || 'no branch'}.`,
|
|
2623
|
+
}),
|
|
2624
|
+
});
|
|
2625
|
+
throw new Error(`Recovery worktree must be on ${landing.branch}, but is on ${currentBranch || 'no branch'}.`);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
const statusResult = spawnSync('git', ['status', '--porcelain'], {
|
|
2629
|
+
cwd: resolvedRecoveryPath,
|
|
2630
|
+
encoding: 'utf8',
|
|
2631
|
+
});
|
|
2632
|
+
if (statusResult.status !== 0) {
|
|
2633
|
+
finishOperationJournalEntry(db, recoveryResumeOperation.id, {
|
|
2634
|
+
status: 'failed',
|
|
2635
|
+
details: JSON.stringify({
|
|
2636
|
+
pipeline_id: pipelineId,
|
|
2637
|
+
branch: landing.branch,
|
|
2638
|
+
recovery_path: resolvedRecoveryPath,
|
|
2639
|
+
error: `Could not inspect recovery worktree ${resolvedRecoveryPath}.`,
|
|
2640
|
+
}),
|
|
2641
|
+
});
|
|
2642
|
+
throw new Error(`Could not inspect recovery worktree ${resolvedRecoveryPath}.`);
|
|
2643
|
+
}
|
|
2644
|
+
const pendingChanges = String(statusResult.stdout || '').trim();
|
|
2645
|
+
if (pendingChanges) {
|
|
2646
|
+
finishOperationJournalEntry(db, recoveryResumeOperation.id, {
|
|
2647
|
+
status: 'failed',
|
|
2648
|
+
details: JSON.stringify({
|
|
2649
|
+
pipeline_id: pipelineId,
|
|
2650
|
+
branch: landing.branch,
|
|
2651
|
+
recovery_path: resolvedRecoveryPath,
|
|
2652
|
+
error: 'Recovery worktree still has unresolved or uncommitted changes.',
|
|
2653
|
+
}),
|
|
2654
|
+
});
|
|
2655
|
+
throw new Error(`Recovery worktree ${resolvedRecoveryPath} still has unresolved or uncommitted changes. Commit the resolved landing branch first.`);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
const recoveredHead = gitRevParse(resolvedRecoveryPath, 'HEAD');
|
|
2659
|
+
const branchHead = gitRevParse(repoRoot, landing.branch);
|
|
2660
|
+
if (!recoveredHead || !branchHead || recoveredHead !== branchHead) {
|
|
2661
|
+
finishOperationJournalEntry(db, recoveryResumeOperation.id, {
|
|
2662
|
+
status: 'failed',
|
|
2663
|
+
details: JSON.stringify({
|
|
2664
|
+
pipeline_id: pipelineId,
|
|
2665
|
+
branch: landing.branch,
|
|
2666
|
+
recovery_path: resolvedRecoveryPath,
|
|
2667
|
+
error: 'Recovery worktree head is not aligned with the landing branch head.',
|
|
2668
|
+
}),
|
|
2669
|
+
});
|
|
2670
|
+
throw new Error(`Recovery worktree ${resolvedRecoveryPath} is not aligned with ${landing.branch}. Push or commit the resolved landing branch there first.`);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const componentCommits = collectBranchHeadCommits(repoRoot, landing.component_branches);
|
|
2674
|
+
logAuditEvent(db, {
|
|
2675
|
+
eventType: 'pipeline_landing_recovery_resumed',
|
|
2676
|
+
status: 'allowed',
|
|
2677
|
+
details: JSON.stringify({
|
|
2678
|
+
pipeline_id: pipelineId,
|
|
2679
|
+
branch: landing.branch,
|
|
2680
|
+
base_branch: baseBranch,
|
|
2681
|
+
base_commit: gitRevParse(repoRoot, baseBranch),
|
|
2682
|
+
head_commit: branchHead,
|
|
2683
|
+
component_branches: landing.component_branches,
|
|
2684
|
+
component_commits: componentCommits,
|
|
2685
|
+
recovery_path: resolvedRecoveryPath,
|
|
2686
|
+
resume_command: `switchman queue add --pipeline ${pipelineId}`,
|
|
2687
|
+
}),
|
|
2688
|
+
});
|
|
2689
|
+
finishOperationJournalEntry(db, recoveryResumeOperation.id, {
|
|
2690
|
+
status: 'completed',
|
|
2691
|
+
details: JSON.stringify({
|
|
2692
|
+
pipeline_id: pipelineId,
|
|
2693
|
+
branch: landing.branch,
|
|
2694
|
+
recovery_path: resolvedRecoveryPath,
|
|
2695
|
+
head_commit: branchHead,
|
|
2696
|
+
}),
|
|
2697
|
+
});
|
|
2698
|
+
const activeRecoveryResources = listTempResources(db, {
|
|
2699
|
+
scopeType: 'pipeline',
|
|
2700
|
+
scopeId: pipelineId,
|
|
2701
|
+
resourceType: 'landing_recovery_worktree',
|
|
2702
|
+
status: 'active',
|
|
2703
|
+
limit: 20,
|
|
2704
|
+
}).filter((resource) => resource.path === resolvedRecoveryPath);
|
|
2705
|
+
for (const resource of activeRecoveryResources) {
|
|
2706
|
+
updateTempResource(db, resource.id, {
|
|
2707
|
+
status: 'resolved',
|
|
2708
|
+
details: stringifyResourceDetails({
|
|
2709
|
+
pipeline_id: pipelineId,
|
|
2710
|
+
branch: landing.branch,
|
|
2711
|
+
operation_type: 'landing_recovery_resume',
|
|
2712
|
+
recovery_path: resolvedRecoveryPath,
|
|
2713
|
+
head_commit: branchHead,
|
|
2714
|
+
}),
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
return {
|
|
2719
|
+
pipeline_id: pipelineId,
|
|
2720
|
+
branch: landing.branch,
|
|
2721
|
+
base_branch: baseBranch,
|
|
2722
|
+
recovery_path: resolvedRecoveryPath,
|
|
2723
|
+
head_commit: branchHead,
|
|
2724
|
+
resume_command: `switchman queue add --pipeline ${pipelineId}`,
|
|
2725
|
+
already_resumed: false,
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
export function cleanupPipelineLandingRecovery(
|
|
2730
|
+
db,
|
|
2731
|
+
repoRoot,
|
|
2732
|
+
pipelineId,
|
|
2733
|
+
{
|
|
2734
|
+
baseBranch = 'main',
|
|
2735
|
+
landingBranch = null,
|
|
2736
|
+
recoveryPath = null,
|
|
2737
|
+
reason = 'manual_cleanup',
|
|
2738
|
+
} = {},
|
|
2739
|
+
) {
|
|
2740
|
+
const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2741
|
+
baseBranch,
|
|
2742
|
+
landingBranch,
|
|
2743
|
+
requireCompleted: true,
|
|
2744
|
+
});
|
|
2745
|
+
const targetPath = recoveryPath || landing.last_recovery?.recovery_path || null;
|
|
2746
|
+
if (!targetPath) {
|
|
2747
|
+
throw new Error(`No recovery worktree is recorded for ${pipelineId}.`);
|
|
2748
|
+
}
|
|
2749
|
+
const recoveryCleanupOperation = startOperationJournalEntry(db, {
|
|
2750
|
+
scopeType: 'pipeline',
|
|
2751
|
+
scopeId: pipelineId,
|
|
2752
|
+
operationType: 'landing_recovery_cleanup',
|
|
2753
|
+
details: JSON.stringify({
|
|
2754
|
+
pipeline_id: pipelineId,
|
|
2755
|
+
branch: landing.branch,
|
|
2756
|
+
recovery_path: targetPath,
|
|
2757
|
+
reason,
|
|
2758
|
+
}),
|
|
2759
|
+
});
|
|
2760
|
+
|
|
2761
|
+
const exists = existsSync(targetPath);
|
|
2762
|
+
const normalizedTargetPath = exists ? realpathSync(targetPath) : targetPath;
|
|
2763
|
+
const tracked = listGitWorktrees(repoRoot).some((worktree) =>
|
|
2764
|
+
worktree.path === targetPath || worktree.path === normalizedTargetPath,
|
|
2765
|
+
);
|
|
2766
|
+
if (tracked && exists) {
|
|
2767
|
+
gitRemoveWorktree(repoRoot, targetPath);
|
|
2768
|
+
}
|
|
2769
|
+
const trackedRecoveryResources = listTempResources(db, {
|
|
2770
|
+
scopeType: 'pipeline',
|
|
2771
|
+
scopeId: pipelineId,
|
|
2772
|
+
resourceType: 'landing_recovery_worktree',
|
|
2773
|
+
limit: 20,
|
|
2774
|
+
}).filter((resource) => resource.path === targetPath && resource.status !== 'released');
|
|
2775
|
+
for (const resource of trackedRecoveryResources) {
|
|
2776
|
+
const nextStatus = resource.status === 'abandoned' && !existsSync(targetPath)
|
|
2777
|
+
? 'abandoned'
|
|
2778
|
+
: existsSync(targetPath)
|
|
2779
|
+
? 'active'
|
|
2780
|
+
: 'released';
|
|
2781
|
+
updateTempResource(db, resource.id, {
|
|
2782
|
+
status: nextStatus,
|
|
2783
|
+
details: stringifyResourceDetails({
|
|
2784
|
+
pipeline_id: pipelineId,
|
|
2785
|
+
branch: landing.branch,
|
|
2786
|
+
operation_type: 'landing_recovery_cleanup',
|
|
2787
|
+
recovery_path: targetPath,
|
|
2788
|
+
removed: tracked && exists,
|
|
2789
|
+
reason,
|
|
2790
|
+
prior_status: resource.status,
|
|
2791
|
+
}),
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
logAuditEvent(db, {
|
|
2796
|
+
eventType: 'pipeline_landing_recovery_cleared',
|
|
2797
|
+
status: 'allowed',
|
|
2798
|
+
details: JSON.stringify({
|
|
2799
|
+
pipeline_id: pipelineId,
|
|
2800
|
+
branch: landing.branch,
|
|
2801
|
+
recovery_path: targetPath,
|
|
2802
|
+
existed: exists,
|
|
2803
|
+
tracked,
|
|
2804
|
+
reason,
|
|
2805
|
+
}),
|
|
2806
|
+
});
|
|
2807
|
+
finishOperationJournalEntry(db, recoveryCleanupOperation.id, {
|
|
2808
|
+
status: 'completed',
|
|
2809
|
+
details: JSON.stringify({
|
|
2810
|
+
pipeline_id: pipelineId,
|
|
2811
|
+
branch: landing.branch,
|
|
2812
|
+
recovery_path: targetPath,
|
|
2813
|
+
removed: tracked && exists,
|
|
2814
|
+
reason,
|
|
2815
|
+
}),
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
return {
|
|
2819
|
+
pipeline_id: pipelineId,
|
|
2820
|
+
branch: landing.branch,
|
|
2821
|
+
recovery_path: targetPath,
|
|
2822
|
+
existed: exists,
|
|
2823
|
+
tracked,
|
|
2824
|
+
removed: tracked && exists,
|
|
2825
|
+
reason,
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
export function repairPipelineState(
|
|
2830
|
+
db,
|
|
2831
|
+
repoRoot,
|
|
2832
|
+
pipelineId,
|
|
2833
|
+
{
|
|
2834
|
+
baseBranch = 'main',
|
|
2835
|
+
landingBranch = null,
|
|
2836
|
+
} = {},
|
|
2837
|
+
) {
|
|
2838
|
+
const actions = [];
|
|
2839
|
+
let notes = [];
|
|
2840
|
+
let landing;
|
|
2841
|
+
|
|
2842
|
+
try {
|
|
2843
|
+
landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2844
|
+
baseBranch,
|
|
2845
|
+
landingBranch,
|
|
2846
|
+
requireCompleted: false,
|
|
2847
|
+
});
|
|
2848
|
+
} catch (err) {
|
|
2849
|
+
return {
|
|
2850
|
+
pipeline_id: pipelineId,
|
|
2851
|
+
repaired: false,
|
|
2852
|
+
actions,
|
|
2853
|
+
notes: [String(err.message || 'Pipeline repair found no landing state to repair.')],
|
|
2854
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
const recoveryStatus = landing.last_recovery?.state?.status || null;
|
|
2859
|
+
if (['missing', 'resolved_missing', 'untracked', 'resolved_untracked', 'moved', 'resolved_moved'].includes(recoveryStatus) && landing.last_recovery?.recovery_path) {
|
|
2860
|
+
const cleared = cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
|
|
2861
|
+
baseBranch,
|
|
2862
|
+
landingBranch,
|
|
2863
|
+
recoveryPath: landing.last_recovery.recovery_path,
|
|
2864
|
+
reason: `repair_${recoveryStatus}_recovery`,
|
|
2865
|
+
});
|
|
2866
|
+
actions.push({
|
|
2867
|
+
kind: 'recovery_state_cleared',
|
|
2868
|
+
recovery_path: cleared.recovery_path,
|
|
2869
|
+
removed: cleared.removed,
|
|
2870
|
+
recovery_status: recoveryStatus,
|
|
2871
|
+
branch_worktree_path: landing.last_recovery?.state?.branch_worktree_path || null,
|
|
2872
|
+
});
|
|
2873
|
+
landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2874
|
+
baseBranch,
|
|
2875
|
+
landingBranch,
|
|
2876
|
+
requireCompleted: false,
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
const needsLandingRefresh = landing.synthetic
|
|
2881
|
+
&& !landing.last_failure
|
|
2882
|
+
&& (
|
|
2883
|
+
landing.stale
|
|
2884
|
+
|| (landing.branch_exists && !landing.last_materialized)
|
|
2885
|
+
);
|
|
2886
|
+
|
|
2887
|
+
if (needsLandingRefresh) {
|
|
2888
|
+
const refreshed = materializePipelineLandingBranch(db, repoRoot, pipelineId, {
|
|
2889
|
+
baseBranch,
|
|
2890
|
+
landingBranch,
|
|
2891
|
+
requireCompleted: false,
|
|
2892
|
+
refresh: true,
|
|
2893
|
+
});
|
|
2894
|
+
actions.push({
|
|
2895
|
+
kind: landing.last_materialized ? 'landing_branch_refreshed' : 'landing_branch_reconciled',
|
|
2896
|
+
branch: refreshed.branch,
|
|
2897
|
+
head_commit: refreshed.head_commit || null,
|
|
2898
|
+
});
|
|
2899
|
+
landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
2900
|
+
baseBranch,
|
|
2901
|
+
landingBranch,
|
|
2902
|
+
requireCompleted: false,
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
if (actions.length === 0) {
|
|
2907
|
+
notes = ['No repair action was needed.'];
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
const nextAction = landing.last_failure?.next_action
|
|
2911
|
+
|| (landing.synthetic
|
|
2912
|
+
? `switchman queue add --pipeline ${pipelineId}`
|
|
2913
|
+
: landing.branch
|
|
2914
|
+
? `switchman queue add ${landing.branch}`
|
|
2915
|
+
: `switchman pipeline status ${pipelineId}`);
|
|
2916
|
+
|
|
2917
|
+
return {
|
|
2918
|
+
pipeline_id: pipelineId,
|
|
2919
|
+
repaired: actions.length > 0,
|
|
2920
|
+
actions,
|
|
2921
|
+
notes,
|
|
2922
|
+
landing,
|
|
2923
|
+
next_action: nextAction,
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
export function preparePipelineLandingTarget(
|
|
2928
|
+
db,
|
|
2929
|
+
repoRoot,
|
|
2930
|
+
pipelineId,
|
|
2931
|
+
{
|
|
2932
|
+
baseBranch = 'main',
|
|
2933
|
+
explicitHeadBranch = null,
|
|
2934
|
+
requireCompleted = false,
|
|
2935
|
+
allowCurrentBranchFallback = true,
|
|
2936
|
+
landingBranch = null,
|
|
2937
|
+
} = {},
|
|
2938
|
+
) {
|
|
2939
|
+
const pipelineStatus = getPipelineStatus(db, pipelineId);
|
|
2940
|
+
const completedPipeline = pipelineStatus.tasks.length > 0 && pipelineStatus.tasks.every((task) => task.status === 'done');
|
|
2941
|
+
const { candidateBranches } = collectPipelineLandingCandidates(db, pipelineStatus);
|
|
2942
|
+
|
|
2943
|
+
if (!explicitHeadBranch && completedPipeline && candidateBranches.length > 1) {
|
|
2944
|
+
return materializePipelineLandingBranch(db, repoRoot, pipelineId, {
|
|
2945
|
+
baseBranch,
|
|
2946
|
+
landingBranch,
|
|
2947
|
+
requireCompleted: true,
|
|
2948
|
+
refresh: true,
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
try {
|
|
2953
|
+
const resolved = resolvePipelineLandingTarget(db, repoRoot, pipelineStatus, {
|
|
2954
|
+
explicitHeadBranch,
|
|
2955
|
+
requireCompleted,
|
|
2956
|
+
allowCurrentBranchFallback,
|
|
2957
|
+
});
|
|
2958
|
+
return {
|
|
2959
|
+
pipeline_id: pipelineId,
|
|
2960
|
+
...resolved,
|
|
2961
|
+
synthetic: false,
|
|
2962
|
+
component_branches: [resolved.branch],
|
|
2963
|
+
head_commit: null,
|
|
2964
|
+
};
|
|
2965
|
+
} catch (err) {
|
|
2966
|
+
if (!String(err.message || '').includes('spans multiple branches')) {
|
|
2967
|
+
throw err;
|
|
2968
|
+
}
|
|
2969
|
+
return materializePipelineLandingBranch(db, repoRoot, pipelineId, {
|
|
2970
|
+
baseBranch,
|
|
2971
|
+
landingBranch,
|
|
2972
|
+
requireCompleted: true,
|
|
2973
|
+
refresh: true,
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
export async function publishPipelinePr(
|
|
2979
|
+
db,
|
|
2980
|
+
repoRoot,
|
|
2981
|
+
pipelineId,
|
|
2982
|
+
{
|
|
2983
|
+
baseBranch = 'main',
|
|
2984
|
+
headBranch = null,
|
|
2985
|
+
draft = false,
|
|
2986
|
+
ghCommand = 'gh',
|
|
2987
|
+
outputDir = null,
|
|
2988
|
+
} = {},
|
|
2989
|
+
) {
|
|
2990
|
+
const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, pipelineId);
|
|
2991
|
+
if (!policyGate.ok) {
|
|
2992
|
+
logAuditEvent(db, {
|
|
2993
|
+
eventType: 'pipeline_pr_published',
|
|
2994
|
+
status: 'denied',
|
|
2995
|
+
reasonCode: policyGate.reason_code,
|
|
2996
|
+
details: JSON.stringify({
|
|
2997
|
+
pipeline_id: pipelineId,
|
|
2998
|
+
base_branch: baseBranch,
|
|
2999
|
+
head_branch: headBranch,
|
|
3000
|
+
policy_state: policyGate.policy_state,
|
|
3001
|
+
next_action: policyGate.next_action,
|
|
3002
|
+
}),
|
|
3003
|
+
});
|
|
3004
|
+
throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
|
|
3008
|
+
const resolvedLandingTarget = preparePipelineLandingTarget(db, repoRoot, pipelineId, {
|
|
3009
|
+
baseBranch,
|
|
3010
|
+
explicitHeadBranch: headBranch,
|
|
3011
|
+
requireCompleted: false,
|
|
3012
|
+
allowCurrentBranchFallback: true,
|
|
3013
|
+
});
|
|
3014
|
+
const resolvedHeadBranch = resolvedLandingTarget.branch;
|
|
3015
|
+
|
|
3016
|
+
const args = [
|
|
3017
|
+
'pr',
|
|
3018
|
+
'create',
|
|
3019
|
+
'--base',
|
|
3020
|
+
baseBranch,
|
|
3021
|
+
'--head',
|
|
3022
|
+
resolvedHeadBranch,
|
|
3023
|
+
'--title',
|
|
3024
|
+
bundle.summary.pr_artifact.title,
|
|
3025
|
+
'--body-file',
|
|
3026
|
+
bundle.files.pr_body_markdown,
|
|
3027
|
+
];
|
|
3028
|
+
|
|
3029
|
+
if (draft) {
|
|
3030
|
+
args.push('--draft');
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
const result = spawnSync(ghCommand, args, {
|
|
3034
|
+
cwd: repoRoot,
|
|
3035
|
+
encoding: 'utf8',
|
|
3036
|
+
});
|
|
870
3037
|
|
|
871
3038
|
const ok = !result.error && result.status === 0;
|
|
872
3039
|
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
@@ -894,16 +3061,149 @@ export async function publishPipelinePr(
|
|
|
894
3061
|
pipeline_id: pipelineId,
|
|
895
3062
|
base_branch: baseBranch,
|
|
896
3063
|
head_branch: resolvedHeadBranch,
|
|
3064
|
+
landing_strategy: resolvedLandingTarget.strategy,
|
|
897
3065
|
draft,
|
|
898
3066
|
bundle,
|
|
899
3067
|
output,
|
|
900
3068
|
};
|
|
901
3069
|
}
|
|
902
3070
|
|
|
3071
|
+
export async function commentPipelinePr(
|
|
3072
|
+
db,
|
|
3073
|
+
repoRoot,
|
|
3074
|
+
pipelineId,
|
|
3075
|
+
{
|
|
3076
|
+
prNumber,
|
|
3077
|
+
ghCommand = 'gh',
|
|
3078
|
+
outputDir = null,
|
|
3079
|
+
updateExisting = false,
|
|
3080
|
+
} = {},
|
|
3081
|
+
) {
|
|
3082
|
+
if (!prNumber) {
|
|
3083
|
+
throw new Error('A pull request number is required.');
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
|
|
3087
|
+
const args = [
|
|
3088
|
+
'pr',
|
|
3089
|
+
'comment',
|
|
3090
|
+
String(prNumber),
|
|
3091
|
+
'--body-file',
|
|
3092
|
+
bundle.files.landing_summary_markdown,
|
|
3093
|
+
];
|
|
3094
|
+
|
|
3095
|
+
if (updateExisting) {
|
|
3096
|
+
args.push('--edit-last', '--create-if-none');
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
const result = spawnSync(ghCommand, args, {
|
|
3100
|
+
cwd: repoRoot,
|
|
3101
|
+
encoding: 'utf8',
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
const ok = !result.error && result.status === 0;
|
|
3105
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
3106
|
+
|
|
3107
|
+
logAuditEvent(db, {
|
|
3108
|
+
eventType: 'pipeline_pr_commented',
|
|
3109
|
+
status: ok ? 'allowed' : 'denied',
|
|
3110
|
+
reasonCode: ok ? null : 'pr_comment_failed',
|
|
3111
|
+
details: JSON.stringify({
|
|
3112
|
+
pipeline_id: pipelineId,
|
|
3113
|
+
pr_number: String(prNumber),
|
|
3114
|
+
gh_command: ghCommand,
|
|
3115
|
+
update_existing: updateExisting,
|
|
3116
|
+
body_file: bundle.files.landing_summary_markdown,
|
|
3117
|
+
exit_code: result.status,
|
|
3118
|
+
output: output.slice(0, 500),
|
|
3119
|
+
}),
|
|
3120
|
+
});
|
|
3121
|
+
|
|
3122
|
+
if (!ok) {
|
|
3123
|
+
throw new Error(result.error?.message || output || `gh pr comment failed with status ${result.status}`);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
return {
|
|
3127
|
+
pipeline_id: pipelineId,
|
|
3128
|
+
pr_number: String(prNumber),
|
|
3129
|
+
bundle,
|
|
3130
|
+
output,
|
|
3131
|
+
updated_existing: updateExisting,
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
export async function syncPipelinePr(
|
|
3136
|
+
db,
|
|
3137
|
+
repoRoot,
|
|
3138
|
+
pipelineId,
|
|
3139
|
+
{
|
|
3140
|
+
prNumber,
|
|
3141
|
+
ghCommand = 'gh',
|
|
3142
|
+
outputDir = null,
|
|
3143
|
+
updateExisting = true,
|
|
3144
|
+
} = {},
|
|
3145
|
+
) {
|
|
3146
|
+
const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
|
|
3147
|
+
let comment = null;
|
|
3148
|
+
|
|
3149
|
+
if (prNumber) {
|
|
3150
|
+
const args = [
|
|
3151
|
+
'pr',
|
|
3152
|
+
'comment',
|
|
3153
|
+
String(prNumber),
|
|
3154
|
+
'--body-file',
|
|
3155
|
+
bundle.files.landing_summary_markdown,
|
|
3156
|
+
];
|
|
3157
|
+
|
|
3158
|
+
if (updateExisting) {
|
|
3159
|
+
args.push('--edit-last', '--create-if-none');
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
const result = spawnSync(ghCommand, args, {
|
|
3163
|
+
cwd: repoRoot,
|
|
3164
|
+
encoding: 'utf8',
|
|
3165
|
+
});
|
|
3166
|
+
const ok = !result.error && result.status === 0;
|
|
3167
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
3168
|
+
|
|
3169
|
+
logAuditEvent(db, {
|
|
3170
|
+
eventType: 'pipeline_pr_synced',
|
|
3171
|
+
status: ok ? 'allowed' : 'denied',
|
|
3172
|
+
reasonCode: ok ? null : 'pr_sync_failed',
|
|
3173
|
+
details: JSON.stringify({
|
|
3174
|
+
pipeline_id: pipelineId,
|
|
3175
|
+
pr_number: String(prNumber),
|
|
3176
|
+
gh_command: ghCommand,
|
|
3177
|
+
update_existing: updateExisting,
|
|
3178
|
+
body_file: bundle.files.landing_summary_markdown,
|
|
3179
|
+
exit_code: result.status,
|
|
3180
|
+
output: output.slice(0, 500),
|
|
3181
|
+
}),
|
|
3182
|
+
});
|
|
3183
|
+
|
|
3184
|
+
if (!ok) {
|
|
3185
|
+
throw new Error(result.error?.message || output || `gh pr comment failed with status ${result.status}`);
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
comment = {
|
|
3189
|
+
pr_number: String(prNumber),
|
|
3190
|
+
output,
|
|
3191
|
+
updated_existing: updateExisting,
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
return {
|
|
3196
|
+
pipeline_id: pipelineId,
|
|
3197
|
+
bundle,
|
|
3198
|
+
comment,
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
|
|
903
3202
|
export async function createPipelineFollowupTasks(db, repoRoot, pipelineId) {
|
|
904
3203
|
const status = getPipelineStatus(db, pipelineId);
|
|
905
3204
|
const report = await scanAllWorktrees(db, repoRoot);
|
|
906
3205
|
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
3206
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
907
3207
|
const existingTitles = new Set(status.tasks.map((task) => task.title));
|
|
908
3208
|
const hasPlannedTestsTask = status.tasks.some((task) =>
|
|
909
3209
|
task.task_spec?.task_type === 'tests' && !task.title.startsWith('Add missing tests'),
|
|
@@ -973,6 +3273,46 @@ export async function createPipelineFollowupTasks(db, repoRoot, pipelineId) {
|
|
|
973
3273
|
}
|
|
974
3274
|
}
|
|
975
3275
|
|
|
3276
|
+
const implementationTasks = status.tasks.filter((task) => task.task_spec?.task_type === 'implementation');
|
|
3277
|
+
for (const task of implementationTasks) {
|
|
3278
|
+
const taskDomains = task.task_spec?.subsystem_tags || [];
|
|
3279
|
+
const requiredTaskTypes = new Set(task.task_spec?.validation_rules?.required_completed_task_types || []);
|
|
3280
|
+
for (const domain of taskDomains) {
|
|
3281
|
+
const rule = changePolicy.domain_rules?.[domain];
|
|
3282
|
+
for (const taskType of rule?.required_completed_task_types || []) {
|
|
3283
|
+
requiredTaskTypes.add(taskType);
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
const completedTypes = new Set(
|
|
3288
|
+
status.tasks
|
|
3289
|
+
.filter((candidate) => candidate.status === 'done')
|
|
3290
|
+
.map((candidate) => candidate.task_spec?.task_type)
|
|
3291
|
+
.filter(Boolean),
|
|
3292
|
+
);
|
|
3293
|
+
|
|
3294
|
+
if (requiredTaskTypes.has('tests') && !completedTypes.has('tests')) {
|
|
3295
|
+
maybeCreateTask(
|
|
3296
|
+
`Add policy-required tests for ${task.title}`,
|
|
3297
|
+
`Policy-triggered follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
if (requiredTaskTypes.has('docs') && !completedTypes.has('docs')) {
|
|
3302
|
+
maybeCreateTask(
|
|
3303
|
+
`Add policy-required docs for ${task.title}`,
|
|
3304
|
+
`Policy-triggered follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
if (requiredTaskTypes.has('governance') && !completedTypes.has('governance')) {
|
|
3309
|
+
maybeCreateTask(
|
|
3310
|
+
`Add policy review for ${task.title}`,
|
|
3311
|
+
`Policy-triggered governance follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
|
|
3312
|
+
);
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
|
|
976
3316
|
logAuditEvent(db, {
|
|
977
3317
|
eventType: 'pipeline_followups_created',
|
|
978
3318
|
status: created.length > 0 ? 'allowed' : 'info',
|