sentinelayer-cli 0.1.2 → 0.4.4
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 +998 -996
- package/bin/create-sentinelayer.js +5 -5
- package/bin/sentinelayer-cli.js +4 -4
- package/bin/sl.js +5 -5
- package/package.json +63 -54
- package/src/agents/jules/config/definition.js +209 -209
- package/src/agents/jules/config/system-prompt.js +175 -175
- package/src/agents/jules/error-intake.js +51 -51
- package/src/agents/jules/fix-cycle.js +377 -377
- package/src/agents/jules/loop.js +367 -367
- package/src/agents/jules/pulse.js +327 -319
- package/src/agents/jules/stream.js +186 -186
- package/src/agents/jules/swarm/file-scanner.js +74 -74
- package/src/agents/jules/swarm/index.js +11 -11
- package/src/agents/jules/swarm/orchestrator.js +362 -362
- package/src/agents/jules/swarm/pattern-hunter.js +123 -123
- package/src/agents/jules/swarm/sub-agent.js +308 -308
- package/src/agents/jules/tools/auth-audit.js +557 -222
- package/src/agents/jules/tools/dispatch.js +327 -327
- package/src/agents/jules/tools/file-edit.js +180 -180
- package/src/agents/jules/tools/file-read.js +100 -100
- package/src/agents/jules/tools/frontend-analyze.js +570 -570
- package/src/agents/jules/tools/glob.js +168 -168
- package/src/agents/jules/tools/grep.js +228 -228
- package/src/agents/jules/tools/index.js +29 -29
- package/src/agents/jules/tools/path-guards.js +161 -161
- package/src/agents/jules/tools/runtime-audit.js +503 -493
- package/src/agents/jules/tools/shell.js +383 -383
- package/src/agents/jules/tools/url-policy.js +100 -0
- package/src/ai/aidenid.js +972 -945
- package/src/ai/client.js +508 -508
- package/src/ai/domain-target-store.js +268 -268
- package/src/ai/identity-store.js +270 -270
- package/src/ai/site-store.js +145 -145
- package/src/audit/agents/architecture.js +180 -180
- package/src/audit/agents/compliance.js +179 -179
- package/src/audit/agents/documentation.js +165 -165
- package/src/audit/agents/performance.js +145 -145
- package/src/audit/agents/security.js +215 -215
- package/src/audit/agents/testing.js +172 -172
- package/src/audit/orchestrator.js +557 -557
- package/src/audit/package.js +204 -204
- package/src/audit/registry.js +284 -284
- package/src/audit/replay.js +103 -103
- package/src/auth/gate.js +45 -11
- package/src/auth/http.js +270 -113
- package/src/auth/service.js +891 -848
- package/src/auth/session-store.js +359 -345
- package/src/cli.js +252 -252
- package/src/commands/ai/identity-lifecycle.js +1338 -1337
- package/src/commands/ai/provision-governance.js +1272 -1246
- package/src/commands/ai/shared.js +147 -147
- package/src/commands/ai.js +11 -11
- package/src/commands/apply.js +12 -12
- package/src/commands/audit.js +1166 -1166
- package/src/commands/auth.js +375 -366
- package/src/commands/chat.js +191 -191
- package/src/commands/config.js +184 -184
- package/src/commands/cost.js +311 -311
- package/src/commands/daemon/core.js +850 -850
- package/src/commands/daemon/extended.js +1048 -1048
- package/src/commands/daemon/shared.js +213 -213
- package/src/commands/daemon.js +11 -11
- package/src/commands/guide.js +174 -174
- package/src/commands/ingest.js +58 -58
- package/src/commands/init.js +55 -55
- package/src/commands/legacy-args.js +10 -10
- package/src/commands/mcp.js +461 -404
- package/src/commands/omargate.js +15 -15
- package/src/commands/persona.js +20 -20
- package/src/commands/plugin.js +260 -260
- package/src/commands/policy.js +132 -132
- package/src/commands/prompt.js +238 -238
- package/src/commands/review.js +704 -704
- package/src/commands/scan.js +866 -788
- package/src/commands/spec.js +716 -716
- package/src/commands/swarm.js +651 -651
- package/src/commands/telemetry.js +202 -202
- package/src/commands/watch.js +510 -510
- package/src/config/agent-dictionary.js +182 -182
- package/src/config/io.js +56 -56
- package/src/config/paths.js +18 -18
- package/src/config/schema.js +55 -55
- package/src/config/service.js +184 -184
- package/src/cost/budget.js +235 -235
- package/src/cost/history.js +188 -188
- package/src/cost/tracker.js +171 -171
- package/src/daemon/artifact-lineage.js +534 -534
- package/src/daemon/assignment-ledger.js +770 -770
- package/src/daemon/ast-parser-layer.js +258 -258
- package/src/daemon/budget-governor.js +633 -633
- package/src/daemon/callgraph-overlay.js +646 -646
- package/src/daemon/error-worker.js +626 -626
- package/src/daemon/hybrid-mapper.js +929 -929
- package/src/daemon/jira-lifecycle.js +632 -632
- package/src/daemon/operator-control.js +657 -657
- package/src/daemon/reliability-lane.js +471 -471
- package/src/daemon/watchdog.js +971 -971
- package/src/guide/generator.js +316 -316
- package/src/ingest/engine.js +918 -918
- package/src/legacy-cli.js +2592 -2435
- package/src/mcp/registry.js +695 -695
- package/src/memory/blackboard.js +301 -301
- package/src/memory/retrieval.js +581 -581
- package/src/plugin/manifest.js +553 -553
- package/src/policy/packs.js +144 -144
- package/src/prompt/generator.js +118 -106
- package/src/review/ai-review.js +669 -669
- package/src/review/local-review.js +1295 -1284
- package/src/review/replay.js +235 -235
- package/src/review/report.js +664 -664
- package/src/review/spec-binding.js +487 -487
- package/src/scaffold/generator.js +67 -0
- package/src/scaffold/templates.js +150 -0
- package/src/scan/generator.js +418 -351
- package/src/scan/gh-secrets.js +107 -0
- package/src/spec/generator.js +519 -519
- package/src/spec/regenerate.js +237 -237
- package/src/spec/templates.js +91 -91
- package/src/swarm/dashboard.js +247 -247
- package/src/swarm/factory.js +363 -363
- package/src/swarm/pentest.js +934 -934
- package/src/swarm/registry.js +419 -419
- package/src/swarm/report.js +158 -158
- package/src/swarm/runtime.js +576 -576
- package/src/swarm/scenario-dsl.js +272 -272
- package/src/telemetry/ledger.js +302 -302
- package/src/telemetry/sync.js +107 -61
- package/src/ui/markdown.js +220 -220
|
@@ -1,633 +1,633 @@
|
|
|
1
|
-
import fsp from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { resolveAssignmentLedgerStorage } from "./assignment-ledger.js";
|
|
5
|
-
import { resolveErrorDaemonStorage } from "./error-worker.js";
|
|
6
|
-
|
|
7
|
-
const BUDGET_STATE_SCHEMA_VERSION = "1.0.0";
|
|
8
|
-
const QUEUE_SCHEMA_VERSION = "1.0.0";
|
|
9
|
-
|
|
10
|
-
export const DAEMON_BUDGET_LIFECYCLE_STATES = Object.freeze([
|
|
11
|
-
"WITHIN_BUDGET",
|
|
12
|
-
"WARNING_THRESHOLD",
|
|
13
|
-
"HARD_LIMIT_QUARANTINED",
|
|
14
|
-
"HARD_LIMIT_SQUASHED",
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
function normalizeString(value) {
|
|
18
|
-
return String(value || "").trim();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
|
|
22
|
-
const normalized = normalizeString(value);
|
|
23
|
-
if (!normalized) {
|
|
24
|
-
return fallbackIso;
|
|
25
|
-
}
|
|
26
|
-
const epoch = Date.parse(normalized);
|
|
27
|
-
if (!Number.isFinite(epoch)) {
|
|
28
|
-
return fallbackIso;
|
|
29
|
-
}
|
|
30
|
-
return new Date(epoch).toISOString();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function normalizeNonNegativeNumber(value, fieldName) {
|
|
34
|
-
const normalized = Number(value || 0);
|
|
35
|
-
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
36
|
-
throw new Error(`${fieldName} must be a non-negative number.`);
|
|
37
|
-
}
|
|
38
|
-
return normalized;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalizePositiveInteger(value, fieldName, fallbackValue) {
|
|
42
|
-
if (value === undefined || value === null || normalizeString(value) === "") {
|
|
43
|
-
return fallbackValue;
|
|
44
|
-
}
|
|
45
|
-
const normalized = Number(value);
|
|
46
|
-
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
47
|
-
throw new Error(`${fieldName} must be a positive integer.`);
|
|
48
|
-
}
|
|
49
|
-
return Math.floor(normalized);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normalizeWarningThresholdPercent(value, fallbackValue = 80) {
|
|
53
|
-
const normalized = Number(value ?? fallbackValue);
|
|
54
|
-
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 100) {
|
|
55
|
-
throw new Error("warningThresholdPercent must be between 0 and 100.");
|
|
56
|
-
}
|
|
57
|
-
return normalized;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function normalizeBudgetEnvelope(envelope = {}) {
|
|
61
|
-
return {
|
|
62
|
-
maxTokens: normalizeNonNegativeNumber(envelope.maxTokens ?? 0, "maxTokens"),
|
|
63
|
-
maxCostUsd: normalizeNonNegativeNumber(envelope.maxCostUsd ?? 0, "maxCostUsd"),
|
|
64
|
-
maxRuntimeMs: normalizeNonNegativeNumber(envelope.maxRuntimeMs ?? 0, "maxRuntimeMs"),
|
|
65
|
-
maxToolCalls: normalizeNonNegativeNumber(envelope.maxToolCalls ?? 0, "maxToolCalls"),
|
|
66
|
-
maxPathViolations: normalizePositiveInteger(
|
|
67
|
-
envelope.maxPathViolations ?? 1,
|
|
68
|
-
"maxPathViolations",
|
|
69
|
-
1
|
|
70
|
-
),
|
|
71
|
-
maxNetworkViolations: normalizePositiveInteger(
|
|
72
|
-
envelope.maxNetworkViolations ?? 1,
|
|
73
|
-
"maxNetworkViolations",
|
|
74
|
-
1
|
|
75
|
-
),
|
|
76
|
-
warningThresholdPercent: normalizeWarningThresholdPercent(
|
|
77
|
-
envelope.warningThresholdPercent,
|
|
78
|
-
80
|
|
79
|
-
),
|
|
80
|
-
quarantineGraceSeconds: normalizePositiveInteger(
|
|
81
|
-
envelope.quarantineGraceSeconds ?? 30,
|
|
82
|
-
"quarantineGraceSeconds",
|
|
83
|
-
30
|
|
84
|
-
),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function normalizeUsageSnapshot(usage = {}) {
|
|
89
|
-
return {
|
|
90
|
-
tokensUsed: normalizeNonNegativeNumber(usage.tokensUsed ?? 0, "tokensUsed"),
|
|
91
|
-
costUsd: normalizeNonNegativeNumber(usage.costUsd ?? 0, "costUsd"),
|
|
92
|
-
runtimeMs: normalizeNonNegativeNumber(usage.runtimeMs ?? 0, "runtimeMs"),
|
|
93
|
-
toolCalls: normalizeNonNegativeNumber(usage.toolCalls ?? 0, "toolCalls"),
|
|
94
|
-
pathOutOfScopeHits: normalizeNonNegativeNumber(
|
|
95
|
-
usage.pathOutOfScopeHits ?? 0,
|
|
96
|
-
"pathOutOfScopeHits"
|
|
97
|
-
),
|
|
98
|
-
networkDomainViolations: normalizeNonNegativeNumber(
|
|
99
|
-
usage.networkDomainViolations ?? 0,
|
|
100
|
-
"networkDomainViolations"
|
|
101
|
-
),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function evaluateThreshold({
|
|
106
|
-
usageValue,
|
|
107
|
-
maxValue,
|
|
108
|
-
warningThresholdPercent,
|
|
109
|
-
stopCode,
|
|
110
|
-
warningCode,
|
|
111
|
-
stopMessage,
|
|
112
|
-
warningMessage,
|
|
113
|
-
warnings,
|
|
114
|
-
stopReasons,
|
|
115
|
-
} = {}) {
|
|
116
|
-
const usage = Number(usageValue || 0);
|
|
117
|
-
const limit = Number(maxValue || 0);
|
|
118
|
-
if (!Number.isFinite(limit) || limit <= 0) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (usage > limit) {
|
|
122
|
-
stopReasons.push({
|
|
123
|
-
code: stopCode,
|
|
124
|
-
message: stopMessage(usage, limit),
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (warningThresholdPercent <= 0) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const threshold = (warningThresholdPercent / 100) * limit;
|
|
132
|
-
if (usage >= threshold) {
|
|
133
|
-
warnings.push({
|
|
134
|
-
code: warningCode,
|
|
135
|
-
message: warningMessage(usage, limit, warningThresholdPercent),
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function addSeconds(isoTimestamp, seconds) {
|
|
141
|
-
const baseEpoch = Date.parse(isoTimestamp) || Date.now();
|
|
142
|
-
return new Date(baseEpoch + seconds * 1000).toISOString();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function createInitialBudgetState(nowIso = new Date().toISOString()) {
|
|
146
|
-
return {
|
|
147
|
-
schemaVersion: BUDGET_STATE_SCHEMA_VERSION,
|
|
148
|
-
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
149
|
-
records: [],
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function normalizeBudgetRecord(record = {}, nowIso = new Date().toISOString()) {
|
|
154
|
-
return {
|
|
155
|
-
workItemId: normalizeString(record.workItemId),
|
|
156
|
-
lifecycleState: normalizeString(record.lifecycleState) || "WITHIN_BUDGET",
|
|
157
|
-
lastAction: normalizeString(record.lastAction) || "NONE",
|
|
158
|
-
updatedAt: normalizeIsoTimestamp(record.updatedAt, nowIso),
|
|
159
|
-
quarantineStartedAt: record.quarantineStartedAt
|
|
160
|
-
? normalizeIsoTimestamp(record.quarantineStartedAt, nowIso)
|
|
161
|
-
: null,
|
|
162
|
-
quarantineUntil: record.quarantineUntil ? normalizeIsoTimestamp(record.quarantineUntil, nowIso) : null,
|
|
163
|
-
warnings: Array.isArray(record.warnings) ? record.warnings : [],
|
|
164
|
-
stopReasons: Array.isArray(record.stopReasons) ? record.stopReasons : [],
|
|
165
|
-
budget: record.budget && typeof record.budget === "object" ? record.budget : {},
|
|
166
|
-
usage: record.usage && typeof record.usage === "object" ? record.usage : {},
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function loadJsonFile(filePath, defaultFactory) {
|
|
171
|
-
try {
|
|
172
|
-
const raw = await fsp.readFile(filePath, "utf-8");
|
|
173
|
-
return JSON.parse(raw);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
176
|
-
return defaultFactory();
|
|
177
|
-
}
|
|
178
|
-
throw error;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function writeJsonFile(filePath, payload = {}) {
|
|
183
|
-
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
184
|
-
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function appendEvent(filePath, payload = {}) {
|
|
188
|
-
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
189
|
-
await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function loadQueue(queuePath, nowIso = new Date().toISOString()) {
|
|
193
|
-
const parsed = await loadJsonFile(queuePath, () => ({
|
|
194
|
-
schemaVersion: QUEUE_SCHEMA_VERSION,
|
|
195
|
-
generatedAt: nowIso,
|
|
196
|
-
items: [],
|
|
197
|
-
}));
|
|
198
|
-
return {
|
|
199
|
-
schemaVersion: normalizeString(parsed.schemaVersion) || QUEUE_SCHEMA_VERSION,
|
|
200
|
-
generatedAt: normalizeIsoTimestamp(parsed.generatedAt, nowIso),
|
|
201
|
-
items: Array.isArray(parsed.items) ? parsed.items : [],
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async function writeQueue(queuePath, queue = {}, nowIso = new Date().toISOString()) {
|
|
206
|
-
await writeJsonFile(queuePath, {
|
|
207
|
-
schemaVersion: QUEUE_SCHEMA_VERSION,
|
|
208
|
-
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
209
|
-
items: Array.isArray(queue.items) ? queue.items : [],
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function loadAssignmentLedger(ledgerPath, nowIso = new Date().toISOString()) {
|
|
214
|
-
return loadJsonFile(ledgerPath, () => ({
|
|
215
|
-
schemaVersion: "1.0.0",
|
|
216
|
-
generatedAt: nowIso,
|
|
217
|
-
assignments: [],
|
|
218
|
-
}));
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function writeAssignmentLedger(ledgerPath, ledger = {}, nowIso = new Date().toISOString()) {
|
|
222
|
-
await writeJsonFile(ledgerPath, {
|
|
223
|
-
...ledger,
|
|
224
|
-
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
225
|
-
assignments: Array.isArray(ledger.assignments) ? ledger.assignments : [],
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function evaluateDaemonBudget({
|
|
230
|
-
budget = {},
|
|
231
|
-
usage = {},
|
|
232
|
-
previousRecord = null,
|
|
233
|
-
nowIso = new Date().toISOString(),
|
|
234
|
-
} = {}) {
|
|
235
|
-
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
236
|
-
const normalizedBudget = normalizeBudgetEnvelope(budget);
|
|
237
|
-
const normalizedUsage = normalizeUsageSnapshot(usage);
|
|
238
|
-
|
|
239
|
-
const warnings = [];
|
|
240
|
-
const stopReasons = [];
|
|
241
|
-
evaluateThreshold({
|
|
242
|
-
usageValue: normalizedUsage.tokensUsed,
|
|
243
|
-
maxValue: normalizedBudget.maxTokens,
|
|
244
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
245
|
-
stopCode: "MAX_TOKENS_EXCEEDED",
|
|
246
|
-
warningCode: "TOKENS_NEAR_LIMIT",
|
|
247
|
-
stopMessage: (used, limit) => `Token budget exceeded (${used} > ${limit}).`,
|
|
248
|
-
warningMessage: (used, limit, threshold) =>
|
|
249
|
-
`Token usage near limit (${used}/${limit} at ${threshold}%).`,
|
|
250
|
-
warnings,
|
|
251
|
-
stopReasons,
|
|
252
|
-
});
|
|
253
|
-
evaluateThreshold({
|
|
254
|
-
usageValue: normalizedUsage.costUsd,
|
|
255
|
-
maxValue: normalizedBudget.maxCostUsd,
|
|
256
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
257
|
-
stopCode: "MAX_COST_USD_EXCEEDED",
|
|
258
|
-
warningCode: "COST_NEAR_LIMIT",
|
|
259
|
-
stopMessage: (used, limit) => `Cost budget exceeded (${used.toFixed(6)} > ${limit.toFixed(6)}).`,
|
|
260
|
-
warningMessage: (used, limit, threshold) =>
|
|
261
|
-
`Cost usage near limit (${used.toFixed(6)}/${limit.toFixed(6)} at ${threshold}%).`,
|
|
262
|
-
warnings,
|
|
263
|
-
stopReasons,
|
|
264
|
-
});
|
|
265
|
-
evaluateThreshold({
|
|
266
|
-
usageValue: normalizedUsage.runtimeMs,
|
|
267
|
-
maxValue: normalizedBudget.maxRuntimeMs,
|
|
268
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
269
|
-
stopCode: "MAX_RUNTIME_MS_EXCEEDED",
|
|
270
|
-
warningCode: "RUNTIME_MS_NEAR_LIMIT",
|
|
271
|
-
stopMessage: (used, limit) => `Runtime budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
272
|
-
warningMessage: (used, limit, threshold) =>
|
|
273
|
-
`Runtime usage near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
274
|
-
warnings,
|
|
275
|
-
stopReasons,
|
|
276
|
-
});
|
|
277
|
-
evaluateThreshold({
|
|
278
|
-
usageValue: normalizedUsage.toolCalls,
|
|
279
|
-
maxValue: normalizedBudget.maxToolCalls,
|
|
280
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
281
|
-
stopCode: "MAX_TOOL_CALLS_EXCEEDED",
|
|
282
|
-
warningCode: "TOOL_CALLS_NEAR_LIMIT",
|
|
283
|
-
stopMessage: (used, limit) => `Tool-call budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
284
|
-
warningMessage: (used, limit, threshold) =>
|
|
285
|
-
`Tool-call usage near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
286
|
-
warnings,
|
|
287
|
-
stopReasons,
|
|
288
|
-
});
|
|
289
|
-
evaluateThreshold({
|
|
290
|
-
usageValue: normalizedUsage.pathOutOfScopeHits,
|
|
291
|
-
maxValue: normalizedBudget.maxPathViolations,
|
|
292
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
293
|
-
stopCode: "MAX_PATH_VIOLATIONS_EXCEEDED",
|
|
294
|
-
warningCode: "PATH_VIOLATIONS_NEAR_LIMIT",
|
|
295
|
-
stopMessage: (used, limit) =>
|
|
296
|
-
`Path-scope violation budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
297
|
-
warningMessage: (used, limit, threshold) =>
|
|
298
|
-
`Path-scope violations near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
299
|
-
warnings,
|
|
300
|
-
stopReasons,
|
|
301
|
-
});
|
|
302
|
-
evaluateThreshold({
|
|
303
|
-
usageValue: normalizedUsage.networkDomainViolations,
|
|
304
|
-
maxValue: normalizedBudget.maxNetworkViolations,
|
|
305
|
-
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
306
|
-
stopCode: "MAX_NETWORK_VIOLATIONS_EXCEEDED",
|
|
307
|
-
warningCode: "NETWORK_VIOLATIONS_NEAR_LIMIT",
|
|
308
|
-
stopMessage: (used, limit) =>
|
|
309
|
-
`Network-domain violation budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
310
|
-
warningMessage: (used, limit, threshold) =>
|
|
311
|
-
`Network-domain violations near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
312
|
-
warnings,
|
|
313
|
-
stopReasons,
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
let lifecycleState;
|
|
317
|
-
let action;
|
|
318
|
-
let quarantineStartedAt = previousRecord?.quarantineStartedAt || null;
|
|
319
|
-
let quarantineUntil = previousRecord?.quarantineUntil || null;
|
|
320
|
-
|
|
321
|
-
if (stopReasons.length > 0) {
|
|
322
|
-
lifecycleState = "HARD_LIMIT_QUARANTINED";
|
|
323
|
-
if (!quarantineStartedAt || !quarantineUntil) {
|
|
324
|
-
quarantineStartedAt = normalizedNow;
|
|
325
|
-
quarantineUntil = addSeconds(normalizedNow, normalizedBudget.quarantineGraceSeconds);
|
|
326
|
-
action = "QUARANTINE";
|
|
327
|
-
} else {
|
|
328
|
-
const nowEpoch = Date.parse(normalizedNow);
|
|
329
|
-
const quarantineUntilEpoch = Date.parse(quarantineUntil);
|
|
330
|
-
if (Number.isFinite(quarantineUntilEpoch) && nowEpoch >= quarantineUntilEpoch) {
|
|
331
|
-
lifecycleState = "HARD_LIMIT_SQUASHED";
|
|
332
|
-
action = "KILL";
|
|
333
|
-
} else {
|
|
334
|
-
action = "QUARANTINE";
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
} else if (warnings.length > 0) {
|
|
338
|
-
lifecycleState = "WARNING_THRESHOLD";
|
|
339
|
-
action = "NONE";
|
|
340
|
-
quarantineStartedAt = null;
|
|
341
|
-
quarantineUntil = null;
|
|
342
|
-
} else {
|
|
343
|
-
lifecycleState = "WITHIN_BUDGET";
|
|
344
|
-
action = "NONE";
|
|
345
|
-
quarantineStartedAt = null;
|
|
346
|
-
quarantineUntil = null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return {
|
|
350
|
-
lifecycleState,
|
|
351
|
-
action,
|
|
352
|
-
updatedAt: normalizedNow,
|
|
353
|
-
quarantineStartedAt,
|
|
354
|
-
quarantineUntil,
|
|
355
|
-
warnings,
|
|
356
|
-
stopReasons,
|
|
357
|
-
budget: normalizedBudget,
|
|
358
|
-
usage: normalizedUsage,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
export async function resolveBudgetGovernorStorage({
|
|
363
|
-
targetPath = ".",
|
|
364
|
-
outputDir = "",
|
|
365
|
-
env,
|
|
366
|
-
homeDir,
|
|
367
|
-
} = {}) {
|
|
368
|
-
const daemonStorage = await resolveErrorDaemonStorage({
|
|
369
|
-
targetPath,
|
|
370
|
-
outputDir,
|
|
371
|
-
env,
|
|
372
|
-
homeDir,
|
|
373
|
-
});
|
|
374
|
-
return {
|
|
375
|
-
...daemonStorage,
|
|
376
|
-
budgetStatePath: path.join(daemonStorage.baseDir, "budget-state.json"),
|
|
377
|
-
budgetEventsPath: path.join(daemonStorage.baseDir, "budget-events.ndjson"),
|
|
378
|
-
budgetRunsDir: path.join(daemonStorage.baseDir, "budget-runs"),
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
async function loadBudgetState(filePath, nowIso = new Date().toISOString()) {
|
|
383
|
-
const parsed = await loadJsonFile(filePath, () => createInitialBudgetState(nowIso));
|
|
384
|
-
return {
|
|
385
|
-
schemaVersion: normalizeString(parsed.schemaVersion) || BUDGET_STATE_SCHEMA_VERSION,
|
|
386
|
-
generatedAt: normalizeIsoTimestamp(parsed.generatedAt, nowIso),
|
|
387
|
-
records: Array.isArray(parsed.records)
|
|
388
|
-
? parsed.records
|
|
389
|
-
.map((record) => normalizeBudgetRecord(record, nowIso))
|
|
390
|
-
.filter((record) => record.workItemId)
|
|
391
|
-
: [],
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
async function writeBudgetState(filePath, state = {}, nowIso = new Date().toISOString()) {
|
|
396
|
-
const normalized = {
|
|
397
|
-
schemaVersion: BUDGET_STATE_SCHEMA_VERSION,
|
|
398
|
-
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
399
|
-
records: Array.isArray(state.records)
|
|
400
|
-
? state.records
|
|
401
|
-
.map((record) => normalizeBudgetRecord(record, nowIso))
|
|
402
|
-
.filter((record) => record.workItemId)
|
|
403
|
-
: [],
|
|
404
|
-
};
|
|
405
|
-
await writeJsonFile(filePath, normalized);
|
|
406
|
-
return normalized;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function applyQueueAndAssignmentStatus({
|
|
410
|
-
queue,
|
|
411
|
-
assignmentLedger,
|
|
412
|
-
workItemId,
|
|
413
|
-
status,
|
|
414
|
-
reason,
|
|
415
|
-
nowIso,
|
|
416
|
-
} = {}) {
|
|
417
|
-
const queueIndex = queue.items.findIndex((item) => normalizeString(item.workItemId) === workItemId);
|
|
418
|
-
if (queueIndex >= 0) {
|
|
419
|
-
queue.items[queueIndex] = {
|
|
420
|
-
...queue.items[queueIndex],
|
|
421
|
-
status,
|
|
422
|
-
updatedAt: nowIso,
|
|
423
|
-
metadata: {
|
|
424
|
-
...(queue.items[queueIndex].metadata && typeof queue.items[queueIndex].metadata === "object"
|
|
425
|
-
? queue.items[queueIndex].metadata
|
|
426
|
-
: {}),
|
|
427
|
-
budgetGovernorStatus: status,
|
|
428
|
-
budgetGovernorReason: reason || null,
|
|
429
|
-
},
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (assignmentLedger && Array.isArray(assignmentLedger.assignments)) {
|
|
434
|
-
const assignmentIndex = assignmentLedger.assignments.findIndex(
|
|
435
|
-
(assignment) => normalizeString(assignment.workItemId) === workItemId
|
|
436
|
-
);
|
|
437
|
-
if (assignmentIndex >= 0) {
|
|
438
|
-
assignmentLedger.assignments[assignmentIndex] = {
|
|
439
|
-
...assignmentLedger.assignments[assignmentIndex],
|
|
440
|
-
status,
|
|
441
|
-
releasedAt: nowIso,
|
|
442
|
-
releaseReason: reason || assignmentLedger.assignments[assignmentIndex].releaseReason || null,
|
|
443
|
-
updatedAt: nowIso,
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
export async function applyDaemonBudgetCheck({
|
|
450
|
-
targetPath = ".",
|
|
451
|
-
outputDir = "",
|
|
452
|
-
workItemId,
|
|
453
|
-
budget = {},
|
|
454
|
-
usage = {},
|
|
455
|
-
env,
|
|
456
|
-
homeDir,
|
|
457
|
-
nowIso = new Date().toISOString(),
|
|
458
|
-
} = {}) {
|
|
459
|
-
const normalizedWorkItemId = normalizeString(workItemId);
|
|
460
|
-
if (!normalizedWorkItemId) {
|
|
461
|
-
throw new Error("workItemId is required.");
|
|
462
|
-
}
|
|
463
|
-
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
464
|
-
const storage = await resolveBudgetGovernorStorage({
|
|
465
|
-
targetPath,
|
|
466
|
-
outputDir,
|
|
467
|
-
env,
|
|
468
|
-
homeDir,
|
|
469
|
-
});
|
|
470
|
-
const assignmentStorage = await resolveAssignmentLedgerStorage({
|
|
471
|
-
targetPath,
|
|
472
|
-
outputDir,
|
|
473
|
-
env,
|
|
474
|
-
homeDir,
|
|
475
|
-
});
|
|
476
|
-
const [budgetState, queue, assignmentLedger] = await Promise.all([
|
|
477
|
-
loadBudgetState(storage.budgetStatePath, normalizedNow),
|
|
478
|
-
loadQueue(storage.queuePath, normalizedNow),
|
|
479
|
-
loadAssignmentLedger(assignmentStorage.ledgerPath, normalizedNow),
|
|
480
|
-
]);
|
|
481
|
-
|
|
482
|
-
const queueItem = queue.items.find((item) => normalizeString(item.workItemId) === normalizedWorkItemId);
|
|
483
|
-
if (!queueItem) {
|
|
484
|
-
throw new Error(`Work item '${normalizedWorkItemId}' was not found in daemon queue.`);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const recordIndex = budgetState.records.findIndex((record) => record.workItemId === normalizedWorkItemId);
|
|
488
|
-
const previousRecord = recordIndex >= 0 ? budgetState.records[recordIndex] : null;
|
|
489
|
-
const evaluation = evaluateDaemonBudget({
|
|
490
|
-
budget,
|
|
491
|
-
usage,
|
|
492
|
-
previousRecord,
|
|
493
|
-
nowIso: normalizedNow,
|
|
494
|
-
});
|
|
495
|
-
const nextRecord = normalizeBudgetRecord(
|
|
496
|
-
{
|
|
497
|
-
workItemId: normalizedWorkItemId,
|
|
498
|
-
...evaluation,
|
|
499
|
-
lastAction: evaluation.action,
|
|
500
|
-
updatedAt: normalizedNow,
|
|
501
|
-
},
|
|
502
|
-
normalizedNow
|
|
503
|
-
);
|
|
504
|
-
if (recordIndex >= 0) {
|
|
505
|
-
budgetState.records[recordIndex] = nextRecord;
|
|
506
|
-
} else {
|
|
507
|
-
budgetState.records.push(nextRecord);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (evaluation.action === "QUARANTINE") {
|
|
511
|
-
applyQueueAndAssignmentStatus({
|
|
512
|
-
queue,
|
|
513
|
-
assignmentLedger,
|
|
514
|
-
workItemId: normalizedWorkItemId,
|
|
515
|
-
status: "BLOCKED",
|
|
516
|
-
reason: "Budget hard limit reached; work item quarantined pending grace window.",
|
|
517
|
-
nowIso: normalizedNow,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
if (evaluation.action === "KILL") {
|
|
521
|
-
applyQueueAndAssignmentStatus({
|
|
522
|
-
queue,
|
|
523
|
-
assignmentLedger,
|
|
524
|
-
workItemId: normalizedWorkItemId,
|
|
525
|
-
status: "SQUASHED",
|
|
526
|
-
reason: "Budget hard limit persisted past grace window; deterministic squash triggered.",
|
|
527
|
-
nowIso: normalizedNow,
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const [savedState] = await Promise.all([
|
|
532
|
-
writeBudgetState(storage.budgetStatePath, budgetState, normalizedNow),
|
|
533
|
-
writeQueue(storage.queuePath, queue, normalizedNow),
|
|
534
|
-
writeAssignmentLedger(assignmentStorage.ledgerPath, assignmentLedger, normalizedNow),
|
|
535
|
-
]);
|
|
536
|
-
|
|
537
|
-
await appendEvent(storage.budgetEventsPath, {
|
|
538
|
-
timestamp: normalizedNow,
|
|
539
|
-
eventType: "budget_check",
|
|
540
|
-
workItemId: normalizedWorkItemId,
|
|
541
|
-
lifecycleState: evaluation.lifecycleState,
|
|
542
|
-
action: evaluation.action,
|
|
543
|
-
warningCodes: evaluation.warnings.map((item) => item.code),
|
|
544
|
-
stopCodes: evaluation.stopReasons.map((item) => item.code),
|
|
545
|
-
quarantineUntil: evaluation.quarantineUntil,
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
await fsp.mkdir(storage.budgetRunsDir, { recursive: true });
|
|
549
|
-
const runId = `budget-check-${normalizedNow.replace(/[:.]/g, "-")}-${String(
|
|
550
|
-
budgetState.records.length
|
|
551
|
-
).padStart(4, "0")}`;
|
|
552
|
-
const runPath = path.join(storage.budgetRunsDir, `${runId}.json`);
|
|
553
|
-
await writeJsonFile(runPath, {
|
|
554
|
-
generatedAt: normalizedNow,
|
|
555
|
-
runId,
|
|
556
|
-
workItemId: normalizedWorkItemId,
|
|
557
|
-
lifecycleState: evaluation.lifecycleState,
|
|
558
|
-
action: evaluation.action,
|
|
559
|
-
warnings: evaluation.warnings,
|
|
560
|
-
stopReasons: evaluation.stopReasons,
|
|
561
|
-
budget: evaluation.budget,
|
|
562
|
-
usage: evaluation.usage,
|
|
563
|
-
quarantineStartedAt: evaluation.quarantineStartedAt,
|
|
564
|
-
quarantineUntil: evaluation.quarantineUntil,
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
return {
|
|
568
|
-
...storage,
|
|
569
|
-
runId,
|
|
570
|
-
runPath,
|
|
571
|
-
record: nextRecord,
|
|
572
|
-
lifecycleState: evaluation.lifecycleState,
|
|
573
|
-
action: evaluation.action,
|
|
574
|
-
warnings: evaluation.warnings,
|
|
575
|
-
stopReasons: evaluation.stopReasons,
|
|
576
|
-
budget: evaluation.budget,
|
|
577
|
-
usage: evaluation.usage,
|
|
578
|
-
quarantineStartedAt: evaluation.quarantineStartedAt,
|
|
579
|
-
quarantineUntil: evaluation.quarantineUntil,
|
|
580
|
-
state: savedState,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export async function listBudgetStates({
|
|
585
|
-
targetPath = ".",
|
|
586
|
-
outputDir = "",
|
|
587
|
-
workItemId = "",
|
|
588
|
-
lifecycleStates = [],
|
|
589
|
-
limit = 50,
|
|
590
|
-
env,
|
|
591
|
-
homeDir,
|
|
592
|
-
nowIso = new Date().toISOString(),
|
|
593
|
-
} = {}) {
|
|
594
|
-
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
595
|
-
const normalizedLimit = Math.max(1, Math.floor(Number(limit || 50)));
|
|
596
|
-
const normalizedWorkItemId = normalizeString(workItemId);
|
|
597
|
-
const normalizedLifecycleStates = new Set(
|
|
598
|
-
(Array.isArray(lifecycleStates) ? lifecycleStates : [])
|
|
599
|
-
.map((item) => normalizeString(item).toUpperCase())
|
|
600
|
-
.filter((item) => DAEMON_BUDGET_LIFECYCLE_STATES.includes(item))
|
|
601
|
-
);
|
|
602
|
-
const storage = await resolveBudgetGovernorStorage({
|
|
603
|
-
targetPath,
|
|
604
|
-
outputDir,
|
|
605
|
-
env,
|
|
606
|
-
homeDir,
|
|
607
|
-
});
|
|
608
|
-
const state = await loadBudgetState(storage.budgetStatePath, normalizedNow);
|
|
609
|
-
const records = state.records
|
|
610
|
-
.filter((record) => {
|
|
611
|
-
if (normalizedWorkItemId && record.workItemId !== normalizedWorkItemId) {
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
if (
|
|
615
|
-
normalizedLifecycleStates.size > 0 &&
|
|
616
|
-
!normalizedLifecycleStates.has(normalizeString(record.lifecycleState).toUpperCase())
|
|
617
|
-
) {
|
|
618
|
-
return false;
|
|
619
|
-
}
|
|
620
|
-
return true;
|
|
621
|
-
})
|
|
622
|
-
.sort((left, right) => {
|
|
623
|
-
const leftEpoch = Date.parse(String(left.updatedAt || "")) || 0;
|
|
624
|
-
const rightEpoch = Date.parse(String(right.updatedAt || "")) || 0;
|
|
625
|
-
return rightEpoch - leftEpoch;
|
|
626
|
-
});
|
|
627
|
-
return {
|
|
628
|
-
...storage,
|
|
629
|
-
totalCount: state.records.length,
|
|
630
|
-
visibleCount: records.length,
|
|
631
|
-
records: records.slice(0, normalizedLimit),
|
|
632
|
-
};
|
|
633
|
-
}
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { resolveAssignmentLedgerStorage } from "./assignment-ledger.js";
|
|
5
|
+
import { resolveErrorDaemonStorage } from "./error-worker.js";
|
|
6
|
+
|
|
7
|
+
const BUDGET_STATE_SCHEMA_VERSION = "1.0.0";
|
|
8
|
+
const QUEUE_SCHEMA_VERSION = "1.0.0";
|
|
9
|
+
|
|
10
|
+
export const DAEMON_BUDGET_LIFECYCLE_STATES = Object.freeze([
|
|
11
|
+
"WITHIN_BUDGET",
|
|
12
|
+
"WARNING_THRESHOLD",
|
|
13
|
+
"HARD_LIMIT_QUARANTINED",
|
|
14
|
+
"HARD_LIMIT_SQUASHED",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function normalizeString(value) {
|
|
18
|
+
return String(value || "").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
|
|
22
|
+
const normalized = normalizeString(value);
|
|
23
|
+
if (!normalized) {
|
|
24
|
+
return fallbackIso;
|
|
25
|
+
}
|
|
26
|
+
const epoch = Date.parse(normalized);
|
|
27
|
+
if (!Number.isFinite(epoch)) {
|
|
28
|
+
return fallbackIso;
|
|
29
|
+
}
|
|
30
|
+
return new Date(epoch).toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeNonNegativeNumber(value, fieldName) {
|
|
34
|
+
const normalized = Number(value || 0);
|
|
35
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
36
|
+
throw new Error(`${fieldName} must be a non-negative number.`);
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizePositiveInteger(value, fieldName, fallbackValue) {
|
|
42
|
+
if (value === undefined || value === null || normalizeString(value) === "") {
|
|
43
|
+
return fallbackValue;
|
|
44
|
+
}
|
|
45
|
+
const normalized = Number(value);
|
|
46
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
47
|
+
throw new Error(`${fieldName} must be a positive integer.`);
|
|
48
|
+
}
|
|
49
|
+
return Math.floor(normalized);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeWarningThresholdPercent(value, fallbackValue = 80) {
|
|
53
|
+
const normalized = Number(value ?? fallbackValue);
|
|
54
|
+
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 100) {
|
|
55
|
+
throw new Error("warningThresholdPercent must be between 0 and 100.");
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeBudgetEnvelope(envelope = {}) {
|
|
61
|
+
return {
|
|
62
|
+
maxTokens: normalizeNonNegativeNumber(envelope.maxTokens ?? 0, "maxTokens"),
|
|
63
|
+
maxCostUsd: normalizeNonNegativeNumber(envelope.maxCostUsd ?? 0, "maxCostUsd"),
|
|
64
|
+
maxRuntimeMs: normalizeNonNegativeNumber(envelope.maxRuntimeMs ?? 0, "maxRuntimeMs"),
|
|
65
|
+
maxToolCalls: normalizeNonNegativeNumber(envelope.maxToolCalls ?? 0, "maxToolCalls"),
|
|
66
|
+
maxPathViolations: normalizePositiveInteger(
|
|
67
|
+
envelope.maxPathViolations ?? 1,
|
|
68
|
+
"maxPathViolations",
|
|
69
|
+
1
|
|
70
|
+
),
|
|
71
|
+
maxNetworkViolations: normalizePositiveInteger(
|
|
72
|
+
envelope.maxNetworkViolations ?? 1,
|
|
73
|
+
"maxNetworkViolations",
|
|
74
|
+
1
|
|
75
|
+
),
|
|
76
|
+
warningThresholdPercent: normalizeWarningThresholdPercent(
|
|
77
|
+
envelope.warningThresholdPercent,
|
|
78
|
+
80
|
|
79
|
+
),
|
|
80
|
+
quarantineGraceSeconds: normalizePositiveInteger(
|
|
81
|
+
envelope.quarantineGraceSeconds ?? 30,
|
|
82
|
+
"quarantineGraceSeconds",
|
|
83
|
+
30
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeUsageSnapshot(usage = {}) {
|
|
89
|
+
return {
|
|
90
|
+
tokensUsed: normalizeNonNegativeNumber(usage.tokensUsed ?? 0, "tokensUsed"),
|
|
91
|
+
costUsd: normalizeNonNegativeNumber(usage.costUsd ?? 0, "costUsd"),
|
|
92
|
+
runtimeMs: normalizeNonNegativeNumber(usage.runtimeMs ?? 0, "runtimeMs"),
|
|
93
|
+
toolCalls: normalizeNonNegativeNumber(usage.toolCalls ?? 0, "toolCalls"),
|
|
94
|
+
pathOutOfScopeHits: normalizeNonNegativeNumber(
|
|
95
|
+
usage.pathOutOfScopeHits ?? 0,
|
|
96
|
+
"pathOutOfScopeHits"
|
|
97
|
+
),
|
|
98
|
+
networkDomainViolations: normalizeNonNegativeNumber(
|
|
99
|
+
usage.networkDomainViolations ?? 0,
|
|
100
|
+
"networkDomainViolations"
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function evaluateThreshold({
|
|
106
|
+
usageValue,
|
|
107
|
+
maxValue,
|
|
108
|
+
warningThresholdPercent,
|
|
109
|
+
stopCode,
|
|
110
|
+
warningCode,
|
|
111
|
+
stopMessage,
|
|
112
|
+
warningMessage,
|
|
113
|
+
warnings,
|
|
114
|
+
stopReasons,
|
|
115
|
+
} = {}) {
|
|
116
|
+
const usage = Number(usageValue || 0);
|
|
117
|
+
const limit = Number(maxValue || 0);
|
|
118
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (usage > limit) {
|
|
122
|
+
stopReasons.push({
|
|
123
|
+
code: stopCode,
|
|
124
|
+
message: stopMessage(usage, limit),
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (warningThresholdPercent <= 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const threshold = (warningThresholdPercent / 100) * limit;
|
|
132
|
+
if (usage >= threshold) {
|
|
133
|
+
warnings.push({
|
|
134
|
+
code: warningCode,
|
|
135
|
+
message: warningMessage(usage, limit, warningThresholdPercent),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addSeconds(isoTimestamp, seconds) {
|
|
141
|
+
const baseEpoch = Date.parse(isoTimestamp) || Date.now();
|
|
142
|
+
return new Date(baseEpoch + seconds * 1000).toISOString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createInitialBudgetState(nowIso = new Date().toISOString()) {
|
|
146
|
+
return {
|
|
147
|
+
schemaVersion: BUDGET_STATE_SCHEMA_VERSION,
|
|
148
|
+
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
149
|
+
records: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeBudgetRecord(record = {}, nowIso = new Date().toISOString()) {
|
|
154
|
+
return {
|
|
155
|
+
workItemId: normalizeString(record.workItemId),
|
|
156
|
+
lifecycleState: normalizeString(record.lifecycleState) || "WITHIN_BUDGET",
|
|
157
|
+
lastAction: normalizeString(record.lastAction) || "NONE",
|
|
158
|
+
updatedAt: normalizeIsoTimestamp(record.updatedAt, nowIso),
|
|
159
|
+
quarantineStartedAt: record.quarantineStartedAt
|
|
160
|
+
? normalizeIsoTimestamp(record.quarantineStartedAt, nowIso)
|
|
161
|
+
: null,
|
|
162
|
+
quarantineUntil: record.quarantineUntil ? normalizeIsoTimestamp(record.quarantineUntil, nowIso) : null,
|
|
163
|
+
warnings: Array.isArray(record.warnings) ? record.warnings : [],
|
|
164
|
+
stopReasons: Array.isArray(record.stopReasons) ? record.stopReasons : [],
|
|
165
|
+
budget: record.budget && typeof record.budget === "object" ? record.budget : {},
|
|
166
|
+
usage: record.usage && typeof record.usage === "object" ? record.usage : {},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function loadJsonFile(filePath, defaultFactory) {
|
|
171
|
+
try {
|
|
172
|
+
const raw = await fsp.readFile(filePath, "utf-8");
|
|
173
|
+
return JSON.parse(raw);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
176
|
+
return defaultFactory();
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function writeJsonFile(filePath, payload = {}) {
|
|
183
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
184
|
+
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function appendEvent(filePath, payload = {}) {
|
|
188
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
189
|
+
await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadQueue(queuePath, nowIso = new Date().toISOString()) {
|
|
193
|
+
const parsed = await loadJsonFile(queuePath, () => ({
|
|
194
|
+
schemaVersion: QUEUE_SCHEMA_VERSION,
|
|
195
|
+
generatedAt: nowIso,
|
|
196
|
+
items: [],
|
|
197
|
+
}));
|
|
198
|
+
return {
|
|
199
|
+
schemaVersion: normalizeString(parsed.schemaVersion) || QUEUE_SCHEMA_VERSION,
|
|
200
|
+
generatedAt: normalizeIsoTimestamp(parsed.generatedAt, nowIso),
|
|
201
|
+
items: Array.isArray(parsed.items) ? parsed.items : [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function writeQueue(queuePath, queue = {}, nowIso = new Date().toISOString()) {
|
|
206
|
+
await writeJsonFile(queuePath, {
|
|
207
|
+
schemaVersion: QUEUE_SCHEMA_VERSION,
|
|
208
|
+
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
209
|
+
items: Array.isArray(queue.items) ? queue.items : [],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function loadAssignmentLedger(ledgerPath, nowIso = new Date().toISOString()) {
|
|
214
|
+
return loadJsonFile(ledgerPath, () => ({
|
|
215
|
+
schemaVersion: "1.0.0",
|
|
216
|
+
generatedAt: nowIso,
|
|
217
|
+
assignments: [],
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function writeAssignmentLedger(ledgerPath, ledger = {}, nowIso = new Date().toISOString()) {
|
|
222
|
+
await writeJsonFile(ledgerPath, {
|
|
223
|
+
...ledger,
|
|
224
|
+
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
225
|
+
assignments: Array.isArray(ledger.assignments) ? ledger.assignments : [],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function evaluateDaemonBudget({
|
|
230
|
+
budget = {},
|
|
231
|
+
usage = {},
|
|
232
|
+
previousRecord = null,
|
|
233
|
+
nowIso = new Date().toISOString(),
|
|
234
|
+
} = {}) {
|
|
235
|
+
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
236
|
+
const normalizedBudget = normalizeBudgetEnvelope(budget);
|
|
237
|
+
const normalizedUsage = normalizeUsageSnapshot(usage);
|
|
238
|
+
|
|
239
|
+
const warnings = [];
|
|
240
|
+
const stopReasons = [];
|
|
241
|
+
evaluateThreshold({
|
|
242
|
+
usageValue: normalizedUsage.tokensUsed,
|
|
243
|
+
maxValue: normalizedBudget.maxTokens,
|
|
244
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
245
|
+
stopCode: "MAX_TOKENS_EXCEEDED",
|
|
246
|
+
warningCode: "TOKENS_NEAR_LIMIT",
|
|
247
|
+
stopMessage: (used, limit) => `Token budget exceeded (${used} > ${limit}).`,
|
|
248
|
+
warningMessage: (used, limit, threshold) =>
|
|
249
|
+
`Token usage near limit (${used}/${limit} at ${threshold}%).`,
|
|
250
|
+
warnings,
|
|
251
|
+
stopReasons,
|
|
252
|
+
});
|
|
253
|
+
evaluateThreshold({
|
|
254
|
+
usageValue: normalizedUsage.costUsd,
|
|
255
|
+
maxValue: normalizedBudget.maxCostUsd,
|
|
256
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
257
|
+
stopCode: "MAX_COST_USD_EXCEEDED",
|
|
258
|
+
warningCode: "COST_NEAR_LIMIT",
|
|
259
|
+
stopMessage: (used, limit) => `Cost budget exceeded (${used.toFixed(6)} > ${limit.toFixed(6)}).`,
|
|
260
|
+
warningMessage: (used, limit, threshold) =>
|
|
261
|
+
`Cost usage near limit (${used.toFixed(6)}/${limit.toFixed(6)} at ${threshold}%).`,
|
|
262
|
+
warnings,
|
|
263
|
+
stopReasons,
|
|
264
|
+
});
|
|
265
|
+
evaluateThreshold({
|
|
266
|
+
usageValue: normalizedUsage.runtimeMs,
|
|
267
|
+
maxValue: normalizedBudget.maxRuntimeMs,
|
|
268
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
269
|
+
stopCode: "MAX_RUNTIME_MS_EXCEEDED",
|
|
270
|
+
warningCode: "RUNTIME_MS_NEAR_LIMIT",
|
|
271
|
+
stopMessage: (used, limit) => `Runtime budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
272
|
+
warningMessage: (used, limit, threshold) =>
|
|
273
|
+
`Runtime usage near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
274
|
+
warnings,
|
|
275
|
+
stopReasons,
|
|
276
|
+
});
|
|
277
|
+
evaluateThreshold({
|
|
278
|
+
usageValue: normalizedUsage.toolCalls,
|
|
279
|
+
maxValue: normalizedBudget.maxToolCalls,
|
|
280
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
281
|
+
stopCode: "MAX_TOOL_CALLS_EXCEEDED",
|
|
282
|
+
warningCode: "TOOL_CALLS_NEAR_LIMIT",
|
|
283
|
+
stopMessage: (used, limit) => `Tool-call budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
284
|
+
warningMessage: (used, limit, threshold) =>
|
|
285
|
+
`Tool-call usage near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
286
|
+
warnings,
|
|
287
|
+
stopReasons,
|
|
288
|
+
});
|
|
289
|
+
evaluateThreshold({
|
|
290
|
+
usageValue: normalizedUsage.pathOutOfScopeHits,
|
|
291
|
+
maxValue: normalizedBudget.maxPathViolations,
|
|
292
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
293
|
+
stopCode: "MAX_PATH_VIOLATIONS_EXCEEDED",
|
|
294
|
+
warningCode: "PATH_VIOLATIONS_NEAR_LIMIT",
|
|
295
|
+
stopMessage: (used, limit) =>
|
|
296
|
+
`Path-scope violation budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
297
|
+
warningMessage: (used, limit, threshold) =>
|
|
298
|
+
`Path-scope violations near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
299
|
+
warnings,
|
|
300
|
+
stopReasons,
|
|
301
|
+
});
|
|
302
|
+
evaluateThreshold({
|
|
303
|
+
usageValue: normalizedUsage.networkDomainViolations,
|
|
304
|
+
maxValue: normalizedBudget.maxNetworkViolations,
|
|
305
|
+
warningThresholdPercent: normalizedBudget.warningThresholdPercent,
|
|
306
|
+
stopCode: "MAX_NETWORK_VIOLATIONS_EXCEEDED",
|
|
307
|
+
warningCode: "NETWORK_VIOLATIONS_NEAR_LIMIT",
|
|
308
|
+
stopMessage: (used, limit) =>
|
|
309
|
+
`Network-domain violation budget exceeded (${Math.round(used)} > ${Math.round(limit)}).`,
|
|
310
|
+
warningMessage: (used, limit, threshold) =>
|
|
311
|
+
`Network-domain violations near limit (${Math.round(used)}/${Math.round(limit)} at ${threshold}%).`,
|
|
312
|
+
warnings,
|
|
313
|
+
stopReasons,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
let lifecycleState;
|
|
317
|
+
let action;
|
|
318
|
+
let quarantineStartedAt = previousRecord?.quarantineStartedAt || null;
|
|
319
|
+
let quarantineUntil = previousRecord?.quarantineUntil || null;
|
|
320
|
+
|
|
321
|
+
if (stopReasons.length > 0) {
|
|
322
|
+
lifecycleState = "HARD_LIMIT_QUARANTINED";
|
|
323
|
+
if (!quarantineStartedAt || !quarantineUntil) {
|
|
324
|
+
quarantineStartedAt = normalizedNow;
|
|
325
|
+
quarantineUntil = addSeconds(normalizedNow, normalizedBudget.quarantineGraceSeconds);
|
|
326
|
+
action = "QUARANTINE";
|
|
327
|
+
} else {
|
|
328
|
+
const nowEpoch = Date.parse(normalizedNow);
|
|
329
|
+
const quarantineUntilEpoch = Date.parse(quarantineUntil);
|
|
330
|
+
if (Number.isFinite(quarantineUntilEpoch) && nowEpoch >= quarantineUntilEpoch) {
|
|
331
|
+
lifecycleState = "HARD_LIMIT_SQUASHED";
|
|
332
|
+
action = "KILL";
|
|
333
|
+
} else {
|
|
334
|
+
action = "QUARANTINE";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (warnings.length > 0) {
|
|
338
|
+
lifecycleState = "WARNING_THRESHOLD";
|
|
339
|
+
action = "NONE";
|
|
340
|
+
quarantineStartedAt = null;
|
|
341
|
+
quarantineUntil = null;
|
|
342
|
+
} else {
|
|
343
|
+
lifecycleState = "WITHIN_BUDGET";
|
|
344
|
+
action = "NONE";
|
|
345
|
+
quarantineStartedAt = null;
|
|
346
|
+
quarantineUntil = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
lifecycleState,
|
|
351
|
+
action,
|
|
352
|
+
updatedAt: normalizedNow,
|
|
353
|
+
quarantineStartedAt,
|
|
354
|
+
quarantineUntil,
|
|
355
|
+
warnings,
|
|
356
|
+
stopReasons,
|
|
357
|
+
budget: normalizedBudget,
|
|
358
|
+
usage: normalizedUsage,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function resolveBudgetGovernorStorage({
|
|
363
|
+
targetPath = ".",
|
|
364
|
+
outputDir = "",
|
|
365
|
+
env,
|
|
366
|
+
homeDir,
|
|
367
|
+
} = {}) {
|
|
368
|
+
const daemonStorage = await resolveErrorDaemonStorage({
|
|
369
|
+
targetPath,
|
|
370
|
+
outputDir,
|
|
371
|
+
env,
|
|
372
|
+
homeDir,
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
...daemonStorage,
|
|
376
|
+
budgetStatePath: path.join(daemonStorage.baseDir, "budget-state.json"),
|
|
377
|
+
budgetEventsPath: path.join(daemonStorage.baseDir, "budget-events.ndjson"),
|
|
378
|
+
budgetRunsDir: path.join(daemonStorage.baseDir, "budget-runs"),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function loadBudgetState(filePath, nowIso = new Date().toISOString()) {
|
|
383
|
+
const parsed = await loadJsonFile(filePath, () => createInitialBudgetState(nowIso));
|
|
384
|
+
return {
|
|
385
|
+
schemaVersion: normalizeString(parsed.schemaVersion) || BUDGET_STATE_SCHEMA_VERSION,
|
|
386
|
+
generatedAt: normalizeIsoTimestamp(parsed.generatedAt, nowIso),
|
|
387
|
+
records: Array.isArray(parsed.records)
|
|
388
|
+
? parsed.records
|
|
389
|
+
.map((record) => normalizeBudgetRecord(record, nowIso))
|
|
390
|
+
.filter((record) => record.workItemId)
|
|
391
|
+
: [],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function writeBudgetState(filePath, state = {}, nowIso = new Date().toISOString()) {
|
|
396
|
+
const normalized = {
|
|
397
|
+
schemaVersion: BUDGET_STATE_SCHEMA_VERSION,
|
|
398
|
+
generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
|
|
399
|
+
records: Array.isArray(state.records)
|
|
400
|
+
? state.records
|
|
401
|
+
.map((record) => normalizeBudgetRecord(record, nowIso))
|
|
402
|
+
.filter((record) => record.workItemId)
|
|
403
|
+
: [],
|
|
404
|
+
};
|
|
405
|
+
await writeJsonFile(filePath, normalized);
|
|
406
|
+
return normalized;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function applyQueueAndAssignmentStatus({
|
|
410
|
+
queue,
|
|
411
|
+
assignmentLedger,
|
|
412
|
+
workItemId,
|
|
413
|
+
status,
|
|
414
|
+
reason,
|
|
415
|
+
nowIso,
|
|
416
|
+
} = {}) {
|
|
417
|
+
const queueIndex = queue.items.findIndex((item) => normalizeString(item.workItemId) === workItemId);
|
|
418
|
+
if (queueIndex >= 0) {
|
|
419
|
+
queue.items[queueIndex] = {
|
|
420
|
+
...queue.items[queueIndex],
|
|
421
|
+
status,
|
|
422
|
+
updatedAt: nowIso,
|
|
423
|
+
metadata: {
|
|
424
|
+
...(queue.items[queueIndex].metadata && typeof queue.items[queueIndex].metadata === "object"
|
|
425
|
+
? queue.items[queueIndex].metadata
|
|
426
|
+
: {}),
|
|
427
|
+
budgetGovernorStatus: status,
|
|
428
|
+
budgetGovernorReason: reason || null,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (assignmentLedger && Array.isArray(assignmentLedger.assignments)) {
|
|
434
|
+
const assignmentIndex = assignmentLedger.assignments.findIndex(
|
|
435
|
+
(assignment) => normalizeString(assignment.workItemId) === workItemId
|
|
436
|
+
);
|
|
437
|
+
if (assignmentIndex >= 0) {
|
|
438
|
+
assignmentLedger.assignments[assignmentIndex] = {
|
|
439
|
+
...assignmentLedger.assignments[assignmentIndex],
|
|
440
|
+
status,
|
|
441
|
+
releasedAt: nowIso,
|
|
442
|
+
releaseReason: reason || assignmentLedger.assignments[assignmentIndex].releaseReason || null,
|
|
443
|
+
updatedAt: nowIso,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function applyDaemonBudgetCheck({
|
|
450
|
+
targetPath = ".",
|
|
451
|
+
outputDir = "",
|
|
452
|
+
workItemId,
|
|
453
|
+
budget = {},
|
|
454
|
+
usage = {},
|
|
455
|
+
env,
|
|
456
|
+
homeDir,
|
|
457
|
+
nowIso = new Date().toISOString(),
|
|
458
|
+
} = {}) {
|
|
459
|
+
const normalizedWorkItemId = normalizeString(workItemId);
|
|
460
|
+
if (!normalizedWorkItemId) {
|
|
461
|
+
throw new Error("workItemId is required.");
|
|
462
|
+
}
|
|
463
|
+
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
464
|
+
const storage = await resolveBudgetGovernorStorage({
|
|
465
|
+
targetPath,
|
|
466
|
+
outputDir,
|
|
467
|
+
env,
|
|
468
|
+
homeDir,
|
|
469
|
+
});
|
|
470
|
+
const assignmentStorage = await resolveAssignmentLedgerStorage({
|
|
471
|
+
targetPath,
|
|
472
|
+
outputDir,
|
|
473
|
+
env,
|
|
474
|
+
homeDir,
|
|
475
|
+
});
|
|
476
|
+
const [budgetState, queue, assignmentLedger] = await Promise.all([
|
|
477
|
+
loadBudgetState(storage.budgetStatePath, normalizedNow),
|
|
478
|
+
loadQueue(storage.queuePath, normalizedNow),
|
|
479
|
+
loadAssignmentLedger(assignmentStorage.ledgerPath, normalizedNow),
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
const queueItem = queue.items.find((item) => normalizeString(item.workItemId) === normalizedWorkItemId);
|
|
483
|
+
if (!queueItem) {
|
|
484
|
+
throw new Error(`Work item '${normalizedWorkItemId}' was not found in daemon queue.`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const recordIndex = budgetState.records.findIndex((record) => record.workItemId === normalizedWorkItemId);
|
|
488
|
+
const previousRecord = recordIndex >= 0 ? budgetState.records[recordIndex] : null;
|
|
489
|
+
const evaluation = evaluateDaemonBudget({
|
|
490
|
+
budget,
|
|
491
|
+
usage,
|
|
492
|
+
previousRecord,
|
|
493
|
+
nowIso: normalizedNow,
|
|
494
|
+
});
|
|
495
|
+
const nextRecord = normalizeBudgetRecord(
|
|
496
|
+
{
|
|
497
|
+
workItemId: normalizedWorkItemId,
|
|
498
|
+
...evaluation,
|
|
499
|
+
lastAction: evaluation.action,
|
|
500
|
+
updatedAt: normalizedNow,
|
|
501
|
+
},
|
|
502
|
+
normalizedNow
|
|
503
|
+
);
|
|
504
|
+
if (recordIndex >= 0) {
|
|
505
|
+
budgetState.records[recordIndex] = nextRecord;
|
|
506
|
+
} else {
|
|
507
|
+
budgetState.records.push(nextRecord);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (evaluation.action === "QUARANTINE") {
|
|
511
|
+
applyQueueAndAssignmentStatus({
|
|
512
|
+
queue,
|
|
513
|
+
assignmentLedger,
|
|
514
|
+
workItemId: normalizedWorkItemId,
|
|
515
|
+
status: "BLOCKED",
|
|
516
|
+
reason: "Budget hard limit reached; work item quarantined pending grace window.",
|
|
517
|
+
nowIso: normalizedNow,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (evaluation.action === "KILL") {
|
|
521
|
+
applyQueueAndAssignmentStatus({
|
|
522
|
+
queue,
|
|
523
|
+
assignmentLedger,
|
|
524
|
+
workItemId: normalizedWorkItemId,
|
|
525
|
+
status: "SQUASHED",
|
|
526
|
+
reason: "Budget hard limit persisted past grace window; deterministic squash triggered.",
|
|
527
|
+
nowIso: normalizedNow,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const [savedState] = await Promise.all([
|
|
532
|
+
writeBudgetState(storage.budgetStatePath, budgetState, normalizedNow),
|
|
533
|
+
writeQueue(storage.queuePath, queue, normalizedNow),
|
|
534
|
+
writeAssignmentLedger(assignmentStorage.ledgerPath, assignmentLedger, normalizedNow),
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
await appendEvent(storage.budgetEventsPath, {
|
|
538
|
+
timestamp: normalizedNow,
|
|
539
|
+
eventType: "budget_check",
|
|
540
|
+
workItemId: normalizedWorkItemId,
|
|
541
|
+
lifecycleState: evaluation.lifecycleState,
|
|
542
|
+
action: evaluation.action,
|
|
543
|
+
warningCodes: evaluation.warnings.map((item) => item.code),
|
|
544
|
+
stopCodes: evaluation.stopReasons.map((item) => item.code),
|
|
545
|
+
quarantineUntil: evaluation.quarantineUntil,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await fsp.mkdir(storage.budgetRunsDir, { recursive: true });
|
|
549
|
+
const runId = `budget-check-${normalizedNow.replace(/[:.]/g, "-")}-${String(
|
|
550
|
+
budgetState.records.length
|
|
551
|
+
).padStart(4, "0")}`;
|
|
552
|
+
const runPath = path.join(storage.budgetRunsDir, `${runId}.json`);
|
|
553
|
+
await writeJsonFile(runPath, {
|
|
554
|
+
generatedAt: normalizedNow,
|
|
555
|
+
runId,
|
|
556
|
+
workItemId: normalizedWorkItemId,
|
|
557
|
+
lifecycleState: evaluation.lifecycleState,
|
|
558
|
+
action: evaluation.action,
|
|
559
|
+
warnings: evaluation.warnings,
|
|
560
|
+
stopReasons: evaluation.stopReasons,
|
|
561
|
+
budget: evaluation.budget,
|
|
562
|
+
usage: evaluation.usage,
|
|
563
|
+
quarantineStartedAt: evaluation.quarantineStartedAt,
|
|
564
|
+
quarantineUntil: evaluation.quarantineUntil,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
...storage,
|
|
569
|
+
runId,
|
|
570
|
+
runPath,
|
|
571
|
+
record: nextRecord,
|
|
572
|
+
lifecycleState: evaluation.lifecycleState,
|
|
573
|
+
action: evaluation.action,
|
|
574
|
+
warnings: evaluation.warnings,
|
|
575
|
+
stopReasons: evaluation.stopReasons,
|
|
576
|
+
budget: evaluation.budget,
|
|
577
|
+
usage: evaluation.usage,
|
|
578
|
+
quarantineStartedAt: evaluation.quarantineStartedAt,
|
|
579
|
+
quarantineUntil: evaluation.quarantineUntil,
|
|
580
|
+
state: savedState,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function listBudgetStates({
|
|
585
|
+
targetPath = ".",
|
|
586
|
+
outputDir = "",
|
|
587
|
+
workItemId = "",
|
|
588
|
+
lifecycleStates = [],
|
|
589
|
+
limit = 50,
|
|
590
|
+
env,
|
|
591
|
+
homeDir,
|
|
592
|
+
nowIso = new Date().toISOString(),
|
|
593
|
+
} = {}) {
|
|
594
|
+
const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
|
|
595
|
+
const normalizedLimit = Math.max(1, Math.floor(Number(limit || 50)));
|
|
596
|
+
const normalizedWorkItemId = normalizeString(workItemId);
|
|
597
|
+
const normalizedLifecycleStates = new Set(
|
|
598
|
+
(Array.isArray(lifecycleStates) ? lifecycleStates : [])
|
|
599
|
+
.map((item) => normalizeString(item).toUpperCase())
|
|
600
|
+
.filter((item) => DAEMON_BUDGET_LIFECYCLE_STATES.includes(item))
|
|
601
|
+
);
|
|
602
|
+
const storage = await resolveBudgetGovernorStorage({
|
|
603
|
+
targetPath,
|
|
604
|
+
outputDir,
|
|
605
|
+
env,
|
|
606
|
+
homeDir,
|
|
607
|
+
});
|
|
608
|
+
const state = await loadBudgetState(storage.budgetStatePath, normalizedNow);
|
|
609
|
+
const records = state.records
|
|
610
|
+
.filter((record) => {
|
|
611
|
+
if (normalizedWorkItemId && record.workItemId !== normalizedWorkItemId) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
if (
|
|
615
|
+
normalizedLifecycleStates.size > 0 &&
|
|
616
|
+
!normalizedLifecycleStates.has(normalizeString(record.lifecycleState).toUpperCase())
|
|
617
|
+
) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
})
|
|
622
|
+
.sort((left, right) => {
|
|
623
|
+
const leftEpoch = Date.parse(String(left.updatedAt || "")) || 0;
|
|
624
|
+
const rightEpoch = Date.parse(String(right.updatedAt || "")) || 0;
|
|
625
|
+
return rightEpoch - leftEpoch;
|
|
626
|
+
});
|
|
627
|
+
return {
|
|
628
|
+
...storage,
|
|
629
|
+
totalCount: state.records.length,
|
|
630
|
+
visibleCount: records.length,
|
|
631
|
+
records: records.slice(0, normalizedLimit),
|
|
632
|
+
};
|
|
633
|
+
}
|