@minionry/minion 0.7.36 → 0.7.39
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/bin/commands/minions.js +19 -0
- package/bin/minion.js +33 -4
- package/dist/server/cli/headless/claude-invoker-process.js +23 -7
- package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
- package/dist/server/cli/headless/haiku-assessments.js +2 -1
- package/dist/server/cli/headless/mcp-config.js +2 -1
- package/dist/server/engines/claude/claude-command.js +1 -0
- package/dist/server/engines/factory.js +2 -0
- package/dist/server/engines/hermes/HermesEngine.js +112 -219
- package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
- package/dist/server/index.js +10 -4
- package/dist/server/mcp/bouncer-integration.js +3 -2
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
- package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
- package/dist/server/mcp/classifier/telemetry.js +0 -0
- package/dist/server/mcp/permission-channels.js +176 -0
- package/dist/server/mcp/security-analysis.js +9 -1
- package/dist/server/mcp/security-audit.js +22 -2
- package/dist/server/mcp/security-patterns.js +73 -1
- package/dist/server/mcp/server.js +80 -50
- package/dist/server/routes/internal.js +6 -1
- package/dist/server/routes/notifications.js +6 -18
- package/dist/server/server-setup.js +24 -48
- package/dist/server/services/analytics.js +4 -2
- package/dist/server/services/browser/host-mock.js +6 -1
- package/dist/server/services/browser/host.js +27 -3
- package/dist/server/services/browser/reasoning-agent.js +3 -1
- package/dist/server/services/chain/chain-engine.js +494 -0
- package/dist/server/services/chain/chain-store.js +542 -0
- package/dist/server/services/git/haiku.js +2 -2
- package/dist/server/services/plan/agents/check-injection.md +43 -2
- package/dist/server/services/plan/agents/review-code.md +62 -3
- package/dist/server/services/plan/agents/review-quality.md +52 -2
- package/dist/server/services/plan/board-export.js +4 -0
- package/dist/server/services/plan/composer-prompt.js +27 -8
- package/dist/server/services/plan/composer.js +10 -4
- package/dist/server/services/plan/config-installer.js +4 -83
- package/dist/server/services/plan/executor.js +155 -73
- package/dist/server/services/plan/issue-effort.js +9 -21
- package/dist/server/services/plan/issue-prompt-builder.js +72 -9
- package/dist/server/services/plan/parser-core.js +6 -1
- package/dist/server/services/plan/quality-delta.js +6 -24
- package/dist/server/services/plan/readiness-planner.js +12 -4
- package/dist/server/services/plan/record-file.js +52 -0
- package/dist/server/services/plan/review-approval.js +64 -0
- package/dist/server/services/plan/review-gate.js +186 -77
- package/dist/server/services/plan/review-outcome.js +146 -0
- package/dist/server/services/plan/review-report.js +28 -1
- package/dist/server/services/plan/review-target.js +57 -0
- package/dist/server/services/plan/state-reconciler.js +3 -3
- package/dist/server/services/plan/template-diff.js +2 -0
- package/dist/server/services/plan/template-instantiator.js +4 -0
- package/dist/server/services/plan/watcher.js +11 -1
- package/dist/server/services/platform-reconnect.js +7 -0
- package/dist/server/services/platform-token-lifecycle.js +2 -1
- package/dist/server/services/platform.js +293 -50
- package/dist/server/services/relay-http-client.js +107 -0
- package/dist/server/services/schedule/agent-schedule-store.js +69 -8
- package/dist/server/services/schedule/agent-scheduler.js +48 -7
- package/dist/server/services/schedule/daily-timing.js +52 -0
- package/dist/server/services/schedule/prompt-file.js +55 -0
- package/dist/server/services/schedule/run-bootstrap.js +20 -5
- package/dist/server/services/schedule/schedule-store.js +54 -4
- package/dist/server/services/schedule/schedule-wire.js +17 -0
- package/dist/server/services/schedule/scheduler.js +43 -9
- package/dist/server/services/sdk/agent-schedule.js +294 -0
- package/dist/server/services/sdk/agents.js +95 -13
- package/dist/server/services/sdk/app-tools.js +44 -7
- package/dist/server/services/sdk/atomic-write.js +75 -0
- package/dist/server/services/sdk/browser.js +5 -1
- package/dist/server/services/sdk/composer.js +2 -0
- package/dist/server/services/sdk/file-change-notify.js +60 -13
- package/dist/server/services/sdk/files-app.js +12 -7
- package/dist/server/services/sdk/files-search.js +14 -0
- package/dist/server/services/sdk/files-transfer.js +1 -1
- package/dist/server/services/sdk/files-watch.js +721 -0
- package/dist/server/services/sdk/files-workspace.js +37 -11
- package/dist/server/services/sdk/inference-claude.js +4 -2
- package/dist/server/services/sdk/inference.js +16 -2
- package/dist/server/services/sdk/pm-chain.js +0 -0
- package/dist/server/services/sdk/pm-schedule.js +72 -15
- package/dist/server/services/sdk/pm.js +22 -0
- package/dist/server/services/sdk/rate-limits.js +32 -0
- package/dist/server/services/sdk/registry.js +40 -2
- package/dist/server/services/sdk/terminal.js +1 -1
- package/dist/server/services/sentry.js +5 -1
- package/dist/server/services/terminal/pty-manager.js +26 -11
- package/dist/server/services/terminal/pty-utils.js +12 -1
- package/dist/server/services/terminal/terminal-modes.js +87 -0
- package/dist/server/services/timeline/assembler.js +2 -2
- package/dist/server/services/timeline/index.js +1 -1
- package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
- package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
- package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
- package/dist/server/services/websocket/browser-handlers.js +5 -2
- package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
- package/dist/server/services/websocket/file-transfer-http.js +12 -6
- package/dist/server/services/websocket/handler.js +185 -33
- package/dist/server/services/websocket/msg-id-tracker.js +94 -19
- package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
- package/dist/server/services/websocket/plan-handlers.js +10 -5
- package/dist/server/services/websocket/plan-helpers.js +23 -0
- package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
- package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
- package/dist/server/services/websocket/schedule-handlers.js +8 -2
- package/dist/server/services/websocket/sdk-agent-host.js +5 -1
- package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
- package/dist/server/services/websocket/sdk-browser-host.js +11 -4
- package/dist/server/services/websocket/sdk-handlers.js +6 -1
- package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
- package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
- package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
- package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
- package/dist/server/services/websocket/settings-handlers.js +6 -18
- package/dist/server/services/websocket/tab-broadcast.js +5 -1
- package/dist/server/services/websocket/terminal-handlers.js +51 -6
- package/dist/server/services/websocket/types.js +6 -5
- package/dist/server/services/websocket/viewer-interest.js +30 -0
- package/package.json +6 -6
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Minionry, Inc.
|
|
2
|
+
// SPDX-License-Identifier: LicenseRef-Minionry-Software
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
6
|
+
import { getClientId } from '../client-id.js';
|
|
7
|
+
import { resolveActiveBoardId } from '../plan/board-config.js';
|
|
8
|
+
import { isBoardPlanning } from '../plan/composer.js';
|
|
9
|
+
import { parseBoardDirectory, resolvePmDir } from '../plan/parser.js';
|
|
10
|
+
import { writeRunInputFile } from '../schedule/run-bootstrap.js';
|
|
11
|
+
import { logAppAudit } from '../sdk/audit.js';
|
|
12
|
+
import { getGrant } from '../sdk/grant-store.js';
|
|
13
|
+
import { makeSpaceKey } from '../settings.js';
|
|
14
|
+
import { broadcastBoardRunPrompt } from '../websocket/plan-execution-handlers.js';
|
|
15
|
+
import { chainEngineCache, executorCache, schedulerCache } from '../websocket/plan-helpers.js';
|
|
16
|
+
import { startFreshBoardRun } from '../websocket/sdk-pm-execution-host.js';
|
|
17
|
+
import { ChainStore, MAX_CHAIN_DEPTH, } from './chain-store.js';
|
|
18
|
+
export const CHAIN_START_MAX_ATTEMPTS = 3;
|
|
19
|
+
export const CHAIN_START_BACKOFF_MS = 30_000;
|
|
20
|
+
export const CHAIN_DRAIN_TICK_MS = 30_000;
|
|
21
|
+
export const CHAIN_START_GRACE_MS = 15_000;
|
|
22
|
+
export const CHAIN_ERRORS = {
|
|
23
|
+
missing: 'target board no longer exists',
|
|
24
|
+
notRunnable: 'target board is not runnable',
|
|
25
|
+
paused: 'target board is paused',
|
|
26
|
+
noIssues: 'target board has no issues',
|
|
27
|
+
startFailed: 'the downstream run could not be started',
|
|
28
|
+
};
|
|
29
|
+
function nonEpicIssues(board) {
|
|
30
|
+
return board.issues.filter((i) => i.type !== 'epic');
|
|
31
|
+
}
|
|
32
|
+
export function classifySettle(board, signal) {
|
|
33
|
+
if (!board)
|
|
34
|
+
return null;
|
|
35
|
+
const issues = nonEpicIssues(board);
|
|
36
|
+
if (issues.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
if (board.state.paused)
|
|
39
|
+
return null;
|
|
40
|
+
if (issues.some((i) => i.status === 'awaiting_approval'))
|
|
41
|
+
return null;
|
|
42
|
+
if (issues.every((i) => i.status === 'done'))
|
|
43
|
+
return 'success';
|
|
44
|
+
if (issues.every((i) => i.status === 'done' || i.status === 'cancelled'))
|
|
45
|
+
return 'failure';
|
|
46
|
+
return signal === 'error' ? 'failure' : null;
|
|
47
|
+
}
|
|
48
|
+
export function computeSettleKey(outcome, boardId, issues) {
|
|
49
|
+
const lines = issues.map((i) => `${i.id}=${i.status}`).sort();
|
|
50
|
+
const digest = createHash('sha256').update(`${boardId}\n${lines.join('\n')}`).digest('hex');
|
|
51
|
+
return `${outcome}:${digest.slice(0, 16)}`;
|
|
52
|
+
}
|
|
53
|
+
export function conditionMatches(on, outcome) {
|
|
54
|
+
return on === 'settled' || on === outcome;
|
|
55
|
+
}
|
|
56
|
+
const UPSTREAM_MAX_FILE_BYTES = 256 * 1024;
|
|
57
|
+
const UPSTREAM_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
58
|
+
const UPSTREAM_MAX_ENTRIES = 50;
|
|
59
|
+
function classifyUpstreamEntry(outDir, realOutDir, boardId, name, acceptedCount, totalBytes) {
|
|
60
|
+
let real;
|
|
61
|
+
try {
|
|
62
|
+
real = realpathSync(join(outDir, name));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { kind: 'silent' };
|
|
66
|
+
}
|
|
67
|
+
const rel = relative(realOutDir, real);
|
|
68
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
69
|
+
return { kind: 'silent' };
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = statSync(real);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { kind: 'silent' };
|
|
76
|
+
}
|
|
77
|
+
if (!stat.isFile())
|
|
78
|
+
return { kind: 'omitted', entry: { name, reason: 'not_a_regular_file' } };
|
|
79
|
+
if (acceptedCount >= UPSTREAM_MAX_ENTRIES)
|
|
80
|
+
return { kind: 'omitted', entry: { name, reason: 'over_count' } };
|
|
81
|
+
if (stat.size > UPSTREAM_MAX_FILE_BYTES)
|
|
82
|
+
return { kind: 'omitted', entry: { name, reason: 'too_large' } };
|
|
83
|
+
if (totalBytes + stat.size > UPSTREAM_MAX_TOTAL_BYTES)
|
|
84
|
+
return { kind: 'omitted', entry: { name, reason: 'over_total' } };
|
|
85
|
+
return { kind: 'output', entry: { name, path: `boards/${boardId}/out/${name}`, bytes: stat.size } };
|
|
86
|
+
}
|
|
87
|
+
function buildUpstreamOutputs(pmDir, boardId) {
|
|
88
|
+
const outDir = join(pmDir, 'boards', boardId, 'out');
|
|
89
|
+
const outputs = [];
|
|
90
|
+
const omitted = [];
|
|
91
|
+
if (!existsSync(outDir))
|
|
92
|
+
return { outputs, omitted };
|
|
93
|
+
let realOutDir;
|
|
94
|
+
let names;
|
|
95
|
+
try {
|
|
96
|
+
realOutDir = realpathSync(outDir);
|
|
97
|
+
names = readdirSync(outDir);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { outputs, omitted };
|
|
101
|
+
}
|
|
102
|
+
let totalBytes = 0;
|
|
103
|
+
for (const name of names.filter((n) => n.endsWith('.md')).sort()) {
|
|
104
|
+
const verdict = classifyUpstreamEntry(outDir, realOutDir, boardId, name, outputs.length, totalBytes);
|
|
105
|
+
if (verdict.kind === 'silent')
|
|
106
|
+
continue;
|
|
107
|
+
if (verdict.kind === 'omitted') {
|
|
108
|
+
omitted.push(verdict.entry);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
totalBytes += verdict.entry.bytes;
|
|
112
|
+
outputs.push(verdict.entry);
|
|
113
|
+
}
|
|
114
|
+
return { outputs, omitted };
|
|
115
|
+
}
|
|
116
|
+
export function buildChainLinkWires(store) {
|
|
117
|
+
return store.list().map((link) => ({ ...link, history: store.getFireHistory(link.id) }));
|
|
118
|
+
}
|
|
119
|
+
export function broadcastChainsChanged(ctx, store) {
|
|
120
|
+
ctx.broadcastToAll({ type: 'planChainsChanged', data: { links: buildChainLinkWires(store) } });
|
|
121
|
+
}
|
|
122
|
+
export function broadcastChainFired(ctx, fire) {
|
|
123
|
+
ctx.broadcastToAll({
|
|
124
|
+
type: 'planChainFired',
|
|
125
|
+
data: {
|
|
126
|
+
linkId: fire.linkId,
|
|
127
|
+
fireId: fire.id,
|
|
128
|
+
from: fire.from,
|
|
129
|
+
to: fire.to,
|
|
130
|
+
status: fire.status,
|
|
131
|
+
upstreamOutcome: fire.upstreamOutcome,
|
|
132
|
+
...(fire.skipReason ? { skipReason: fire.skipReason } : {}),
|
|
133
|
+
...(fire.error ? { error: fire.error } : {}),
|
|
134
|
+
...(fire.downstreamRunId ? { downstreamRunId: fire.downstreamRunId } : {}),
|
|
135
|
+
lineage: fire.lineage,
|
|
136
|
+
at: fire.claimedAt,
|
|
137
|
+
...(fire.completedAt ? { completedAt: fire.completedAt } : {}),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export class ChainEngine {
|
|
142
|
+
workingDir;
|
|
143
|
+
ctx;
|
|
144
|
+
deps;
|
|
145
|
+
drainTickMs;
|
|
146
|
+
startGraceMs;
|
|
147
|
+
timer = null;
|
|
148
|
+
graceTimer = null;
|
|
149
|
+
store = null;
|
|
150
|
+
drainNotBefore = 0;
|
|
151
|
+
needsReload = false;
|
|
152
|
+
draining = false;
|
|
153
|
+
constructor(workingDir, ctx, deps = {}) {
|
|
154
|
+
this.workingDir = workingDir;
|
|
155
|
+
this.ctx = ctx;
|
|
156
|
+
this.deps = deps;
|
|
157
|
+
this.drainTickMs = deps.drainTickMs ?? CHAIN_DRAIN_TICK_MS;
|
|
158
|
+
this.startGraceMs = deps.startGraceMs ?? CHAIN_START_GRACE_MS;
|
|
159
|
+
}
|
|
160
|
+
getStore() {
|
|
161
|
+
return this.store;
|
|
162
|
+
}
|
|
163
|
+
isRunning() {
|
|
164
|
+
return this.timer !== null;
|
|
165
|
+
}
|
|
166
|
+
start() {
|
|
167
|
+
if (this.timer)
|
|
168
|
+
return;
|
|
169
|
+
if (!resolvePmDir(this.workingDir))
|
|
170
|
+
return;
|
|
171
|
+
this.store = new ChainStore(this.workingDir);
|
|
172
|
+
this.drainNotBefore = this.nowMs() + this.startGraceMs;
|
|
173
|
+
this.needsReload = true;
|
|
174
|
+
this.graceTimer = setTimeout(() => this.drain(), this.startGraceMs);
|
|
175
|
+
if (typeof this.graceTimer.unref === 'function')
|
|
176
|
+
this.graceTimer.unref();
|
|
177
|
+
this.timer = setInterval(() => this.drain(), this.drainTickMs);
|
|
178
|
+
if (typeof this.timer.unref === 'function')
|
|
179
|
+
this.timer.unref();
|
|
180
|
+
}
|
|
181
|
+
stop() {
|
|
182
|
+
if (this.timer) {
|
|
183
|
+
clearInterval(this.timer);
|
|
184
|
+
this.timer = null;
|
|
185
|
+
}
|
|
186
|
+
if (this.graceTimer) {
|
|
187
|
+
clearTimeout(this.graceTimer);
|
|
188
|
+
this.graceTimer = null;
|
|
189
|
+
}
|
|
190
|
+
this.store = null;
|
|
191
|
+
}
|
|
192
|
+
notifyRunEnded(boardId, signal) {
|
|
193
|
+
try {
|
|
194
|
+
const pmDir = resolvePmDir(this.workingDir);
|
|
195
|
+
if (!pmDir)
|
|
196
|
+
return;
|
|
197
|
+
const settledBoardId = boardId ?? resolveActiveBoardId(pmDir);
|
|
198
|
+
if (!settledBoardId)
|
|
199
|
+
return;
|
|
200
|
+
if (!this.store)
|
|
201
|
+
this.start();
|
|
202
|
+
const store = this.store;
|
|
203
|
+
if (!store)
|
|
204
|
+
return;
|
|
205
|
+
const board = parseBoardDirectory(pmDir, settledBoardId);
|
|
206
|
+
const outcome = classifySettle(board, signal);
|
|
207
|
+
if (!outcome || !board)
|
|
208
|
+
return;
|
|
209
|
+
this.answerSettle(store, settledBoardId, board, outcome);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.error('[ChainEngine] notifyRunEnded failed:', error);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
this.scheduleDrain();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
answerSettle(store, boardId, board, outcome) {
|
|
219
|
+
const settleKey = computeSettleKey(outcome, boardId, nonEpicIssues(board));
|
|
220
|
+
const source = store.findLineageSource(boardId);
|
|
221
|
+
const upstreamLineage = source?.lineage.length ? source.lineage : [boardId];
|
|
222
|
+
const candidates = store.list().filter((l) => l.from === boardId && l.enabled && l.state === 'armed');
|
|
223
|
+
let wrote = false;
|
|
224
|
+
for (const link of candidates) {
|
|
225
|
+
const record = this.answerSettleForLink(store, link, settleKey, outcome, upstreamLineage);
|
|
226
|
+
if (!record)
|
|
227
|
+
continue;
|
|
228
|
+
wrote = true;
|
|
229
|
+
broadcastChainFired(this.ctx, record);
|
|
230
|
+
this.auditFire(link, record);
|
|
231
|
+
}
|
|
232
|
+
if (source)
|
|
233
|
+
store.markLineageConsumed(source.id);
|
|
234
|
+
if (wrote)
|
|
235
|
+
broadcastChainsChanged(this.ctx, store);
|
|
236
|
+
}
|
|
237
|
+
answerSettleForLink(store, link, settleKey, outcome, upstreamLineage) {
|
|
238
|
+
const context = { upstreamOutcome: outcome, lineage: [...upstreamLineage, link.to] };
|
|
239
|
+
if (!conditionMatches(link.on, outcome)) {
|
|
240
|
+
return store.recordSkip(link.id, settleKey, 'condition_not_met', context);
|
|
241
|
+
}
|
|
242
|
+
const guard = lineageRefusal(link.to, upstreamLineage);
|
|
243
|
+
if (guard)
|
|
244
|
+
return store.recordSkip(link.id, settleKey, guard, context);
|
|
245
|
+
return store.claimFire(link.id, settleKey, context);
|
|
246
|
+
}
|
|
247
|
+
notifyExecutorStatus(status) {
|
|
248
|
+
if (status === 'starting' || status === 'executing')
|
|
249
|
+
return;
|
|
250
|
+
this.scheduleDrain();
|
|
251
|
+
}
|
|
252
|
+
scheduleDrain() {
|
|
253
|
+
const deferred = setTimeout(() => this.drain(), 0);
|
|
254
|
+
if (typeof deferred.unref === 'function')
|
|
255
|
+
deferred.unref();
|
|
256
|
+
}
|
|
257
|
+
drain() {
|
|
258
|
+
if (this.draining)
|
|
259
|
+
return;
|
|
260
|
+
this.draining = true;
|
|
261
|
+
try {
|
|
262
|
+
this.drainOnce();
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
console.error('[ChainEngine] drain failed:', error);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
this.draining = false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
drainOnce() {
|
|
272
|
+
const store = this.store;
|
|
273
|
+
if (!store)
|
|
274
|
+
return;
|
|
275
|
+
const now = this.nowMs();
|
|
276
|
+
if (now < this.drainNotBefore)
|
|
277
|
+
return;
|
|
278
|
+
if (this.needsReload) {
|
|
279
|
+
store.reload();
|
|
280
|
+
this.needsReload = false;
|
|
281
|
+
}
|
|
282
|
+
if (this.isSpaceBusy())
|
|
283
|
+
return;
|
|
284
|
+
for (const pending of store.listPendingFires()) {
|
|
285
|
+
const fire = store.getFire(pending.id);
|
|
286
|
+
if (!fire || fire.status !== 'pending')
|
|
287
|
+
continue;
|
|
288
|
+
if (fire.nextAttemptAt && Date.parse(fire.nextAttemptAt) > now)
|
|
289
|
+
continue;
|
|
290
|
+
if (this.isTargetHeld(fire.to))
|
|
291
|
+
continue;
|
|
292
|
+
if (this.attemptFire(store, fire))
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
attemptFire(store, fire) {
|
|
297
|
+
const link = store.get(fire.linkId);
|
|
298
|
+
if (!link)
|
|
299
|
+
return this.terminate(store, fire, { status: 'skipped', skipReason: 'link_deleted' });
|
|
300
|
+
if (!link.enabled || link.state !== 'armed') {
|
|
301
|
+
return this.terminate(store, fire, { status: 'skipped', skipReason: 'link_disabled' });
|
|
302
|
+
}
|
|
303
|
+
if (!this.hasGrant(link.createdBy)) {
|
|
304
|
+
return this.terminate(store, fire, { status: 'skipped', skipReason: 'grant_revoked' });
|
|
305
|
+
}
|
|
306
|
+
const guard = lineageRefusal(fire.to, fire.lineage.slice(0, -1));
|
|
307
|
+
if (guard)
|
|
308
|
+
return this.terminate(store, fire, { status: 'skipped', skipReason: guard });
|
|
309
|
+
if (store.isOnArmedCycle(fire.linkId)) {
|
|
310
|
+
return this.terminate(store, fire, { status: 'skipped', skipReason: 'cycle' });
|
|
311
|
+
}
|
|
312
|
+
const precheck = this.precheckTarget(fire.to);
|
|
313
|
+
if (precheck)
|
|
314
|
+
return this.terminate(store, fire, { status: 'failed', error: precheck });
|
|
315
|
+
if (link.passOutputs)
|
|
316
|
+
this.writeUpstreamManifest(link, fire);
|
|
317
|
+
const launchPrompt = `Chain ${link.id}: run board ${fire.to} — board ${fire.from} settled (${fire.upstreamOutcome})`;
|
|
318
|
+
let runId;
|
|
319
|
+
try {
|
|
320
|
+
const start = this.deps.startRun ?? startFreshBoardRun;
|
|
321
|
+
runId = start(this.ctx, this.workingDir, fire.to, { launchPrompt }).runId;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
console.error(`[ChainEngine] start of board ${fire.to} threw:`, error);
|
|
325
|
+
this.recordTransientFailure(store, fire);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
const started = store.completeFire(fire.id, { status: 'started', downstreamRunId: runId });
|
|
329
|
+
if (started) {
|
|
330
|
+
broadcastChainFired(this.ctx, started);
|
|
331
|
+
this.auditFire(link, started);
|
|
332
|
+
}
|
|
333
|
+
broadcastChainsChanged(this.ctx, store);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
terminate(store, fire, outcome) {
|
|
337
|
+
const record = store.completeFire(fire.id, outcome);
|
|
338
|
+
if (!record)
|
|
339
|
+
return false;
|
|
340
|
+
broadcastChainFired(this.ctx, record);
|
|
341
|
+
this.auditFire(store.get(record.linkId), record);
|
|
342
|
+
broadcastChainsChanged(this.ctx, store);
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
recordTransientFailure(store, fire) {
|
|
346
|
+
const attempts = fire.attempts + 1;
|
|
347
|
+
if (attempts >= CHAIN_START_MAX_ATTEMPTS) {
|
|
348
|
+
this.terminate(store, fire, { status: 'failed', error: CHAIN_ERRORS.startFailed });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const backoff = CHAIN_START_BACKOFF_MS * 2 ** (attempts - 1);
|
|
352
|
+
const updated = store.recordStartAttempt(fire.id, new Date(this.nowMs() + backoff).toISOString());
|
|
353
|
+
if (!updated)
|
|
354
|
+
return;
|
|
355
|
+
broadcastChainFired(this.ctx, updated);
|
|
356
|
+
broadcastChainsChanged(this.ctx, store);
|
|
357
|
+
}
|
|
358
|
+
isSpaceBusy() {
|
|
359
|
+
if (this.deps.spaceBusy)
|
|
360
|
+
return this.deps.spaceBusy(this.workingDir);
|
|
361
|
+
const executor = executorCache.get(this.workingDir);
|
|
362
|
+
if (!executor)
|
|
363
|
+
return false;
|
|
364
|
+
return executor.isRunning() || executor.getStatus() === 'paused';
|
|
365
|
+
}
|
|
366
|
+
isTargetHeld(boardId) {
|
|
367
|
+
if (this.deps.targetHeld)
|
|
368
|
+
return this.deps.targetHeld(this.workingDir, boardId);
|
|
369
|
+
if (isBoardPlanning(this.workingDir, boardId))
|
|
370
|
+
return true;
|
|
371
|
+
const scheduler = schedulerCache.get(this.workingDir);
|
|
372
|
+
return scheduler?.runs.snapshotValues().some((run) => run.boardId === boardId) ?? false;
|
|
373
|
+
}
|
|
374
|
+
hasGrant(createdBy) {
|
|
375
|
+
if (this.deps.hasChainGrant)
|
|
376
|
+
return this.deps.hasChainGrant(createdBy, this.workingDir);
|
|
377
|
+
try {
|
|
378
|
+
const spaceKey = makeSpaceKey(getClientId(), this.workingDir);
|
|
379
|
+
return getGrant(createdBy, spaceKey)?.scopes.includes('pm:chain') === true;
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
console.error('[ChainEngine] grant re-check failed:', error);
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
precheckTarget(boardId) {
|
|
387
|
+
const pmDir = resolvePmDir(this.workingDir);
|
|
388
|
+
if (!pmDir)
|
|
389
|
+
return CHAIN_ERRORS.missing;
|
|
390
|
+
const boardsDir = join(pmDir, 'boards');
|
|
391
|
+
let entries = [];
|
|
392
|
+
try {
|
|
393
|
+
entries = existsSync(boardsDir) ? readdirSync(boardsDir) : [];
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
entries = [];
|
|
397
|
+
}
|
|
398
|
+
if (!entries.includes(boardId))
|
|
399
|
+
return CHAIN_ERRORS.missing;
|
|
400
|
+
const board = parseBoardDirectory(pmDir, boardId);
|
|
401
|
+
if (!board)
|
|
402
|
+
return CHAIN_ERRORS.missing;
|
|
403
|
+
if (board.board.status === 'completed' || board.board.status === 'archived')
|
|
404
|
+
return CHAIN_ERRORS.notRunnable;
|
|
405
|
+
if (board.state.paused)
|
|
406
|
+
return CHAIN_ERRORS.paused;
|
|
407
|
+
if (nonEpicIssues(board).length === 0 && !isBoardPlanning(this.workingDir, boardId)) {
|
|
408
|
+
return CHAIN_ERRORS.noIssues;
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
writeUpstreamManifest(link, fire) {
|
|
413
|
+
const pmDir = resolvePmDir(this.workingDir);
|
|
414
|
+
if (!pmDir)
|
|
415
|
+
return;
|
|
416
|
+
const upstream = {
|
|
417
|
+
linkId: link.id,
|
|
418
|
+
boardId: fire.from,
|
|
419
|
+
outcome: fire.upstreamOutcome,
|
|
420
|
+
settledAt: fire.claimedAt,
|
|
421
|
+
lineage: fire.lineage,
|
|
422
|
+
...buildUpstreamOutputs(pmDir, fire.from),
|
|
423
|
+
};
|
|
424
|
+
try {
|
|
425
|
+
const written = writeRunInputFile(join(pmDir, 'boards', fire.to), { boardId: fire.to, upstream });
|
|
426
|
+
if (written === null) {
|
|
427
|
+
broadcastBoardRunPrompt(this.ctx, fire.to, `Chain ${link.id}: board ${fire.to}'s existing run-input.json could not be parsed — starting without the upstream-outputs manifest.`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
console.error(`[ChainEngine] failed to write upstream run-input for board ${fire.to}:`, error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
auditFire(link, fire) {
|
|
435
|
+
if (!link)
|
|
436
|
+
return;
|
|
437
|
+
if (fire.status === 'pending')
|
|
438
|
+
return;
|
|
439
|
+
if (fire.status === 'skipped' && fire.skipReason === 'condition_not_met')
|
|
440
|
+
return;
|
|
441
|
+
const method = fire.status === 'started'
|
|
442
|
+
? 'pm.chains.fire.started'
|
|
443
|
+
: fire.status === 'skipped'
|
|
444
|
+
? 'pm.chains.fire.skipped'
|
|
445
|
+
: 'pm.chains.fire.failed';
|
|
446
|
+
const reason = fire.status === 'started'
|
|
447
|
+
? `link ${link.id} started board ${fire.to} (run ${fire.downstreamRunId}) after ${fire.from} settled ${fire.upstreamOutcome}`
|
|
448
|
+
: fire.status === 'skipped'
|
|
449
|
+
? `link ${link.id} skipped: ${fire.skipReason}`
|
|
450
|
+
: `link ${link.id} failed: ${fire.error}`;
|
|
451
|
+
try {
|
|
452
|
+
logAppAudit({
|
|
453
|
+
appId: link.createdBy,
|
|
454
|
+
origin: link.createdByOrigin ?? '',
|
|
455
|
+
method,
|
|
456
|
+
decision: fire.status === 'started' ? 'allow' : 'deny',
|
|
457
|
+
requestId: `chain:${fire.id}`,
|
|
458
|
+
spaceKey: makeSpaceKey(getClientId(), this.workingDir),
|
|
459
|
+
reason,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
console.error('[ChainEngine] audit failed:', error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
nowMs() {
|
|
467
|
+
return this.deps.now ? this.deps.now() : Date.now();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function lineageRefusal(to, upstreamLineage) {
|
|
471
|
+
if (upstreamLineage.includes(to))
|
|
472
|
+
return 'cycle';
|
|
473
|
+
if (upstreamLineage.length + 1 > MAX_CHAIN_DEPTH)
|
|
474
|
+
return 'max_depth';
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
export function getChainEngine(workingDir, ctx, deps) {
|
|
478
|
+
let engine = chainEngineCache.get(workingDir);
|
|
479
|
+
if (!engine) {
|
|
480
|
+
engine = new ChainEngine(workingDir, ctx, deps);
|
|
481
|
+
chainEngineCache.set(workingDir, engine);
|
|
482
|
+
}
|
|
483
|
+
return engine;
|
|
484
|
+
}
|
|
485
|
+
export function stopAllChainEngines() {
|
|
486
|
+
for (const engine of chainEngineCache.values()) {
|
|
487
|
+
try {
|
|
488
|
+
engine.stop();
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
chainEngineCache.clear();
|
|
494
|
+
}
|