jules-orchestrator-kit 0.52.8 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -8
- package/bin/agentctl.mjs +327 -24
- package/bin/init.js +87 -74
- package/index.mjs +10 -0
- package/package.json +5 -2
- package/scripts/jules-create.mjs +5 -16
- package/scripts/jules-patch.mjs +18 -10
- package/scripts/stale-base-check.mjs +1 -1
- package/src/ci-templates.mjs +199 -0
- package/src/config-edit.mjs +138 -0
- package/src/config.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/git.mjs +79 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/cli-intent.mjs +29 -0
- package/src/ops/command-registry.mjs +19 -10
- package/src/ops/doctor-registry.mjs +75 -32
- package/src/ops/ide-scaffold.mjs +22 -16
- package/src/ops/next-step.mjs +38 -7
- package/src/ops/verify-output.mjs +38 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +284 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +88 -12
- package/src/wizard-task.mjs +22 -5
- package/src/ops/doctor-planner.mjs +0 -220
- package/src/ops/receipts.mjs +0 -171
- package/src/ops/swarm-actions.mjs +0 -156
- package/src/ops/task-actions.mjs +0 -214
- package/src/ops/transaction.mjs +0 -278
- package/src/ux/capabilities.mjs +0 -180
- package/src/ux/diff-viewer.mjs +0 -289
- package/src/ux/layout.mjs +0 -195
- package/src/ux/log-viewer.mjs +0 -110
- package/src/ux/palette.mjs +0 -179
- package/src/ux/queue-model.mjs +0 -206
- package/src/ux/renderer.mjs +0 -188
- package/src/ux/swarm-model.mjs +0 -146
- package/src/ux/terminal-session.mjs +0 -215
- package/src/ux/widgets.mjs +0 -323
- /package/src/{ux/key-decoder.mjs → key-decoder.mjs} +0 -0
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { detectStack } from "../config.mjs";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
|
|
8
|
-
* @typedef {import("./doctor-registry.mjs").DoctorReport} DoctorReport
|
|
9
|
-
*
|
|
10
|
-
* @typedef {Object} FixPlanningContext
|
|
11
|
-
* @property {string} root
|
|
12
|
-
* @property {DoctorReport} report
|
|
13
|
-
* @property {string[]} selectedFixIds
|
|
14
|
-
* @property {Record<string, unknown>} [answers]
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Generate unified diff preview string for file creation/replacement.
|
|
19
|
-
* @param {string} path
|
|
20
|
-
* @param {string} oldContent
|
|
21
|
-
* @param {string} newContent
|
|
22
|
-
* @returns {string}
|
|
23
|
-
*/
|
|
24
|
-
function createDiffPreview(path, oldContent, newContent) {
|
|
25
|
-
const oldLines = oldContent ? oldContent.split("\n") : [];
|
|
26
|
-
const newLines = newContent ? newContent.split("\n") : [];
|
|
27
|
-
const lines = [];
|
|
28
|
-
|
|
29
|
-
lines.push(`--- a/${path}`);
|
|
30
|
-
lines.push(`+++ b/${path}`);
|
|
31
|
-
lines.push(`@@ -1,${oldLines.length || 0} +1,${newLines.length || 0} @@`);
|
|
32
|
-
|
|
33
|
-
for (const line of oldLines) {
|
|
34
|
-
lines.push(`-${line}`);
|
|
35
|
-
}
|
|
36
|
-
for (const line of newLines) {
|
|
37
|
-
lines.push(`+${line}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return lines.join("\n");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function deepFreeze(value) {
|
|
44
|
-
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
45
|
-
for (const child of Object.values(value)) deepFreeze(child);
|
|
46
|
-
return Object.freeze(value);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function finalizePlan(plan) {
|
|
50
|
-
const { planHash: _oldHash, ...unsigned } = plan;
|
|
51
|
-
const planHash = "sha256:" + createHash("sha256").update(JSON.stringify(unsigned)).digest("hex");
|
|
52
|
-
return deepFreeze({ ...unsigned, planHash });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Plan diagnostic fixes into immutable ActionPlan objects.
|
|
57
|
-
* @param {FixPlanningContext} context
|
|
58
|
-
* @returns {Promise<ActionPlan[]>}
|
|
59
|
-
*/
|
|
60
|
-
export async function planDiagnosticFixes(context = {}) {
|
|
61
|
-
const root = resolve(context.root || process.cwd());
|
|
62
|
-
const report = context.report || { results: [] };
|
|
63
|
-
const selectedFixIds = Array.isArray(context.selectedFixIds) ? context.selectedFixIds : ["safe"];
|
|
64
|
-
|
|
65
|
-
const wantSafeOnly = selectedFixIds.includes("safe");
|
|
66
|
-
/** @type {ActionPlan[]} */
|
|
67
|
-
const plans = [];
|
|
68
|
-
|
|
69
|
-
for (const result of report.results || []) {
|
|
70
|
-
for (const fix of result.fixes || []) {
|
|
71
|
-
const isSelected = selectedFixIds.includes(fix.id) || (wantSafeOnly && fix.automatic);
|
|
72
|
-
if (!isSelected) continue;
|
|
73
|
-
|
|
74
|
-
if (fix.id === "config.create-default") {
|
|
75
|
-
const configPath = ".agent/config.yml";
|
|
76
|
-
// Detected, not assumed. Writing `npm test` into a Rust or Python
|
|
77
|
-
// repository's config produced a manifest whose first verification run
|
|
78
|
-
// could only fail, and the stack detector already has the answer.
|
|
79
|
-
const detected = detectStack(root);
|
|
80
|
-
const newContent = `# jules-orchestrator-kit configuration manifest
|
|
81
|
-
version: 1
|
|
82
|
-
# Free-tier limits until you state your plan (free | pro | ultra).
|
|
83
|
-
tier: free
|
|
84
|
-
verify:
|
|
85
|
-
test: ${JSON.stringify(detected.testCmd || "")}
|
|
86
|
-
build: ${JSON.stringify(detected.buildCmd || "")}
|
|
87
|
-
`;
|
|
88
|
-
const diff = createDiffPreview(configPath, "", newContent);
|
|
89
|
-
const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
90
|
-
|
|
91
|
-
plans.push({
|
|
92
|
-
schema: "agentctl/action-plan-v1",
|
|
93
|
-
id: planId,
|
|
94
|
-
kind: "config.create-default",
|
|
95
|
-
title: "Create default .agent/config.yml",
|
|
96
|
-
summary: "Generate initial configuration manifest for jules-orchestrator-kit",
|
|
97
|
-
risk: "low",
|
|
98
|
-
repository: root,
|
|
99
|
-
createdAt: new Date().toISOString(),
|
|
100
|
-
preconditions: [],
|
|
101
|
-
fileMutations: [
|
|
102
|
-
{
|
|
103
|
-
operation: "create",
|
|
104
|
-
path: configPath,
|
|
105
|
-
newContent,
|
|
106
|
-
},
|
|
107
|
-
],
|
|
108
|
-
commandEffects: [],
|
|
109
|
-
stateTransitions: [],
|
|
110
|
-
preview: {
|
|
111
|
-
unifiedDiff: diff,
|
|
112
|
-
warnings: [],
|
|
113
|
-
estimatedImpact: ["Creates .agent/config.yml"],
|
|
114
|
-
},
|
|
115
|
-
confirmation: {
|
|
116
|
-
mode: "none",
|
|
117
|
-
prompt: "Apply default configuration?",
|
|
118
|
-
},
|
|
119
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
120
|
-
});
|
|
121
|
-
} else if (fix.id === "state.rebuild-head") {
|
|
122
|
-
const dateStr = new Date().toISOString().split("T")[0];
|
|
123
|
-
const stateDir = join(root, ".agent", "state");
|
|
124
|
-
const headPath = `.agent/state/telemetry-${dateStr}.head`;
|
|
125
|
-
let tailHash = "0".repeat(64);
|
|
126
|
-
let segment = 0;
|
|
127
|
-
try {
|
|
128
|
-
const telemetryFiles = readdirSync(stateDir)
|
|
129
|
-
.filter((name) => name.startsWith(`telemetry-${dateStr}`) && name.endsWith(".jsonl"))
|
|
130
|
-
.sort((a, b) => {
|
|
131
|
-
const index = (name) => Number(name.match(/-(\d+)\.jsonl$/)?.[1] || 0);
|
|
132
|
-
return index(a) - index(b);
|
|
133
|
-
});
|
|
134
|
-
const lastFile = telemetryFiles.at(-1);
|
|
135
|
-
if (lastFile) {
|
|
136
|
-
const lines = readFileSync(join(stateDir, lastFile), "utf-8").split("\n").filter(Boolean);
|
|
137
|
-
const lastEntry = JSON.parse(lines.at(-1));
|
|
138
|
-
if (typeof lastEntry?.hash === "string") tailHash = lastEntry.hash;
|
|
139
|
-
const segmentMatch = lastFile.match(/-(\d+)\.jsonl$/);
|
|
140
|
-
segment = segmentMatch ? Number(segmentMatch[1]) : 0;
|
|
141
|
-
}
|
|
142
|
-
} catch (_) {}
|
|
143
|
-
const newContent = JSON.stringify({ v: 1, hash: tailHash, segment, ts: new Date().toISOString() });
|
|
144
|
-
const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
145
|
-
|
|
146
|
-
plans.push({
|
|
147
|
-
schema: "agentctl/action-plan-v1",
|
|
148
|
-
id: planId,
|
|
149
|
-
kind: "state.rebuild-head",
|
|
150
|
-
title: "Rebuild telemetry spine head pointer",
|
|
151
|
-
summary: "Re-initialize .head pointer for telemetry spine ledger",
|
|
152
|
-
risk: "low",
|
|
153
|
-
repository: root,
|
|
154
|
-
createdAt: new Date().toISOString(),
|
|
155
|
-
preconditions: [],
|
|
156
|
-
fileMutations: [
|
|
157
|
-
{
|
|
158
|
-
operation: "create",
|
|
159
|
-
path: headPath,
|
|
160
|
-
newContent,
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
commandEffects: [],
|
|
164
|
-
stateTransitions: [],
|
|
165
|
-
preview: {
|
|
166
|
-
unifiedDiff: createDiffPreview(headPath, "", newContent),
|
|
167
|
-
warnings: [],
|
|
168
|
-
estimatedImpact: [`Creates ${headPath} pointer`],
|
|
169
|
-
},
|
|
170
|
-
confirmation: {
|
|
171
|
-
mode: "none",
|
|
172
|
-
prompt: "Rebuild telemetry head pointer?",
|
|
173
|
-
},
|
|
174
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
175
|
-
});
|
|
176
|
-
} else if (fix.id === "locks.prune-stale") {
|
|
177
|
-
const locksDir = join(root, ".agent", "state", "locks");
|
|
178
|
-
if (existsSync(locksDir)) {
|
|
179
|
-
const lockFiles = readdirSync(locksDir).filter((f) => f.endsWith(".json"));
|
|
180
|
-
for (const lockFile of lockFiles) {
|
|
181
|
-
const lockRelPath = `.agent/state/locks/${lockFile}`;
|
|
182
|
-
const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
183
|
-
|
|
184
|
-
plans.push({
|
|
185
|
-
schema: "agentctl/action-plan-v1",
|
|
186
|
-
id: planId,
|
|
187
|
-
kind: "locks.prune-stale",
|
|
188
|
-
title: `Remove stale lock ${lockFile}`,
|
|
189
|
-
summary: `Prune inactive VFS lock file ${lockRelPath}`,
|
|
190
|
-
risk: "low",
|
|
191
|
-
repository: root,
|
|
192
|
-
createdAt: new Date().toISOString(),
|
|
193
|
-
preconditions: [],
|
|
194
|
-
fileMutations: [
|
|
195
|
-
{
|
|
196
|
-
operation: "delete",
|
|
197
|
-
path: lockRelPath,
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
commandEffects: [],
|
|
201
|
-
stateTransitions: [],
|
|
202
|
-
preview: {
|
|
203
|
-
unifiedDiff: `--- a/${lockRelPath}\n+++ /dev/null\n`,
|
|
204
|
-
warnings: [],
|
|
205
|
-
estimatedImpact: [`Removes ${lockRelPath}`],
|
|
206
|
-
},
|
|
207
|
-
confirmation: {
|
|
208
|
-
mode: "none",
|
|
209
|
-
prompt: `Remove lock ${lockFile}?`,
|
|
210
|
-
},
|
|
211
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return plans.map(finalizePlan);
|
|
220
|
-
}
|
package/src/ops/receipts.mjs
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
openSync,
|
|
6
|
-
writeSync,
|
|
7
|
-
fsyncSync,
|
|
8
|
-
closeSync,
|
|
9
|
-
renameSync,
|
|
10
|
-
unlinkSync,
|
|
11
|
-
} from "node:fs";
|
|
12
|
-
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
13
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import { redactSecrets } from "../security.mjs";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @typedef {Object} OperationReceipt
|
|
18
|
-
* @property {"agentctl/operation-receipt-v1"} schema
|
|
19
|
-
* @property {string} receiptId
|
|
20
|
-
* @property {string} actionPlanId
|
|
21
|
-
* @property {string} kind
|
|
22
|
-
* @property {string} title
|
|
23
|
-
* @property {string} repository
|
|
24
|
-
* @property {string} appliedAt
|
|
25
|
-
* @property {"success" | "rolled-back" | "failed"} status
|
|
26
|
-
* @property {number} durationMs
|
|
27
|
-
* @property {Array<{ path: string, operation: string, hashBefore?: string, hashAfter?: string }>} mutationsApplied
|
|
28
|
-
* @property {Array<{ executable: string, args: string[], exitCode: number }>} effectsExecuted
|
|
29
|
-
* @property {string} receiptHash
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Compute SHA-256 hash of operation receipt metadata.
|
|
34
|
-
* @param {Omit<OperationReceipt, "receiptHash">} receipt
|
|
35
|
-
* @returns {string}
|
|
36
|
-
*/
|
|
37
|
-
export function computeReceiptHash(receipt) {
|
|
38
|
-
const payload = JSON.stringify({
|
|
39
|
-
schema: receipt.schema,
|
|
40
|
-
receiptId: receipt.receiptId,
|
|
41
|
-
actionPlanId: receipt.actionPlanId,
|
|
42
|
-
kind: receipt.kind,
|
|
43
|
-
title: receipt.title,
|
|
44
|
-
repository: receipt.repository,
|
|
45
|
-
status: receipt.status,
|
|
46
|
-
appliedAt: receipt.appliedAt,
|
|
47
|
-
durationMs: receipt.durationMs,
|
|
48
|
-
mutationsApplied: receipt.mutationsApplied,
|
|
49
|
-
effectsExecuted: receipt.effectsExecuted,
|
|
50
|
-
});
|
|
51
|
-
return "sha256:" + createHash("sha256").update(payload).digest("hex");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function redactReceiptValue(value) {
|
|
55
|
-
if (typeof value === "string") return redactSecrets(value);
|
|
56
|
-
if (Array.isArray(value)) return value.map(redactReceiptValue);
|
|
57
|
-
if (value && typeof value === "object") {
|
|
58
|
-
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactReceiptValue(child)]));
|
|
59
|
-
}
|
|
60
|
-
return value;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function assertReceiptId(receiptId) {
|
|
64
|
-
if (typeof receiptId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(receiptId)) {
|
|
65
|
-
throw new Error("Invalid operation receipt id");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function writeFileAtomically(filePath, content) {
|
|
70
|
-
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
71
|
-
let fd;
|
|
72
|
-
try {
|
|
73
|
-
fd = openSync(tmpPath, "wx", 0o600);
|
|
74
|
-
writeSync(fd, content, "utf-8");
|
|
75
|
-
fsyncSync(fd);
|
|
76
|
-
closeSync(fd);
|
|
77
|
-
fd = undefined;
|
|
78
|
-
renameSync(tmpPath, filePath);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
if (fd !== undefined) {
|
|
81
|
-
try { closeSync(fd); } catch (_) {}
|
|
82
|
-
}
|
|
83
|
-
try { unlinkSync(tmpPath); } catch (_) {}
|
|
84
|
-
throw err;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Create and persist an OperationReceipt atomically.
|
|
90
|
-
* @param {string} root
|
|
91
|
-
* @param {Omit<OperationReceipt, "schema" | "receiptId" | "appliedAt" | "receiptHash"> & { receiptId?: string }} data
|
|
92
|
-
* @returns {OperationReceipt}
|
|
93
|
-
*/
|
|
94
|
-
export function createOperationReceipt(root, data) {
|
|
95
|
-
const receiptsDir = join(root, ".agent", "receipts");
|
|
96
|
-
if (!existsSync(receiptsDir)) {
|
|
97
|
-
mkdirSync(receiptsDir, { recursive: true });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const receiptId = data.receiptId || `REC-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
101
|
-
assertReceiptId(receiptId);
|
|
102
|
-
const appliedAt = new Date().toISOString();
|
|
103
|
-
|
|
104
|
-
const baseReceipt = redactReceiptValue({
|
|
105
|
-
schema: /** @type {const} */ ("agentctl/operation-receipt-v1"),
|
|
106
|
-
receiptId,
|
|
107
|
-
actionPlanId: data.actionPlanId,
|
|
108
|
-
kind: data.kind,
|
|
109
|
-
title: data.title,
|
|
110
|
-
repository: data.repository || "local",
|
|
111
|
-
appliedAt,
|
|
112
|
-
status: data.status,
|
|
113
|
-
durationMs: data.durationMs || 0,
|
|
114
|
-
mutationsApplied: data.mutationsApplied || [],
|
|
115
|
-
effectsExecuted: data.effectsExecuted || [],
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const receiptHash = computeReceiptHash(baseReceipt);
|
|
119
|
-
/** @type {OperationReceipt} */
|
|
120
|
-
const fullReceipt = {
|
|
121
|
-
...baseReceipt,
|
|
122
|
-
receiptHash,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const targetPath = join(receiptsDir, `${receiptId}.json`);
|
|
126
|
-
writeFileAtomically(targetPath, JSON.stringify(fullReceipt, null, 2));
|
|
127
|
-
|
|
128
|
-
return fullReceipt;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Load and validate an OperationReceipt from disk.
|
|
133
|
-
* @param {string} root
|
|
134
|
-
* @param {string} receiptIdOrPath
|
|
135
|
-
* @returns {OperationReceipt}
|
|
136
|
-
*/
|
|
137
|
-
export function loadOperationReceipt(root, receiptIdOrPath) {
|
|
138
|
-
const receiptsDir = resolve(root, ".agent", "receipts");
|
|
139
|
-
let targetPath;
|
|
140
|
-
if (typeof receiptIdOrPath !== "string" || !receiptIdOrPath) {
|
|
141
|
-
throw new Error("Operation receipt id or path is required");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (!receiptIdOrPath.endsWith(".json")) {
|
|
145
|
-
assertReceiptId(receiptIdOrPath);
|
|
146
|
-
targetPath = join(receiptsDir, `${receiptIdOrPath}.json`);
|
|
147
|
-
} else {
|
|
148
|
-
targetPath = isAbsolute(receiptIdOrPath) ? resolve(receiptIdOrPath) : resolve(root, receiptIdOrPath);
|
|
149
|
-
const rel = relative(receiptsDir, targetPath);
|
|
150
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
151
|
-
throw new Error("Operation receipt path escapes the receipts directory");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (!existsSync(targetPath)) {
|
|
156
|
-
throw new Error(`Operation receipt not found: ${targetPath}`);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const content = readFileSync(targetPath, "utf-8");
|
|
160
|
-
/** @type {OperationReceipt} */
|
|
161
|
-
const receipt = JSON.parse(content);
|
|
162
|
-
|
|
163
|
-
if (receipt.schema !== "agentctl/operation-receipt-v1") {
|
|
164
|
-
throw new Error(`Invalid receipt schema version: ${receipt.schema}`);
|
|
165
|
-
}
|
|
166
|
-
if (receipt.receiptHash !== computeReceiptHash(receipt)) {
|
|
167
|
-
throw new Error(`Invalid operation receipt hash: ${targetPath}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return receipt;
|
|
171
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
|
|
5
|
-
* @typedef {import("../ux/swarm-model.mjs").SwarmSlot} SwarmSlot
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Plan swarm concurrency action into an immutable ActionPlan.
|
|
10
|
-
* @param {{ slots: SwarmSlot[], revision: string }} snapshot
|
|
11
|
-
* @param {Object} intent
|
|
12
|
-
* @param {string} intent.kind
|
|
13
|
-
* @param {string} [intent.slotId]
|
|
14
|
-
* @param {string} [intent.taskId]
|
|
15
|
-
* @param {Record<string, any>} [intent.answers]
|
|
16
|
-
* @returns {Promise<ActionPlan>}
|
|
17
|
-
*/
|
|
18
|
-
export async function planSwarmAction(snapshot, intent) {
|
|
19
|
-
const planId = `PLAN-SWARM-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
20
|
-
|
|
21
|
-
if (intent.kind === "pause-scheduler" || intent.kind === "resume-scheduler") {
|
|
22
|
-
const isPause = intent.kind === "pause-scheduler";
|
|
23
|
-
const schedulerRelPath = ".agent/state/swarm/scheduler.json";
|
|
24
|
-
const newContent = JSON.stringify(
|
|
25
|
-
{
|
|
26
|
-
schema: "agentctl/swarm-scheduler-v1",
|
|
27
|
-
paused: isPause,
|
|
28
|
-
updatedAt: new Date().toISOString(),
|
|
29
|
-
},
|
|
30
|
-
null,
|
|
31
|
-
2
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
schema: "agentctl/action-plan-v1",
|
|
36
|
-
id: planId,
|
|
37
|
-
kind: intent.kind,
|
|
38
|
-
title: `${isPause ? "Pause" : "Resume"} Swarm Scheduler`,
|
|
39
|
-
summary: `${isPause ? "Halt dispatching new tasks to swarm slots" : "Resume task dispatching to available slots"}`,
|
|
40
|
-
risk: "moderate",
|
|
41
|
-
repository: "local",
|
|
42
|
-
createdAt: new Date().toISOString(),
|
|
43
|
-
preconditions: [],
|
|
44
|
-
fileMutations: [
|
|
45
|
-
{
|
|
46
|
-
operation: "replace",
|
|
47
|
-
path: schedulerRelPath,
|
|
48
|
-
newContent,
|
|
49
|
-
},
|
|
50
|
-
],
|
|
51
|
-
commandEffects: [],
|
|
52
|
-
stateTransitions: [],
|
|
53
|
-
preview: {
|
|
54
|
-
unifiedDiff: `--- a/${schedulerRelPath}\n+++ b/${schedulerRelPath}\n@@ -paused @@\n- paused: ${!isPause}\n+ paused: ${isPause}\n`,
|
|
55
|
-
warnings: isPause ? ["Running tasks will complete, but no new tasks will be scheduled."] : [],
|
|
56
|
-
estimatedImpact: [`Swarm scheduler state set to ${isPause ? "PAUSED" : "RUNNING"}`],
|
|
57
|
-
},
|
|
58
|
-
confirmation: {
|
|
59
|
-
mode: "keypress",
|
|
60
|
-
prompt: `Confirm ${isPause ? "pause" : "resume"} of swarm scheduler?`,
|
|
61
|
-
},
|
|
62
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (intent.kind === "cancel-slot-task") {
|
|
67
|
-
const slotId = intent.slotId;
|
|
68
|
-
const slot = (snapshot.slots || []).find((s) => s.id === slotId || s.taskId === intent.taskId);
|
|
69
|
-
if (!slot) {
|
|
70
|
-
throw new Error(`Target slot ${slotId || intent.taskId} not found.`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const slotRelPath = `.agent/state/swarm/${slot.id}.json`;
|
|
74
|
-
const newContent = JSON.stringify(
|
|
75
|
-
{
|
|
76
|
-
id: slot.id,
|
|
77
|
-
state: "draining",
|
|
78
|
-
taskId: slot.taskId,
|
|
79
|
-
cancelledAt: new Date().toISOString(),
|
|
80
|
-
},
|
|
81
|
-
null,
|
|
82
|
-
2
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
schema: "agentctl/action-plan-v1",
|
|
87
|
-
id: planId,
|
|
88
|
-
kind: "swarm.cancel-slot-task",
|
|
89
|
-
title: `Cancel active slot task ${slot.taskId || slot.id}`,
|
|
90
|
-
summary: `Send cancellation signal to slot ${slot.id} (Task ${slot.taskId || "N/A"})`,
|
|
91
|
-
risk: "high",
|
|
92
|
-
repository: "local",
|
|
93
|
-
createdAt: new Date().toISOString(),
|
|
94
|
-
preconditions: [],
|
|
95
|
-
fileMutations: [
|
|
96
|
-
{
|
|
97
|
-
operation: "replace",
|
|
98
|
-
path: slotRelPath,
|
|
99
|
-
newContent,
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
commandEffects: [],
|
|
103
|
-
stateTransitions: [{ taskId: slot.taskId, from: slot.state, to: "cancelled" }],
|
|
104
|
-
preview: {
|
|
105
|
-
unifiedDiff: `--- a/${slotRelPath}\n+++ b/${slotRelPath}\n@@ -state @@\n- state: ${slot.state}\n+ state: draining\n`,
|
|
106
|
-
warnings: ["Cancelling a running worker process will terminate its active attempt."],
|
|
107
|
-
estimatedImpact: [`Cancels execution in slot ${slot.id}`],
|
|
108
|
-
},
|
|
109
|
-
confirmation: {
|
|
110
|
-
mode: "typed-task-id",
|
|
111
|
-
prompt: `Type '${slot.taskId || slot.id}' to confirm cancellation:`,
|
|
112
|
-
expected: slot.taskId || slot.id,
|
|
113
|
-
},
|
|
114
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (intent.kind === "prune-stale-slots") {
|
|
119
|
-
const staleSlots = (snapshot.slots || []).filter((s) => s.state === "stale");
|
|
120
|
-
if (staleSlots.length === 0) {
|
|
121
|
-
throw new Error("No stale slots found to prune.");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const fileMutations = staleSlots.map((s) => ({
|
|
125
|
-
operation: /** @type {const} */ ("delete"),
|
|
126
|
-
path: `.agent/state/swarm/${s.id}.json`,
|
|
127
|
-
}));
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
schema: "agentctl/action-plan-v1",
|
|
131
|
-
id: planId,
|
|
132
|
-
kind: "swarm.prune-stale-slots",
|
|
133
|
-
title: `Prune ${staleSlots.length} stale swarm slot(s)`,
|
|
134
|
-
summary: `Remove stale slot state files for dead worker process PIDs`,
|
|
135
|
-
risk: "low",
|
|
136
|
-
repository: "local",
|
|
137
|
-
createdAt: new Date().toISOString(),
|
|
138
|
-
preconditions: [],
|
|
139
|
-
fileMutations,
|
|
140
|
-
commandEffects: [],
|
|
141
|
-
stateTransitions: [],
|
|
142
|
-
preview: {
|
|
143
|
-
unifiedDiff: staleSlots.map((s) => `--- a/.agent/state/swarm/${s.id}.json\n+++ /dev/null\n`).join("\n"),
|
|
144
|
-
warnings: [],
|
|
145
|
-
estimatedImpact: [`Prunes ${staleSlots.length} stale slot file(s)`],
|
|
146
|
-
},
|
|
147
|
-
confirmation: {
|
|
148
|
-
mode: "keypress",
|
|
149
|
-
prompt: `Prune ${staleSlots.length} stale slot(s)?`,
|
|
150
|
-
},
|
|
151
|
-
planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
throw new Error(`Unsupported swarm action intent: ${intent.kind}`);
|
|
156
|
-
}
|