switchman-dev 0.1.8 → 0.1.9
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 +118 -303
- package/package.json +1 -1
- package/src/cli/commands/audit.js +77 -0
- package/src/cli/commands/claude.js +37 -0
- package/src/cli/commands/gate.js +278 -0
- package/src/cli/commands/lease.js +256 -0
- package/src/cli/commands/mcp.js +45 -0
- package/src/cli/commands/monitor.js +191 -0
- package/src/cli/commands/queue.js +549 -0
- package/src/cli/commands/task.js +248 -0
- package/src/cli/commands/telemetry.js +108 -0
- package/src/cli/commands/worktree.js +85 -0
- package/src/cli/index.js +780 -1697
- package/src.zip +0 -0
- package/tests.zip +0 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
export function registerQueueCommands(program, {
|
|
2
|
+
buildQueueStatusSummary,
|
|
3
|
+
chalk,
|
|
4
|
+
colorForHealth,
|
|
5
|
+
escalateMergeQueueItem,
|
|
6
|
+
enqueueMergeItem,
|
|
7
|
+
evaluatePipelinePolicyGate,
|
|
8
|
+
getDb,
|
|
9
|
+
getRepo,
|
|
10
|
+
healthLabel,
|
|
11
|
+
listMergeQueue,
|
|
12
|
+
listMergeQueueEvents,
|
|
13
|
+
listWorktrees,
|
|
14
|
+
maybeCaptureTelemetry,
|
|
15
|
+
preparePipelineLandingTarget,
|
|
16
|
+
printErrorWithNext,
|
|
17
|
+
pushSyncEvent,
|
|
18
|
+
renderChip,
|
|
19
|
+
renderMetricRow,
|
|
20
|
+
renderPanel,
|
|
21
|
+
renderSignalStrip,
|
|
22
|
+
removeMergeQueueItem,
|
|
23
|
+
retryMergeQueueItem,
|
|
24
|
+
runMergeQueue,
|
|
25
|
+
sleepSync,
|
|
26
|
+
statusBadge,
|
|
27
|
+
}) {
|
|
28
|
+
const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
|
|
29
|
+
queueCmd.addHelpText('after', `
|
|
30
|
+
Examples:
|
|
31
|
+
switchman queue add --worktree agent1
|
|
32
|
+
switchman queue status
|
|
33
|
+
switchman queue run --watch
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
queueCmd
|
|
37
|
+
.command('add [branch]')
|
|
38
|
+
.description('Add a branch, workspace, or pipeline to the landing queue')
|
|
39
|
+
.option('--worktree <name>', 'Queue a registered workspace by name')
|
|
40
|
+
.option('--pipeline <pipelineId>', 'Queue a pipeline by id')
|
|
41
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
42
|
+
.option('--max-retries <n>', 'Maximum automatic retries', '1')
|
|
43
|
+
.option('--submitted-by <name>', 'Operator or automation name')
|
|
44
|
+
.option('--json', 'Output raw JSON')
|
|
45
|
+
.addHelpText('after', `
|
|
46
|
+
Examples:
|
|
47
|
+
switchman queue add feature/auth-hardening
|
|
48
|
+
switchman queue add --worktree agent2
|
|
49
|
+
switchman queue add --pipeline pipe-123
|
|
50
|
+
|
|
51
|
+
Pipeline landing rule:
|
|
52
|
+
switchman queue add --pipeline <id>
|
|
53
|
+
lands the pipeline's inferred landing branch.
|
|
54
|
+
If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
|
|
55
|
+
`)
|
|
56
|
+
.action(async (branch, opts) => {
|
|
57
|
+
const repoRoot = getRepo();
|
|
58
|
+
const db = getDb(repoRoot);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
let payload;
|
|
62
|
+
if (opts.worktree) {
|
|
63
|
+
const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
|
|
64
|
+
if (!worktree) {
|
|
65
|
+
throw new Error(`Workspace ${opts.worktree} is not registered.`);
|
|
66
|
+
}
|
|
67
|
+
payload = {
|
|
68
|
+
sourceType: 'worktree',
|
|
69
|
+
sourceRef: worktree.branch,
|
|
70
|
+
sourceWorktree: worktree.name,
|
|
71
|
+
targetBranch: opts.target,
|
|
72
|
+
maxRetries: opts.maxRetries,
|
|
73
|
+
submittedBy: opts.submittedBy || null,
|
|
74
|
+
};
|
|
75
|
+
} else if (opts.pipeline) {
|
|
76
|
+
const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
|
|
77
|
+
if (!policyGate.ok) {
|
|
78
|
+
throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
|
|
79
|
+
}
|
|
80
|
+
const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
|
|
81
|
+
baseBranch: opts.target || 'main',
|
|
82
|
+
requireCompleted: true,
|
|
83
|
+
allowCurrentBranchFallback: false,
|
|
84
|
+
});
|
|
85
|
+
payload = {
|
|
86
|
+
sourceType: 'pipeline',
|
|
87
|
+
sourceRef: landingTarget.branch,
|
|
88
|
+
sourcePipelineId: opts.pipeline,
|
|
89
|
+
sourceWorktree: landingTarget.worktree || null,
|
|
90
|
+
targetBranch: opts.target,
|
|
91
|
+
maxRetries: opts.maxRetries,
|
|
92
|
+
submittedBy: opts.submittedBy || null,
|
|
93
|
+
eventDetails: policyGate.override_applied
|
|
94
|
+
? {
|
|
95
|
+
policy_override_summary: policyGate.override_summary,
|
|
96
|
+
overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
|
|
97
|
+
}
|
|
98
|
+
: null,
|
|
99
|
+
};
|
|
100
|
+
} else if (branch) {
|
|
101
|
+
payload = {
|
|
102
|
+
sourceType: 'branch',
|
|
103
|
+
sourceRef: branch,
|
|
104
|
+
targetBranch: opts.target,
|
|
105
|
+
maxRetries: opts.maxRetries,
|
|
106
|
+
submittedBy: opts.submittedBy || null,
|
|
107
|
+
};
|
|
108
|
+
} else {
|
|
109
|
+
throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = enqueueMergeItem(db, payload);
|
|
113
|
+
db.close();
|
|
114
|
+
pushSyncEvent('queue_added', {
|
|
115
|
+
item_id: result.id,
|
|
116
|
+
source_type: result.source_type,
|
|
117
|
+
source_ref: result.source_ref,
|
|
118
|
+
source_worktree: result.source_worktree || null,
|
|
119
|
+
target_branch: result.target_branch,
|
|
120
|
+
}, { worktree: result.source_worktree || null }).catch(() => {});
|
|
121
|
+
|
|
122
|
+
if (opts.json) {
|
|
123
|
+
console.log(JSON.stringify(result, null, 2));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
128
|
+
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
129
|
+
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
130
|
+
if (payload.eventDetails?.policy_override_summary) {
|
|
131
|
+
console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
db.close();
|
|
135
|
+
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
queueCmd
|
|
141
|
+
.command('list')
|
|
142
|
+
.description('List merge queue items')
|
|
143
|
+
.option('--status <status>', 'Filter by queue status')
|
|
144
|
+
.option('--json', 'Output raw JSON')
|
|
145
|
+
.action((opts) => {
|
|
146
|
+
const repoRoot = getRepo();
|
|
147
|
+
const db = getDb(repoRoot);
|
|
148
|
+
const items = listMergeQueue(db, { status: opts.status || null });
|
|
149
|
+
db.close();
|
|
150
|
+
|
|
151
|
+
if (opts.json) {
|
|
152
|
+
console.log(JSON.stringify(items, null, 2));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (items.length === 0) {
|
|
157
|
+
console.log(chalk.dim('Merge queue is empty.'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
163
|
+
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
164
|
+
const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
|
|
165
|
+
const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
|
|
166
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
|
|
167
|
+
if (item.last_error_summary) {
|
|
168
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
169
|
+
}
|
|
170
|
+
if (item.next_action) {
|
|
171
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
queueCmd
|
|
177
|
+
.command('status')
|
|
178
|
+
.description('Show an operator-friendly merge queue summary')
|
|
179
|
+
.option('--json', 'Output raw JSON')
|
|
180
|
+
.addHelpText('after', `
|
|
181
|
+
Plain English:
|
|
182
|
+
Use this when finished branches are waiting to land and you want one safe queue view.
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
switchman queue status
|
|
186
|
+
switchman queue status --json
|
|
187
|
+
|
|
188
|
+
What it helps you answer:
|
|
189
|
+
- what lands next
|
|
190
|
+
- what is blocked
|
|
191
|
+
- what command should I run now
|
|
192
|
+
`)
|
|
193
|
+
.action((opts) => {
|
|
194
|
+
const repoRoot = getRepo();
|
|
195
|
+
const db = getDb(repoRoot);
|
|
196
|
+
const items = listMergeQueue(db);
|
|
197
|
+
const summary = buildQueueStatusSummary(items, { db, repoRoot });
|
|
198
|
+
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
199
|
+
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
200
|
+
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
201
|
+
db.close();
|
|
202
|
+
|
|
203
|
+
if (opts.json) {
|
|
204
|
+
console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (items.length === 0) {
|
|
209
|
+
console.log('');
|
|
210
|
+
console.log(chalk.bold('switchman queue status'));
|
|
211
|
+
console.log('');
|
|
212
|
+
console.log('Queue is empty.');
|
|
213
|
+
console.log(`Add finished work with: ${chalk.cyan('switchman queue add --worktree agent1')}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const queueHealth = summary.counts.blocked > 0
|
|
218
|
+
? 'block'
|
|
219
|
+
: summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
|
|
220
|
+
? 'warn'
|
|
221
|
+
: 'healthy';
|
|
222
|
+
const queueHealthColor = colorForHealth(queueHealth);
|
|
223
|
+
const retryingItems = items.filter((item) => item.status === 'retrying');
|
|
224
|
+
const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
|
|
225
|
+
const focusLine = focus
|
|
226
|
+
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
227
|
+
: 'Nothing waiting. Landing queue is clear.';
|
|
228
|
+
|
|
229
|
+
console.log('');
|
|
230
|
+
console.log(queueHealthColor('='.repeat(72)));
|
|
231
|
+
console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
|
|
232
|
+
console.log(queueHealthColor('='.repeat(72)));
|
|
233
|
+
console.log(renderSignalStrip([
|
|
234
|
+
renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
|
|
235
|
+
renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
|
|
236
|
+
renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
|
|
237
|
+
renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
|
|
238
|
+
renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
|
|
239
|
+
renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
240
|
+
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
241
|
+
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
242
|
+
]));
|
|
243
|
+
console.log(renderMetricRow([
|
|
244
|
+
{ label: 'items', value: items.length, color: chalk.white },
|
|
245
|
+
{ label: 'validating', value: summary.counts.validating, color: chalk.blue },
|
|
246
|
+
{ label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
|
|
247
|
+
{ label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
|
|
248
|
+
]));
|
|
249
|
+
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
250
|
+
|
|
251
|
+
const queueFocusLines = summary.next
|
|
252
|
+
? [
|
|
253
|
+
`${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
|
|
254
|
+
...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
|
|
255
|
+
...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
|
|
256
|
+
` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
|
|
257
|
+
]
|
|
258
|
+
: [chalk.dim('No queued landing work right now.')];
|
|
259
|
+
|
|
260
|
+
const queueHeldBackLines = summary.held_back.length > 0
|
|
261
|
+
? summary.held_back.flatMap((item) => {
|
|
262
|
+
const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
|
|
263
|
+
if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
|
|
264
|
+
if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
|
|
265
|
+
if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
|
|
266
|
+
return lines;
|
|
267
|
+
})
|
|
268
|
+
: [chalk.green('Nothing significant is being held back.')];
|
|
269
|
+
|
|
270
|
+
const queueBlockedLines = summary.blocked.length > 0
|
|
271
|
+
? summary.blocked.slice(0, 4).flatMap((item) => {
|
|
272
|
+
const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
273
|
+
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
274
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
275
|
+
return lines;
|
|
276
|
+
})
|
|
277
|
+
: [chalk.green('Nothing blocked.')];
|
|
278
|
+
|
|
279
|
+
const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
|
|
280
|
+
? items
|
|
281
|
+
.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
282
|
+
.slice(0, 4)
|
|
283
|
+
.flatMap((item) => {
|
|
284
|
+
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
285
|
+
if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
|
|
286
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
287
|
+
return lines;
|
|
288
|
+
})
|
|
289
|
+
: [chalk.green('No in-flight queue items right now.')];
|
|
290
|
+
|
|
291
|
+
const queueCommandLines = [
|
|
292
|
+
`${chalk.cyan('$')} switchman queue run`,
|
|
293
|
+
`${chalk.cyan('$')} switchman queue status --json`,
|
|
294
|
+
...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const queuePlanLines = [
|
|
298
|
+
...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
299
|
+
...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
300
|
+
...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
301
|
+
...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
302
|
+
...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
303
|
+
];
|
|
304
|
+
const queueSequenceLines = summary.recommended_sequence?.length > 0
|
|
305
|
+
? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
|
|
306
|
+
: [chalk.green('No recommended sequence beyond the current landing focus.')];
|
|
307
|
+
|
|
308
|
+
console.log('');
|
|
309
|
+
for (const block of [
|
|
310
|
+
renderPanel('Landing focus', queueFocusLines, chalk.green),
|
|
311
|
+
renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
|
|
312
|
+
renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
|
|
313
|
+
renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
|
|
314
|
+
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
315
|
+
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
316
|
+
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
317
|
+
]) {
|
|
318
|
+
for (const line of block) console.log(line);
|
|
319
|
+
console.log('');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (recentEvents.length > 0) {
|
|
323
|
+
console.log(chalk.bold('Recent Queue Events:'));
|
|
324
|
+
for (const event of recentEvents) {
|
|
325
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
queueCmd
|
|
331
|
+
.command('retry <itemId>')
|
|
332
|
+
.description('Retry a blocked merge queue item')
|
|
333
|
+
.option('--json', 'Output raw JSON')
|
|
334
|
+
.action((itemId, opts) => {
|
|
335
|
+
const repoRoot = getRepo();
|
|
336
|
+
const db = getDb(repoRoot);
|
|
337
|
+
const item = retryMergeQueueItem(db, itemId);
|
|
338
|
+
db.close();
|
|
339
|
+
|
|
340
|
+
if (!item) {
|
|
341
|
+
printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (opts.json) {
|
|
347
|
+
console.log(JSON.stringify(item, null, 2));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
queueCmd
|
|
355
|
+
.command('escalate <itemId>')
|
|
356
|
+
.description('Mark a queue item as needing explicit operator review before landing')
|
|
357
|
+
.option('--reason <text>', 'Why this item is being escalated')
|
|
358
|
+
.option('--json', 'Output raw JSON')
|
|
359
|
+
.action((itemId, opts) => {
|
|
360
|
+
const repoRoot = getRepo();
|
|
361
|
+
const db = getDb(repoRoot);
|
|
362
|
+
const item = escalateMergeQueueItem(db, itemId, {
|
|
363
|
+
summary: opts.reason || null,
|
|
364
|
+
nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
|
|
365
|
+
});
|
|
366
|
+
db.close();
|
|
367
|
+
|
|
368
|
+
if (!item) {
|
|
369
|
+
printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (opts.json) {
|
|
375
|
+
console.log(JSON.stringify(item, null, 2));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
|
|
380
|
+
if (item.last_error_summary) {
|
|
381
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
382
|
+
}
|
|
383
|
+
if (item.next_action) {
|
|
384
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
queueCmd
|
|
389
|
+
.command('remove <itemId>')
|
|
390
|
+
.description('Remove a merge queue item')
|
|
391
|
+
.action((itemId) => {
|
|
392
|
+
const repoRoot = getRepo();
|
|
393
|
+
const db = getDb(repoRoot);
|
|
394
|
+
const item = removeMergeQueueItem(db, itemId);
|
|
395
|
+
db.close();
|
|
396
|
+
|
|
397
|
+
if (!item) {
|
|
398
|
+
printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
queueCmd
|
|
407
|
+
.command('run')
|
|
408
|
+
.description('Process landing-queue items one at a time')
|
|
409
|
+
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
410
|
+
.option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
|
|
411
|
+
.option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
|
|
412
|
+
.option('--target <branch>', 'Default target branch', 'main')
|
|
413
|
+
.option('--watch', 'Keep polling for new queue items')
|
|
414
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
415
|
+
.option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
|
|
416
|
+
.option('--json', 'Output raw JSON')
|
|
417
|
+
.addHelpText('after', `
|
|
418
|
+
Examples:
|
|
419
|
+
switchman queue run
|
|
420
|
+
switchman queue run --follow-plan --merge-budget 2
|
|
421
|
+
switchman queue run --watch
|
|
422
|
+
switchman queue run --watch --watch-interval-ms 1000
|
|
423
|
+
`)
|
|
424
|
+
.action(async (opts) => {
|
|
425
|
+
const repoRoot = getRepo();
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const watch = Boolean(opts.watch);
|
|
429
|
+
const followPlan = Boolean(opts.followPlan);
|
|
430
|
+
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
431
|
+
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
432
|
+
const mergeBudget = opts.mergeBudget !== undefined
|
|
433
|
+
? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
|
|
434
|
+
: null;
|
|
435
|
+
const aggregate = {
|
|
436
|
+
processed: [],
|
|
437
|
+
cycles: 0,
|
|
438
|
+
watch,
|
|
439
|
+
execution_policy: {
|
|
440
|
+
follow_plan: followPlan,
|
|
441
|
+
merge_budget: mergeBudget,
|
|
442
|
+
merged_count: 0,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
while (true) {
|
|
447
|
+
const db = getDb(repoRoot);
|
|
448
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
449
|
+
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
450
|
+
targetBranch: opts.target || 'main',
|
|
451
|
+
followPlan,
|
|
452
|
+
mergeBudget,
|
|
453
|
+
});
|
|
454
|
+
db.close();
|
|
455
|
+
|
|
456
|
+
aggregate.processed.push(...result.processed);
|
|
457
|
+
aggregate.summary = result.summary;
|
|
458
|
+
aggregate.deferred = result.deferred || aggregate.deferred || null;
|
|
459
|
+
aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
|
|
460
|
+
aggregate.cycles += 1;
|
|
461
|
+
|
|
462
|
+
if (!watch) break;
|
|
463
|
+
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
464
|
+
if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
|
|
465
|
+
if (result.processed.length === 0) {
|
|
466
|
+
sleepSync(watchIntervalMs);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (opts.json) {
|
|
471
|
+
console.log(JSON.stringify(aggregate, null, 2));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (aggregate.processed.length === 0) {
|
|
476
|
+
const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
|
|
477
|
+
if (deferredFocus?.recommendation?.action) {
|
|
478
|
+
console.log(chalk.yellow('No landing candidate is ready to run right now.'));
|
|
479
|
+
console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
|
|
480
|
+
if (followPlan) {
|
|
481
|
+
console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
|
|
482
|
+
}
|
|
483
|
+
if (deferredFocus.recommendation?.summary) {
|
|
484
|
+
console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
|
|
485
|
+
}
|
|
486
|
+
if (deferredFocus.recommendation?.command) {
|
|
487
|
+
console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
console.log(chalk.dim('No queued merge items.'));
|
|
491
|
+
}
|
|
492
|
+
await maybeCaptureTelemetry('queue_used', {
|
|
493
|
+
watch,
|
|
494
|
+
cycles: aggregate.cycles,
|
|
495
|
+
processed_count: 0,
|
|
496
|
+
merged_count: 0,
|
|
497
|
+
blocked_count: 0,
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const entry of aggregate.processed) {
|
|
503
|
+
const item = entry.item;
|
|
504
|
+
if (entry.status === 'merged') {
|
|
505
|
+
pushSyncEvent('queue_merged', {
|
|
506
|
+
item_id: item.id,
|
|
507
|
+
source_type: item.source_type,
|
|
508
|
+
source_ref: item.source_ref,
|
|
509
|
+
source_worktree: item.source_worktree || null,
|
|
510
|
+
target_branch: item.target_branch,
|
|
511
|
+
merged_commit: item.merged_commit || null,
|
|
512
|
+
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
513
|
+
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
514
|
+
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
515
|
+
} else {
|
|
516
|
+
pushSyncEvent('queue_blocked', {
|
|
517
|
+
item_id: item.id,
|
|
518
|
+
source_type: item.source_type,
|
|
519
|
+
source_ref: item.source_ref,
|
|
520
|
+
source_worktree: item.source_worktree || null,
|
|
521
|
+
target_branch: item.target_branch,
|
|
522
|
+
error_code: item.last_error_code || null,
|
|
523
|
+
error_summary: item.last_error_summary || null,
|
|
524
|
+
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
525
|
+
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
526
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
527
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (aggregate.execution_policy.follow_plan) {
|
|
532
|
+
console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await maybeCaptureTelemetry('queue_used', {
|
|
536
|
+
watch,
|
|
537
|
+
cycles: aggregate.cycles,
|
|
538
|
+
processed_count: aggregate.processed.length,
|
|
539
|
+
merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
|
|
540
|
+
blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
|
|
541
|
+
});
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error(chalk.red(err.message));
|
|
544
|
+
process.exitCode = 1;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return queueCmd;
|
|
549
|
+
}
|