jules-orchestrator-kit 0.52.8 → 0.53.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 +66 -7
- package/bin/agentctl.mjs +172 -1
- 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.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/doctor-registry.mjs +53 -32
- package/src/ops/next-step.mjs +38 -7
- package/src/profiles-io.mjs +77 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +215 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +32 -4
- 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
package/src/wizard-init.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
|
|
4
|
+
import { suggestProvider } from "./provider-readiness.mjs";
|
|
5
|
+
import { PROFILE_NAMES } from "./profiles.mjs";
|
|
4
6
|
import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
|
|
5
7
|
import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
|
|
6
8
|
import { KIT_VERSION } from "./version.mjs";
|
|
@@ -158,18 +160,35 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
158
160
|
|
|
159
161
|
const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
|
|
160
162
|
|
|
163
|
+
// Scaffolding `provider: jules` into a repository whose operator has no Jules
|
|
164
|
+
// key — and a Claude Code or Codex CLI sitting on PATH — produced a config
|
|
165
|
+
// that could never dispatch, and gave no hint that three other providers were
|
|
166
|
+
// installed. Detection only fills a blank: an explicit option, and an
|
|
167
|
+
// existing config, both still win.
|
|
168
|
+
const provider =
|
|
169
|
+
options.provider || existingConfig.provider || suggestProvider({ env: options.env || process.env });
|
|
170
|
+
|
|
171
|
+
// How hard this repository wants its agents verified, in one word. Expanded
|
|
172
|
+
// into concrete stages at load time by `loadConfig`, never frozen here, so
|
|
173
|
+
// the gate list follows the stack instead of the day the repo was scaffolded.
|
|
174
|
+
const requestedProfile = String(options.profile || existingConfig.verify?.profile || "standard").toLowerCase();
|
|
175
|
+
const profile = PROFILE_NAMES.includes(requestedProfile) ? requestedProfile : "standard";
|
|
176
|
+
|
|
161
177
|
const limitsBlock = isCustomLimits
|
|
162
178
|
? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
|
|
163
179
|
: "";
|
|
164
180
|
|
|
165
|
-
const configYaml = `#
|
|
181
|
+
const configYaml = `# Agent Orchestrator Kit Config (v${KIT_VERSION})
|
|
182
|
+
# provider: jules | claude-code | codex | gemini-flash (agentctl providers)
|
|
166
183
|
version: 1
|
|
167
|
-
provider: ${
|
|
184
|
+
provider: ${provider}
|
|
168
185
|
tier: ${tierName}
|
|
169
186
|
base_branch: ${options.baseBranch || existingConfig.base_branch || "main"}
|
|
170
187
|
branch_prefix: ${existingConfig.branch_prefix || "agent/"}
|
|
171
188
|
${limitsBlock}
|
|
172
189
|
verify:
|
|
190
|
+
# minimal | standard | max — see: agentctl profile --list
|
|
191
|
+
profile: ${profile}
|
|
173
192
|
test: "${verify.test}"
|
|
174
193
|
build: "${verify.build}"
|
|
175
194
|
lint: "${verify.lint}"
|
|
@@ -179,12 +198,19 @@ presets:
|
|
|
179
198
|
${selectedPresets.map((p) => ` - ${p}`).join("\n")}
|
|
180
199
|
`;
|
|
181
200
|
|
|
201
|
+
// Universal defaults only. This list used to name `**/lock-manager/**` and
|
|
202
|
+
// `scripts/jules-self-audit.mjs` — paths that exist in the kit's own
|
|
203
|
+
// repository and in no other, so every project scaffolded by the wizard
|
|
204
|
+
// inherited two rules that could never match anything it contained, and none
|
|
205
|
+
// for the credentials it actually had.
|
|
182
206
|
const forbiddenPaths = options.forbiddenPaths || existingJules.forbidden_paths || [
|
|
183
207
|
".github/**",
|
|
184
208
|
"**/secrets/**",
|
|
185
209
|
"**/*.pem",
|
|
186
|
-
"
|
|
187
|
-
"
|
|
210
|
+
"**/*.key",
|
|
211
|
+
"**/.env",
|
|
212
|
+
"**/.env.*",
|
|
213
|
+
".agent/config.yml",
|
|
188
214
|
".agent/jules.yml",
|
|
189
215
|
];
|
|
190
216
|
const allowPaths = options.allowPaths || existingJules.allow_paths || [];
|
|
@@ -201,6 +227,8 @@ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`
|
|
|
201
227
|
return {
|
|
202
228
|
stack: oracle.stack,
|
|
203
229
|
tier: tierName,
|
|
230
|
+
provider,
|
|
231
|
+
profile,
|
|
204
232
|
verify,
|
|
205
233
|
limits,
|
|
206
234
|
presets: selectedPresets,
|
|
@@ -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
|
-
}
|