jules-orchestrator-kit 0.32.8 → 0.34.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 +3 -3
- package/bin/agentctl.mjs +131 -10
- package/index.mjs +19 -0
- package/package.json +1 -1
- package/scripts/doc-sync-check.mjs +69 -8
- package/scripts/utils.mjs +6 -2
- package/src/budget.mjs +345 -0
- package/src/config.mjs +71 -3
- package/src/dashboard.mjs +2 -1
- package/src/engine.mjs +24 -2
- package/src/mcp.mjs +15 -4
- package/src/ops/command-registry.mjs +24 -1
- package/src/ops/doctor-registry.mjs +87 -5
- package/src/ops/next-step.mjs +111 -0
- package/src/state.mjs +162 -57
- package/src/version.mjs +27 -0
- package/src/wizard-init.mjs +57 -28
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { loadConfig } from "../config.mjs";
|
|
6
|
+
import { resolveConcurrency } from "../budget.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
|
|
@@ -261,6 +263,41 @@ export async function runDoctorChecks(options = {}) {
|
|
|
261
263
|
});
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
// 3b. Worker slots against the plan's ceiling.
|
|
267
|
+
//
|
|
268
|
+
// A warning, never a failure: the kit cannot see the account, only the
|
|
269
|
+
// config. Pooled accounts legitimately exceed a single plan's ceiling, and
|
|
270
|
+
// the provider refuses what it will not allow regardless of what we think.
|
|
271
|
+
try {
|
|
272
|
+
const cfg = loadConfig(root);
|
|
273
|
+
const slots = resolveConcurrency(cfg);
|
|
274
|
+
addResult({
|
|
275
|
+
id: "limits.concurrency",
|
|
276
|
+
category: "Config",
|
|
277
|
+
title: "Worker Slots vs Plan Ceiling",
|
|
278
|
+
status: slots.overCeiling ? "warn" : "pass",
|
|
279
|
+
severity: slots.overCeiling ? "medium" : "info",
|
|
280
|
+
summary: `${slots.concurrency} concurrent worker(s) — ${slots.note}`,
|
|
281
|
+
evidence: [
|
|
282
|
+
{ label: "concurrency", value: slots.concurrency, sensitive: false },
|
|
283
|
+
{ label: "planCeiling", value: slots.ceiling, sensitive: false },
|
|
284
|
+
{ label: "source", value: slots.source, sensitive: false },
|
|
285
|
+
],
|
|
286
|
+
remediation: slots.overCeiling
|
|
287
|
+
? [
|
|
288
|
+
{
|
|
289
|
+
summary: `Set limits.concurrency to ${slots.ceiling} or lower in .agent/config.yml, unless this account pools several plans`,
|
|
290
|
+
risk: "low",
|
|
291
|
+
automatic: false,
|
|
292
|
+
requiresProbe: false,
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: [],
|
|
296
|
+
});
|
|
297
|
+
} catch (_) {
|
|
298
|
+
// A config the loader rejects is already reported by config.present.
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
// 4. Verification Oracle Checks
|
|
265
302
|
const pkgPath = join(root, "package.json");
|
|
266
303
|
if (existsSync(pkgPath)) {
|
|
@@ -393,16 +430,21 @@ export async function runDoctorChecks(options = {}) {
|
|
|
393
430
|
}
|
|
394
431
|
|
|
395
432
|
// 7. Jules Provider Key Check
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
433
|
+
const keyVar = process.env.JULES_API_KEY ? "JULES_API_KEY" : process.env.GEMINI_API_KEY ? "GEMINI_API_KEY" : "";
|
|
434
|
+
if (keyVar) {
|
|
398
435
|
addResult({
|
|
399
436
|
id: "provider.key",
|
|
400
437
|
category: "Jules",
|
|
401
438
|
title: "Jules Provider API Key",
|
|
402
439
|
status: "pass",
|
|
403
440
|
severity: "info",
|
|
404
|
-
|
|
405
|
-
|
|
441
|
+
// Naming the variable, not the value: an operator who wonders which key a
|
|
442
|
+
// dispatch will use should not have to echo a secret to find out.
|
|
443
|
+
summary: `API key supplied via ${keyVar} (environment only — never written to config or sent anywhere but the provider)`,
|
|
444
|
+
evidence: [
|
|
445
|
+
{ label: "keyConfigured", value: true, sensitive: false },
|
|
446
|
+
{ label: "keySource", value: keyVar, sensitive: false },
|
|
447
|
+
],
|
|
406
448
|
});
|
|
407
449
|
} else {
|
|
408
450
|
addResult({
|
|
@@ -412,10 +454,50 @@ export async function runDoctorChecks(options = {}) {
|
|
|
412
454
|
status: "warn",
|
|
413
455
|
severity: "high",
|
|
414
456
|
summary: "Neither JULES_API_KEY nor GEMINI_API_KEY environment variable is set",
|
|
457
|
+
remediation: [
|
|
458
|
+
{
|
|
459
|
+
summary: "Export JULES_API_KEY in your shell profile, or place it in a git-ignored .env",
|
|
460
|
+
risk: "low",
|
|
461
|
+
automatic: false,
|
|
462
|
+
requiresProbe: false,
|
|
463
|
+
},
|
|
464
|
+
],
|
|
415
465
|
evidence: [{ label: "keyConfigured", value: false, sensitive: false }],
|
|
416
466
|
});
|
|
417
467
|
}
|
|
418
468
|
|
|
469
|
+
// 7b. A .env holding the key must not be tracked by git.
|
|
470
|
+
const envFile = join(root, ".env");
|
|
471
|
+
if (existsSync(envFile)) {
|
|
472
|
+
let tracked = false;
|
|
473
|
+
try {
|
|
474
|
+
const res = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], { cwd: root, encoding: "utf-8" });
|
|
475
|
+
tracked = res.status === 0;
|
|
476
|
+
} catch (_) {}
|
|
477
|
+
|
|
478
|
+
addResult({
|
|
479
|
+
id: "provider.key.dotenv",
|
|
480
|
+
category: "Jules",
|
|
481
|
+
title: "Local .env secrecy",
|
|
482
|
+
status: tracked ? "fail" : "pass",
|
|
483
|
+
severity: tracked ? "critical" : "info",
|
|
484
|
+
summary: tracked
|
|
485
|
+
? ".env is tracked by git — an API key committed here is disclosed to everyone with repository access"
|
|
486
|
+
: ".env is present and untracked",
|
|
487
|
+
evidence: [{ label: "gitTracked", value: tracked, sensitive: false }],
|
|
488
|
+
remediation: tracked
|
|
489
|
+
? [
|
|
490
|
+
{
|
|
491
|
+
summary: "Run: git rm --cached .env && echo '.env' >> .gitignore, then rotate the key",
|
|
492
|
+
risk: "low",
|
|
493
|
+
automatic: false,
|
|
494
|
+
requiresProbe: false,
|
|
495
|
+
},
|
|
496
|
+
]
|
|
497
|
+
: [],
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
419
501
|
// Summarize count by status
|
|
420
502
|
const summary = { pass: 0, warn: 0, fail: 0, skip: 0, unknown: 0 };
|
|
421
503
|
for (const r of results) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Work out the one thing the operator should do next.
|
|
7
|
+
*
|
|
8
|
+
* `agentctl --help` lists thirty commands with no indication of which is step
|
|
9
|
+
* one. That list is a reference for people who already know the tool; someone
|
|
10
|
+
* meeting it for the first time cannot tell `hydrate` from `harvest` from
|
|
11
|
+
* `dispatch`, and the cost of guessing wrong is a confusing failure rather than
|
|
12
|
+
* a hint. This walks the same preconditions the commands themselves enforce and
|
|
13
|
+
* names the single next action.
|
|
14
|
+
*
|
|
15
|
+
* Ordered by dependency: nothing later is worth suggesting while something
|
|
16
|
+
* earlier is unmet.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} root
|
|
19
|
+
* @param {object} [env=process.env]
|
|
20
|
+
* @returns {{ id: string, headline: string, detail: string, command: string, blocking: boolean }}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveNextStep(root, env = process.env) {
|
|
23
|
+
const inGitRepo = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return spawnSync("git", ["rev-parse", "--git-dir"], { cwd: root, stdio: "ignore" }).status === 0;
|
|
26
|
+
} catch (_) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
if (!inGitRepo) {
|
|
32
|
+
return {
|
|
33
|
+
id: "git",
|
|
34
|
+
headline: "This directory is not a git repository",
|
|
35
|
+
detail:
|
|
36
|
+
"Every safety guarantee here is expressed in terms of diffs, branches and a base to compare against, so the kit has nothing to reason about without git.",
|
|
37
|
+
command: "git init",
|
|
38
|
+
blocking: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const configured = existsSync(join(root, ".agent", "config.yml")) || existsSync(join(root, ".agent", "jules.yml"));
|
|
43
|
+
if (!configured) {
|
|
44
|
+
return {
|
|
45
|
+
id: "init",
|
|
46
|
+
headline: "No .agent/ configuration yet",
|
|
47
|
+
detail:
|
|
48
|
+
"The wizard detects your stack, proposes a verification command, and writes the scope rules the gate enforces.",
|
|
49
|
+
command: "agentctl init",
|
|
50
|
+
blocking: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!env.JULES_API_KEY && !env.GEMINI_API_KEY) {
|
|
55
|
+
return {
|
|
56
|
+
id: "key",
|
|
57
|
+
headline: "No provider API key in the environment",
|
|
58
|
+
detail:
|
|
59
|
+
"The key is read from the environment only — never written to config and never sent anywhere but the provider. Until it is set you can still run `agentctl gate` and `--dry-run` dispatches locally.",
|
|
60
|
+
command: "export JULES_API_KEY=...",
|
|
61
|
+
blocking: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const queueDir = join(root, ".agent", "queue");
|
|
66
|
+
const queued = existsSync(queueDir) ? readdirSync(queueDir).filter((f) => f.endsWith(".json") || f.endsWith(".yml")).length : 0;
|
|
67
|
+
if (queued > 0) {
|
|
68
|
+
return {
|
|
69
|
+
id: "queue",
|
|
70
|
+
headline: `${queued} task(s) waiting in the queue`,
|
|
71
|
+
detail: "Run them through the gate and dispatch pipeline.",
|
|
72
|
+
command: "agentctl queue",
|
|
73
|
+
blocking: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id: "ready",
|
|
79
|
+
headline: "Set up and ready to dispatch",
|
|
80
|
+
detail: "Add --dry-run first to see the envelope without spending a task.",
|
|
81
|
+
command: 'agentctl dispatch -p "your task"',
|
|
82
|
+
blocking: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the bare-invocation greeting: state, next step, and where the full
|
|
88
|
+
* command list lives for those who want it.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} ctx
|
|
91
|
+
* @param {string} ctx.version
|
|
92
|
+
* @param {string} ctx.root
|
|
93
|
+
* @param {{ headline: string, detail: string, command: string }} ctx.next
|
|
94
|
+
* @param {string} [ctx.budgetLine]
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function renderNextStep({ version, root, next, budgetLine }) {
|
|
98
|
+
const lines = [
|
|
99
|
+
``,
|
|
100
|
+
`🚀 agentctl v${version}`,
|
|
101
|
+
` ${root}`,
|
|
102
|
+
``,
|
|
103
|
+
` ${next.headline}`,
|
|
104
|
+
` ${next.detail}`,
|
|
105
|
+
``,
|
|
106
|
+
` Next: ${next.command}`,
|
|
107
|
+
];
|
|
108
|
+
if (budgetLine) lines.push(``, ` Budget: ${budgetLine}`);
|
|
109
|
+
lines.push(``, ` All commands: agentctl --help`, ``);
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
package/src/state.mjs
CHANGED
|
@@ -55,6 +55,129 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
|
|
|
55
55
|
return join(getStateDir(root), `ledger-${dateStr}.jsonl`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How far back a task still counts against the allowance.
|
|
60
|
+
*
|
|
61
|
+
* The provider's daily quota resets on a rolling 24-hour window, not at
|
|
62
|
+
* midnight. Counting per calendar day — which the ledger's `ledger-<date>`
|
|
63
|
+
* rotation invites — is wrong in both directions: a batch dispatched at 23:00
|
|
64
|
+
* stops being counted at 00:01 while the provider still refuses on it, and
|
|
65
|
+
* yesterday's last hours vanish from a count that should still include them.
|
|
66
|
+
*
|
|
67
|
+
* The files stay day-scoped, because rotation is a storage concern. Counting
|
|
68
|
+
* is not: it spans whatever files the window touches and filters on entry
|
|
69
|
+
* timestamps.
|
|
70
|
+
*/
|
|
71
|
+
export const ROLLING_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ledger files that can hold entries inside the rolling window, oldest first.
|
|
77
|
+
*
|
|
78
|
+
* One day wider than the window itself: an entry timestamped 23:59 UTC sits in
|
|
79
|
+
* that day's file, and a window opening moments later still has to see it.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} [root]
|
|
82
|
+
* @param {number} [now] - Epoch ms; injectable so tests need not wait a day.
|
|
83
|
+
* @param {number} [windowMs]
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function getLedgerPathsInWindow(root = resolveRoot(), now = Date.now(), windowMs = ROLLING_WINDOW_MS) {
|
|
87
|
+
const stateDir = getStateDir(root);
|
|
88
|
+
const spanDays = Math.ceil(windowMs / DAY_MS) + 1;
|
|
89
|
+
const paths = [];
|
|
90
|
+
for (let i = spanDays - 1; i >= 0; i--) {
|
|
91
|
+
const dateStr = new Date(now - i * DAY_MS).toISOString().split("T")[0];
|
|
92
|
+
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
93
|
+
if (!paths.includes(filePath) && existsSync(filePath)) paths.push(filePath);
|
|
94
|
+
}
|
|
95
|
+
return paths;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replay the budget events in the rolling window and report what is still spent.
|
|
100
|
+
*
|
|
101
|
+
* Reservations carrying a `reservationId` are matched by name. Legacy id-less
|
|
102
|
+
* ones — written by older kit versions — can only be matched by position, so a
|
|
103
|
+
* release without a `releasedTimestamp` consumes the oldest still-open
|
|
104
|
+
* anonymous reservation. `releaseOpenReservations` records that timestamp
|
|
105
|
+
* precisely so the pairing survives the window boundary: without it, an
|
|
106
|
+
* anonymous release could outlive the reservation it cancelled and start
|
|
107
|
+
* subtracting from a later one instead.
|
|
108
|
+
*
|
|
109
|
+
* Entries whose timestamp will not parse are counted. A budget event the kit
|
|
110
|
+
* cannot place in time is safer treated as spent than as free.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} [root]
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {number} [opts.now]
|
|
115
|
+
* @param {number} [opts.windowMs]
|
|
116
|
+
* @returns {{ used: number, open: { reservationId: string|null, timestamp: string, committed: boolean }[], windowStart: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function scanBudgetWindow(root = resolveRoot(), opts = {}) {
|
|
119
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
120
|
+
const windowMs = Number.isFinite(opts.windowMs) ? opts.windowMs : ROLLING_WINDOW_MS;
|
|
121
|
+
const cutoff = now - windowMs;
|
|
122
|
+
|
|
123
|
+
/** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean, inWindow: boolean }>} */
|
|
124
|
+
const byId = new Map();
|
|
125
|
+
/** @type {{ reservationId: null, timestamp: string, committed: boolean, inWindow: boolean }[]} */
|
|
126
|
+
const anonymous = [];
|
|
127
|
+
|
|
128
|
+
const inWindow = (timestamp) => {
|
|
129
|
+
const ts = Date.parse(timestamp || "");
|
|
130
|
+
return Number.isFinite(ts) ? ts >= cutoff : true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const filePath of getLedgerPathsInWindow(root, now, windowMs)) {
|
|
134
|
+
let raw = "";
|
|
135
|
+
try {
|
|
136
|
+
raw = readFileSync(filePath, "utf-8");
|
|
137
|
+
} catch (_) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const line of raw.split("\n")) {
|
|
141
|
+
if (!line) continue;
|
|
142
|
+
let entry;
|
|
143
|
+
try {
|
|
144
|
+
entry = JSON.parse(line);
|
|
145
|
+
} catch (_) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!entry || !entry.event) continue;
|
|
149
|
+
const timestamp = entry.timestamp || "";
|
|
150
|
+
|
|
151
|
+
if (entry.event === "budget_reserved") {
|
|
152
|
+
const record = { timestamp, committed: false, inWindow: inWindow(timestamp) };
|
|
153
|
+
if (entry.reservationId) byId.set(entry.reservationId, { reservationId: entry.reservationId, ...record });
|
|
154
|
+
else anonymous.push({ reservationId: null, ...record });
|
|
155
|
+
} else if (entry.event === "budget_committed") {
|
|
156
|
+
// A commit does not free the slot — the task really ran — it only marks
|
|
157
|
+
// the reservation as having reached the provider.
|
|
158
|
+
const record = entry.reservationId ? byId.get(entry.reservationId) : null;
|
|
159
|
+
if (record) record.committed = true;
|
|
160
|
+
} else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
|
|
161
|
+
if (entry.reservationId) {
|
|
162
|
+
byId.delete(entry.reservationId);
|
|
163
|
+
} else if (entry.releasedTimestamp) {
|
|
164
|
+
const idx = anonymous.findIndex((r) => r.timestamp === entry.releasedTimestamp);
|
|
165
|
+
if (idx !== -1) anonymous.splice(idx, 1);
|
|
166
|
+
} else {
|
|
167
|
+
anonymous.shift();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const open = [...byId.values(), ...anonymous].filter((r) => r.inWindow);
|
|
174
|
+
return {
|
|
175
|
+
used: open.length,
|
|
176
|
+
open: open.map(({ reservationId, timestamp, committed }) => ({ reservationId, timestamp, committed })),
|
|
177
|
+
windowStart: new Date(cutoff).toISOString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
58
181
|
export class MutexTimeoutError extends Error {
|
|
59
182
|
constructor(message = "Failed to acquire VFS mutex lock within timeout") {
|
|
60
183
|
super(message);
|
|
@@ -187,43 +310,24 @@ export function readLedger(filePath) {
|
|
|
187
310
|
}
|
|
188
311
|
}
|
|
189
312
|
|
|
190
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Tasks still counted against the allowance, over the rolling 24-hour window.
|
|
315
|
+
*
|
|
316
|
+
* The name is kept for compatibility; "daily" here means the provider's day,
|
|
317
|
+
* which is the last 24 hours rather than the calendar one.
|
|
318
|
+
*/
|
|
319
|
+
export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300, opts = {}) {
|
|
191
320
|
let root = typeof arg1 === "string" ? arg1 : resolveRoot();
|
|
192
321
|
let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
|
|
193
322
|
|
|
194
|
-
const filePath = getDailyLedgerPath(root);
|
|
195
|
-
if (!existsSync(filePath)) {
|
|
196
|
-
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
197
|
-
}
|
|
198
323
|
try {
|
|
199
|
-
const
|
|
200
|
-
const lines = content.split("\n").filter(Boolean);
|
|
201
|
-
let count = 0;
|
|
202
|
-
const activeIds = new Set();
|
|
203
|
-
for (const line of lines) {
|
|
204
|
-
try {
|
|
205
|
-
const entry = JSON.parse(line);
|
|
206
|
-
if (entry && entry.event === "budget_reserved") {
|
|
207
|
-
if (entry.reservationId) {
|
|
208
|
-
activeIds.add(entry.reservationId);
|
|
209
|
-
} else {
|
|
210
|
-
count++;
|
|
211
|
-
}
|
|
212
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
213
|
-
if (entry.reservationId) {
|
|
214
|
-
activeIds.delete(entry.reservationId);
|
|
215
|
-
} else {
|
|
216
|
-
count = Math.max(0, count - 1);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
} catch (_) {}
|
|
220
|
-
}
|
|
221
|
-
const used = count + activeIds.size;
|
|
324
|
+
const scan = scanBudgetWindow(root, opts);
|
|
222
325
|
return {
|
|
223
|
-
ok: used < limit,
|
|
224
|
-
used,
|
|
326
|
+
ok: scan.used < limit,
|
|
327
|
+
used: scan.used,
|
|
225
328
|
budget: limit,
|
|
226
|
-
remaining: Math.max(0, limit - used),
|
|
329
|
+
remaining: Math.max(0, limit - scan.used),
|
|
330
|
+
windowStart: scan.windowStart,
|
|
227
331
|
};
|
|
228
332
|
} catch (_) {
|
|
229
333
|
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
@@ -244,48 +348,43 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
244
348
|
const mutexDir = join(stateDir, ".budget.mutex");
|
|
245
349
|
|
|
246
350
|
return withVfsMutex(mutexDir, () => {
|
|
247
|
-
const
|
|
351
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
352
|
+
const dateStr = new Date(now).toISOString().split("T")[0];
|
|
248
353
|
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
249
354
|
|
|
250
|
-
|
|
251
|
-
|
|
355
|
+
// The count spans the rolling window; the hash chain does not. A chain is
|
|
356
|
+
// per file, so the new entry links to today's last hash even when the
|
|
357
|
+
// reservations it is counted against were written yesterday.
|
|
358
|
+
const used = scanBudgetWindow(root, { ...opts, now }).used;
|
|
252
359
|
let prevHash = "0".repeat(64);
|
|
253
360
|
|
|
254
361
|
if (existsSync(filePath)) {
|
|
255
362
|
try {
|
|
256
363
|
const raw = readFileSync(filePath, "utf-8");
|
|
257
364
|
const lines = raw.split("\n").filter(Boolean);
|
|
258
|
-
for (
|
|
365
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
259
366
|
try {
|
|
260
|
-
const entry = JSON.parse(
|
|
261
|
-
if (entry && entry.event === "budget_reserved") {
|
|
262
|
-
if (entry.reservationId) {
|
|
263
|
-
activeIds.add(entry.reservationId);
|
|
264
|
-
} else {
|
|
265
|
-
count++;
|
|
266
|
-
}
|
|
267
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
268
|
-
if (entry.reservationId) {
|
|
269
|
-
activeIds.delete(entry.reservationId);
|
|
270
|
-
} else {
|
|
271
|
-
count = Math.max(0, count - 1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
367
|
+
const entry = JSON.parse(lines[i]);
|
|
274
368
|
if (entry && entry.hash) {
|
|
275
369
|
prevHash = entry.hash;
|
|
370
|
+
break;
|
|
276
371
|
}
|
|
277
372
|
} catch (_) {}
|
|
278
373
|
}
|
|
279
374
|
} catch (_) {}
|
|
280
375
|
}
|
|
281
376
|
|
|
282
|
-
|
|
283
|
-
|
|
377
|
+
// `enforce: false` marks a limit the kit only guessed (a tier preset rather
|
|
378
|
+
// than a stated or provider-demonstrated figure). Blocking on a guess would
|
|
379
|
+
// refuse work the provider would happily have accepted, so an uncertain
|
|
380
|
+
// ceiling records the overrun and lets the call through to find out.
|
|
381
|
+
const overLimit = used >= limit;
|
|
382
|
+
if (overLimit && opts.enforce !== false) {
|
|
284
383
|
throw new BudgetError(`Daily budget exhausted (${used}/${limit} tasks executed)`);
|
|
285
384
|
}
|
|
286
385
|
|
|
287
|
-
const timestamp = new Date().toISOString();
|
|
288
|
-
const reservationId = `res-${
|
|
386
|
+
const timestamp = new Date(now).toISOString();
|
|
387
|
+
const reservationId = `res-${now}-${Math.random().toString(36).substring(2, 8)}`;
|
|
289
388
|
const rawPayload = { timestamp, event: "budget_reserved", reservationId, budget: limit, prevHash };
|
|
290
389
|
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
291
390
|
const payload = { ...rawPayload, hash };
|
|
@@ -303,12 +402,13 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
303
402
|
reservationId,
|
|
304
403
|
remaining: Math.max(0, limit - (used + 1)),
|
|
305
404
|
used: used + 1,
|
|
405
|
+
softLimitExceeded: overLimit,
|
|
306
406
|
};
|
|
307
407
|
}, opts);
|
|
308
408
|
}
|
|
309
409
|
|
|
310
|
-
export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
|
|
311
|
-
return reserveBudgetAtomic(rootOrOpts, limit);
|
|
410
|
+
export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300, opts = {}) {
|
|
411
|
+
return reserveBudgetAtomic(rootOrOpts, limit, opts);
|
|
312
412
|
}
|
|
313
413
|
|
|
314
414
|
export function commitBudgetReservation(rootOrOpts = resolveRoot(), reservationId = "") {
|
|
@@ -321,8 +421,13 @@ export function rollbackBudgetReservation(rootOrOpts = resolveRoot(), reservatio
|
|
|
321
421
|
return appendLedger({ event: "budget_rolled_back", reservationId }, root);
|
|
322
422
|
}
|
|
323
423
|
|
|
324
|
-
|
|
325
|
-
|
|
424
|
+
/**
|
|
425
|
+
* @param {object} [opts]
|
|
426
|
+
* @param {boolean} [opts.enforce=true] - Pass false when `limit` is an estimate
|
|
427
|
+
* rather than a known allowance; the overrun is then recorded, not blocked.
|
|
428
|
+
*/
|
|
429
|
+
export async function withBudget(fn, root = resolveRoot(), limit = 300, opts = {}) {
|
|
430
|
+
const reservation = reserveBudget(root, limit, opts);
|
|
326
431
|
try {
|
|
327
432
|
const result = await fn();
|
|
328
433
|
commitBudgetReservation(root, reservation.reservationId);
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The kit version, read once from package.json.
|
|
6
|
+
*
|
|
7
|
+
* Every module that needed to name the version used to hardcode it, and they
|
|
8
|
+
* drifted: the CLI banner said 0.32.8 while the MCP server, the dashboard and
|
|
9
|
+
* the config the wizard scaffolded all still claimed 0.29.x. Reading the
|
|
10
|
+
* manifest is the only way the number cannot go stale.
|
|
11
|
+
*
|
|
12
|
+
* fileURLToPath keeps this correct on Windows, where a file:// URL pathname
|
|
13
|
+
* starts with a drive-letter slash that fs cannot open.
|
|
14
|
+
*/
|
|
15
|
+
function readKitVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
19
|
+
return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
|
|
20
|
+
} catch (_) {
|
|
21
|
+
// A consumer may vendor src/ without the manifest; a placeholder is better
|
|
22
|
+
// than an import-time crash in a library whose whole job is running gates.
|
|
23
|
+
return "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const KIT_VERSION = readKitVersion();
|
package/src/wizard-init.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { parseYaml } from "./config.mjs";
|
|
3
|
+
import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
|
|
4
4
|
import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
|
|
5
5
|
import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
|
|
6
|
+
import { KIT_VERSION } from "./version.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Write a file atomically using a temporary file and atomic rename.
|
|
@@ -21,27 +22,57 @@ function writeAtomic(filePath, content) {
|
|
|
21
22
|
renameSync(tmpPath, filePath);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Snake_case projection of {@link TIER_PRESETS} for the YAML the wizard writes.
|
|
27
|
+
* Derived rather than declared: as a second literal table it drifted out of sync
|
|
28
|
+
* with the runtime and scaffolded configs with limits nothing else agreed on.
|
|
29
|
+
*/
|
|
30
|
+
export const TIER_PROFILES = Object.fromEntries(
|
|
31
|
+
Object.entries(TIER_PRESETS).map(([name, p]) => [
|
|
32
|
+
name,
|
|
33
|
+
{
|
|
34
|
+
concurrency: p.concurrency,
|
|
35
|
+
daily_tasks: p.dailyTasks,
|
|
36
|
+
stagger_ms: p.staggerMs,
|
|
37
|
+
diff_kb: p.diffKb,
|
|
38
|
+
},
|
|
39
|
+
])
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const TIER_LABELS = {
|
|
43
|
+
free: "Free",
|
|
44
|
+
pro: "Pro",
|
|
45
|
+
ultra: "Ultra",
|
|
46
|
+
enterprise: "Custom / self-hosted pool",
|
|
43
47
|
};
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Build the tier menu from {@link TIER_PRESETS} so the prompt text cannot drift
|
|
51
|
+
* from the limits actually written. The hardcoded descriptions it replaces
|
|
52
|
+
* advertised numbers no tier had, and omitted `ultra` entirely.
|
|
53
|
+
*
|
|
54
|
+
* No option is marked "recommended": picking a plan the account does not have
|
|
55
|
+
* is precisely how the budget ends up guarding the wrong ceiling.
|
|
56
|
+
* @returns {Array<{ label: string, value: string, description: string }>}
|
|
57
|
+
*/
|
|
58
|
+
export function tierOptions() {
|
|
59
|
+
const order = [...VENDOR_TIERS, ...Object.keys(TIER_PRESETS).filter((t) => !VENDOR_TIERS.includes(t))];
|
|
60
|
+
return order.map((name) => {
|
|
61
|
+
const p = TIER_PRESETS[name];
|
|
62
|
+
const worker = p.concurrency === 1 ? "worker" : "workers";
|
|
63
|
+
// The ceiling is shown next to the default so the number the wizard writes
|
|
64
|
+
// reads as a starting point rather than as the plan's limit.
|
|
65
|
+
const slots = p.maxConcurrency > p.concurrency
|
|
66
|
+
? `${p.concurrency} ${worker} (plan allows ${p.maxConcurrency})`
|
|
67
|
+
: `${p.concurrency} ${worker}`;
|
|
68
|
+
return {
|
|
69
|
+
label: TIER_LABELS[name] || name,
|
|
70
|
+
value: name,
|
|
71
|
+
description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
export const BUILTIN_PRESETS = [
|
|
46
77
|
{
|
|
47
78
|
id: "nightly-security-audit",
|
|
@@ -86,7 +117,9 @@ export const BUILTIN_PRESETS = [
|
|
|
86
117
|
export function planInit(root = process.cwd(), options = {}) {
|
|
87
118
|
const oracle = detectStackOracles(root);
|
|
88
119
|
const tierName = options.tier || "pro";
|
|
89
|
-
|
|
120
|
+
// An unrecognised name resolves the same way loadConfig() resolves it, so the
|
|
121
|
+
// scaffolded limits always match what the runtime will later enforce.
|
|
122
|
+
const limits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
|
|
90
123
|
|
|
91
124
|
// Preserve existing config if present
|
|
92
125
|
let existingConfig = {};
|
|
@@ -106,7 +139,7 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
106
139
|
|
|
107
140
|
const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
|
|
108
141
|
|
|
109
|
-
const configYaml = `# Google Jules Orchestrator Kit Config (
|
|
142
|
+
const configYaml = `# Google Jules Orchestrator Kit Config (v${KIT_VERSION})
|
|
110
143
|
version: 1
|
|
111
144
|
provider: ${existingConfig.provider || "jules"}
|
|
112
145
|
tier: ${tierName}
|
|
@@ -203,12 +236,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
|
|
|
203
236
|
sp.stop(`Detected Stack: ${oracle.stack}`);
|
|
204
237
|
|
|
205
238
|
selectedTier = await select(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
{ label: "Free / Individual Tier", value: "free", description: "1 worker, 30 daily tasks, 50 KB diff limit" },
|
|
209
|
-
{ label: "Custom Enterprise", value: "enterprise", description: "10 workers, 1000 daily tasks, 100 KB diff limit" },
|
|
210
|
-
],
|
|
211
|
-
"Select Jules Orchestrator Usage Tier",
|
|
239
|
+
tierOptions(),
|
|
240
|
+
"Which plan does your Jules account use? (limits are adjustable later)",
|
|
212
241
|
options
|
|
213
242
|
);
|
|
214
243
|
|