@ashlr/hub 3.2.6 → 3.3.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/CHANGELOG.md +290 -0
- package/README.md +4 -1
- package/dist/build-identity.json +2 -2
- package/dist/cli/activation.d.ts +41 -0
- package/dist/cli/activation.js +485 -0
- package/dist/cli/activation.js.map +1 -0
- package/dist/cli/audit.js +18 -0
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/backlog.d.ts +0 -5
- package/dist/cli/backlog.js +18 -0
- package/dist/cli/backlog.js.map +1 -1
- package/dist/cli/comms.js +20 -0
- package/dist/cli/comms.js.map +1 -1
- package/dist/cli/completions.d.ts +6 -1
- package/dist/cli/completions.js +37 -11
- package/dist/cli/completions.js.map +1 -1
- package/dist/cli/conductor-permit.d.ts +1 -0
- package/dist/cli/conductor-permit.js +122 -0
- package/dist/cli/conductor-permit.js.map +1 -0
- package/dist/cli/daemon.js +10 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/demo.d.ts +0 -27
- package/dist/cli/demo.js +17 -0
- package/dist/cli/demo.js.map +1 -1
- package/dist/cli/goals.d.ts +2 -0
- package/dist/cli/goals.js +90 -5
- package/dist/cli/goals.js.map +1 -1
- package/dist/cli/help.js +21 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inbox.d.ts +14 -2
- package/dist/cli/inbox.js +137 -23
- package/dist/cli/inbox.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/loop.js +26 -5
- package/dist/cli/loop.js.map +1 -1
- package/dist/cli/onboard.d.ts +0 -13
- package/dist/cli/onboard.js +20 -0
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/resources.js +15 -0
- package/dist/cli/resources.js.map +1 -1
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/sandbox.js +43 -0
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/tui.d.ts +0 -6
- package/dist/cli/tui.js +14 -0
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/comms/elon-dialogue.js +16 -7
- package/dist/core/comms/elon-dialogue.js.map +1 -1
- package/dist/core/config.js +70 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/daemon/activation-permit.d.ts +357 -33
- package/dist/core/daemon/activation-permit.js +1585 -183
- package/dist/core/daemon/activation-permit.js.map +1 -1
- package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
- package/dist/core/daemon/loop.d.ts +13 -1
- package/dist/core/daemon/loop.js +294 -29
- package/dist/core/daemon/loop.js.map +1 -1
- package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
- package/dist/core/daemon/runtime-activation-transaction.js +80 -11
- package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
- package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
- package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
- package/dist/core/daemon/service-install-authority.d.ts +0 -4
- package/dist/core/daemon/service-install-authority.js +18 -3
- package/dist/core/daemon/service-install-authority.js.map +1 -1
- package/dist/core/dashboard.d.ts +10 -0
- package/dist/core/dashboard.js +103 -28
- package/dist/core/dashboard.js.map +1 -1
- package/dist/core/effective-config.js +7 -0
- package/dist/core/effective-config.js.map +1 -1
- package/dist/core/fleet/automerge-pass.js +33 -0
- package/dist/core/fleet/automerge-pass.js.map +1 -1
- package/dist/core/fleet/decisions-ledger.js +35 -12
- package/dist/core/fleet/decisions-ledger.js.map +1 -1
- package/dist/core/fleet/external-skill-git-capture.js +9 -1
- package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
- package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
- package/dist/core/fleet/judge-decision-metadata.js +14 -1
- package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
- package/dist/core/fleet/manager.d.ts +15 -1
- package/dist/core/fleet/manager.js +108 -30
- package/dist/core/fleet/manager.js.map +1 -1
- package/dist/core/fleet/post-merge-credit.d.ts +134 -9
- package/dist/core/fleet/post-merge-credit.js +350 -10
- package/dist/core/fleet/post-merge-credit.js.map +1 -1
- package/dist/core/fleet/quota.d.ts +74 -7
- package/dist/core/fleet/quota.js +688 -34
- package/dist/core/fleet/quota.js.map +1 -1
- package/dist/core/fleet/self-improve.d.ts +34 -1
- package/dist/core/fleet/self-improve.js +106 -1
- package/dist/core/fleet/self-improve.js.map +1 -1
- package/dist/core/fleet/skill-library.d.ts +21 -2
- package/dist/core/fleet/skill-library.js +54 -12
- package/dist/core/fleet/skill-library.js.map +1 -1
- package/dist/core/fleet/status.js +7 -5
- package/dist/core/fleet/status.js.map +1 -1
- package/dist/core/fleet/worked-ledger.d.ts +22 -0
- package/dist/core/fleet/worked-ledger.js +32 -0
- package/dist/core/fleet/worked-ledger.js.map +1 -1
- package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
- package/dist/core/foundry/automerge-scope-policy.js +9 -2
- package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
- package/dist/core/goals/advance.js +34 -5
- package/dist/core/goals/advance.js.map +1 -1
- package/dist/core/goals/conductor-quota.d.ts +38 -0
- package/dist/core/goals/conductor-quota.js +110 -0
- package/dist/core/goals/conductor-quota.js.map +1 -0
- package/dist/core/goals/conductor-target.d.ts +24 -0
- package/dist/core/goals/conductor-target.js +35 -0
- package/dist/core/goals/conductor-target.js.map +1 -0
- package/dist/core/goals/conductor.d.ts +15 -0
- package/dist/core/goals/conductor.js +116 -9
- package/dist/core/goals/conductor.js.map +1 -1
- package/dist/core/goals/store.d.ts +29 -5
- package/dist/core/goals/store.js +197 -30
- package/dist/core/goals/store.js.map +1 -1
- package/dist/core/goals/timestamp-repair.d.ts +50 -0
- package/dist/core/goals/timestamp-repair.js +740 -0
- package/dist/core/goals/timestamp-repair.js.map +1 -0
- package/dist/core/inbox/apply.js +5 -2
- package/dist/core/inbox/apply.js.map +1 -1
- package/dist/core/inbox/merge.d.ts +94 -1
- package/dist/core/inbox/merge.js +349 -16
- package/dist/core/inbox/merge.js.map +1 -1
- package/dist/core/integrations/github.d.ts +2 -0
- package/dist/core/integrations/github.js +2 -1
- package/dist/core/integrations/github.js.map +1 -1
- package/dist/core/integrations/pulse-sync.js +13 -4
- package/dist/core/integrations/pulse-sync.js.map +1 -1
- package/dist/core/learn/reflect.d.ts +42 -4
- package/dist/core/learn/reflect.js +56 -5
- package/dist/core/learn/reflect.js.map +1 -1
- package/dist/core/run/best-of-n.d.ts +18 -0
- package/dist/core/run/best-of-n.js +163 -18
- package/dist/core/run/best-of-n.js.map +1 -1
- package/dist/core/run/engines.d.ts +8 -0
- package/dist/core/run/engines.js +9 -1
- package/dist/core/run/engines.js.map +1 -1
- package/dist/core/run/learned-router.js +28 -3
- package/dist/core/run/learned-router.js.map +1 -1
- package/dist/core/run/orchestrator.d.ts +7 -2
- package/dist/core/run/orchestrator.js +140 -60
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/strategy/goal-planner.js +7 -1
- package/dist/core/strategy/goal-planner.js.map +1 -1
- package/dist/core/swarm/planner.d.ts +2 -2
- package/dist/core/swarm/planner.js +52 -5
- package/dist/core/swarm/planner.js.map +1 -1
- package/dist/core/swarm/rollback.d.ts +11 -0
- package/dist/core/swarm/rollback.js +54 -0
- package/dist/core/swarm/rollback.js.map +1 -1
- package/dist/core/swarm/runner.js +123 -11
- package/dist/core/swarm/runner.js.map +1 -1
- package/dist/core/types.d.ts +139 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/web/api.js +105 -7
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/control.d.ts +20 -0
- package/dist/core/web/control.js +49 -34
- package/dist/core/web/control.js.map +1 -1
- package/dist/core/web/fleet-status-cache.d.ts +68 -0
- package/dist/core/web/fleet-status-cache.js +120 -0
- package/dist/core/web/fleet-status-cache.js.map +1 -0
- package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
- package/dist/core/web/public/next/index.html +15 -0
- package/dist/release-dependency-inventory.json +1 -1
- package/dist/tui/app.js +2 -2
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/render.d.ts +2 -0
- package/dist/tui/render.js +162 -4
- package/dist/tui/render.js.map +1 -1
- package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
- package/docs/contracts/CONTRACT-M521.md +102 -0
- package/package.json +20 -3
- package/schema/config.schema.json +800 -5
- package/node_modules/which/CHANGELOG.md +0 -166
package/dist/core/fleet/quota.js
CHANGED
|
@@ -6,25 +6,51 @@
|
|
|
6
6
|
* ledger records each dispatch and answers "how many in the last window?" and
|
|
7
7
|
* "are we still under the configured cap?".
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Observation compatibility remains deliberately tolerant: load returns a fresh
|
|
10
|
+
* empty ledger on missing/corrupt state and recordUse remains best-effort. The
|
|
11
|
+
* authority-bearing reservation API is stricter:
|
|
12
|
+
* - One local-store lock serializes read/check/append publication.
|
|
13
|
+
* - Configured limits fail closed on ambiguous or unavailable persistence.
|
|
14
|
+
* - Reservation identities are hashed and idempotent across retries.
|
|
15
|
+
* - The replacement file and containing directory are fsynced before success.
|
|
13
16
|
* - mkdir -p the parent dir.
|
|
14
|
-
* - Bounded history (last
|
|
17
|
+
* - Bounded authority history (last 2000 events per known backend, max 30d).
|
|
15
18
|
* - Homedir re-resolved at call time so tests can relocate HOME.
|
|
16
19
|
*
|
|
17
20
|
* No new runtime deps; node builtins only. evalQuota mirrors evalBudget's
|
|
18
21
|
* three-level (ok/warn/over) semantics with a null-cap-disabled path.
|
|
19
22
|
*/
|
|
20
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
23
|
+
import { closeSync, constants as fsConstants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
24
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
21
25
|
import { homedir } from 'node:os';
|
|
22
26
|
import { join } from 'node:path';
|
|
27
|
+
import { acquireLocalStoreLockWithOutcome, releaseLocalStoreLock, } from './local-store-lock.js';
|
|
28
|
+
import { fsyncDirectory } from '../util/durability.js';
|
|
29
|
+
import { assurePrivateStoragePath } from '../util/private-storage.js';
|
|
23
30
|
// ---------------------------------------------------------------------------
|
|
24
31
|
// Constants
|
|
25
32
|
// ---------------------------------------------------------------------------
|
|
26
33
|
/** Maximum number of dispatch events retained in quota.json. */
|
|
27
34
|
const MAX_EVENTS = 2000;
|
|
35
|
+
const MAX_LEDGER_BYTES = 1024 * 1024;
|
|
36
|
+
const MAX_AUTHORITY_LEDGER_BYTES = 8 * 1024 * 1024;
|
|
37
|
+
const MAX_DISPATCH_ID_BYTES = 4_096;
|
|
38
|
+
const MAX_BATCH_RESERVATIONS = 64;
|
|
39
|
+
const RESERVATION_DOMAIN = 'ashlr:fleet-quota-reservation:v1';
|
|
40
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
41
|
+
const O_NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
42
|
+
const ENGINE_IDS = new Set([
|
|
43
|
+
'builtin', 'local-coder', 'ashlrcode', 'aw', 'claude', 'codex', 'hermes',
|
|
44
|
+
'kimi', 'nim', 'opencode', 'grok',
|
|
45
|
+
]);
|
|
46
|
+
const MAX_AUTHORITY_EVENTS = MAX_EVENTS * ENGINE_IDS.size;
|
|
47
|
+
let fleetQuotaTestHooks;
|
|
48
|
+
/** Test-only fault injection; production callers must never configure this. */
|
|
49
|
+
export function setFleetQuotaTestHooksForTests(hooks) {
|
|
50
|
+
if (process.env.NODE_ENV !== 'test')
|
|
51
|
+
throw new Error('fleet quota test hooks are test-only');
|
|
52
|
+
fleetQuotaTestHooks = hooks;
|
|
53
|
+
}
|
|
28
54
|
// ---------------------------------------------------------------------------
|
|
29
55
|
// windowToMs — EXTENDS rollup's vocabulary with sub-day windows (local copy)
|
|
30
56
|
// ---------------------------------------------------------------------------
|
|
@@ -39,12 +65,26 @@ export function windowToMs(window) {
|
|
|
39
65
|
case '5m': return 5 * 60_000;
|
|
40
66
|
case '15m': return 15 * 60_000;
|
|
41
67
|
case '1h': return 3_600_000;
|
|
68
|
+
case '5h': return 5 * 3_600_000;
|
|
42
69
|
case '1d': return 86_400_000;
|
|
43
70
|
case '7d': return 7 * 86_400_000;
|
|
44
71
|
case '30d': return 30 * 86_400_000;
|
|
45
72
|
default: return 3_600_000;
|
|
46
73
|
}
|
|
47
74
|
}
|
|
75
|
+
function reservationWindowMs(window) {
|
|
76
|
+
switch (window) {
|
|
77
|
+
case '1m': return 60_000;
|
|
78
|
+
case '5m': return 5 * 60_000;
|
|
79
|
+
case '15m': return 15 * 60_000;
|
|
80
|
+
case '1h': return 3_600_000;
|
|
81
|
+
case '5h': return 5 * 3_600_000;
|
|
82
|
+
case '1d': return 86_400_000;
|
|
83
|
+
case '7d': return 7 * 86_400_000;
|
|
84
|
+
case '30d': return 30 * 86_400_000;
|
|
85
|
+
default: return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
48
88
|
// ---------------------------------------------------------------------------
|
|
49
89
|
// Path helpers (re-resolved at call time so tests can relocate HOME)
|
|
50
90
|
// ---------------------------------------------------------------------------
|
|
@@ -55,12 +95,368 @@ function fleetDir() {
|
|
|
55
95
|
export function fleetQuotaPath() {
|
|
56
96
|
return join(fleetDir(), 'quota.json');
|
|
57
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Private authority ledger. It is deliberately separate from quota.json:
|
|
100
|
+
* quota.json remains backwards-compatible, best-effort operator telemetry,
|
|
101
|
+
* while this file is the fail-closed idempotency/capacity boundary.
|
|
102
|
+
*/
|
|
103
|
+
export function fleetQuotaReservationPath() {
|
|
104
|
+
return join(fleetDir(), 'quota-reservations.json');
|
|
105
|
+
}
|
|
106
|
+
/** Lock path used by the authority-bearing quota reservation transaction. */
|
|
107
|
+
export function fleetQuotaReservationLockPath() {
|
|
108
|
+
return join(fleetDir(), '.quota-reservation.lock');
|
|
109
|
+
}
|
|
58
110
|
// ---------------------------------------------------------------------------
|
|
59
111
|
// Fresh default
|
|
60
112
|
// ---------------------------------------------------------------------------
|
|
61
113
|
function freshQuota() {
|
|
62
114
|
return { events: [] };
|
|
63
115
|
}
|
|
116
|
+
function ownedByCurrentUser(stat) {
|
|
117
|
+
return typeof process.getuid !== 'function' || stat.uid === BigInt(process.getuid());
|
|
118
|
+
}
|
|
119
|
+
function sameIdentity(left, right) {
|
|
120
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
121
|
+
}
|
|
122
|
+
function safeQuotaDirectory(stat) {
|
|
123
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && ownedByCurrentUser(stat) &&
|
|
124
|
+
(process.platform === 'win32' || (stat.mode & 511n) === 448n);
|
|
125
|
+
}
|
|
126
|
+
function safeQuotaFile(stat) {
|
|
127
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n &&
|
|
128
|
+
ownedByCurrentUser(stat) &&
|
|
129
|
+
(process.platform === 'win32' || (stat.mode & 511n) === 384n);
|
|
130
|
+
}
|
|
131
|
+
// Pre-reservation releases wrote best-effort telemetry with the caller's
|
|
132
|
+
// normal umask (commonly 0755/0644). Accept that owned, non-writable-by-others
|
|
133
|
+
// shape only for a read-only first-use migration; new authority state remains
|
|
134
|
+
// exact-private 0700/0600.
|
|
135
|
+
function safeLegacyQuotaDirectory(stat) {
|
|
136
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && ownedByCurrentUser(stat) &&
|
|
137
|
+
(process.platform === 'win32' || (stat.mode & 18n) === 0n);
|
|
138
|
+
}
|
|
139
|
+
function safeLegacyQuotaFile(stat) {
|
|
140
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n &&
|
|
141
|
+
ownedByCurrentUser(stat) &&
|
|
142
|
+
(process.platform === 'win32' || (stat.mode & 18n) === 0n);
|
|
143
|
+
}
|
|
144
|
+
function pinQuotaDirectory() {
|
|
145
|
+
try {
|
|
146
|
+
const path = fleetDir();
|
|
147
|
+
const stat = lstatSync(path, { bigint: true });
|
|
148
|
+
return safeQuotaDirectory(stat) ? { path, dev: stat.dev, ino: stat.ino } : null;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function stableQuotaDirectory(authority) {
|
|
155
|
+
try {
|
|
156
|
+
const stat = lstatSync(authority.path, { bigint: true });
|
|
157
|
+
return safeQuotaDirectory(stat) && sameIdentity(authority, stat);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function pinLegacyQuotaDirectory() {
|
|
164
|
+
try {
|
|
165
|
+
const path = fleetDir();
|
|
166
|
+
const stat = lstatSync(path, { bigint: true });
|
|
167
|
+
return safeLegacyQuotaDirectory(stat) ? { path, dev: stat.dev, ino: stat.ino } : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function stableLegacyQuotaDirectory(authority) {
|
|
174
|
+
try {
|
|
175
|
+
const stat = lstatSync(authority.path, { bigint: true });
|
|
176
|
+
return safeLegacyQuotaDirectory(stat) && sameIdentity(authority, stat);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function prospectiveQuotaDirectoryAvailable() {
|
|
183
|
+
try {
|
|
184
|
+
const dir = fleetDir();
|
|
185
|
+
if (existsSync(dir)) {
|
|
186
|
+
const stat = lstatSync(dir, { bigint: true });
|
|
187
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && ownedByCurrentUser(stat);
|
|
188
|
+
}
|
|
189
|
+
const parent = join(homedir(), '.ashlr');
|
|
190
|
+
if (!existsSync(parent))
|
|
191
|
+
return true;
|
|
192
|
+
const stat = lstatSync(parent, { bigint: true });
|
|
193
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && ownedByCurrentUser(stat);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function strictQuotaEvent(value) {
|
|
200
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
201
|
+
return null;
|
|
202
|
+
const row = value;
|
|
203
|
+
const keys = Object.keys(row).sort().join(',');
|
|
204
|
+
if (keys !== 'backend,ts' && keys !== 'backend,reservationIdHash,ts')
|
|
205
|
+
return null;
|
|
206
|
+
const backend = row['backend'];
|
|
207
|
+
const ts = row['ts'];
|
|
208
|
+
const reservationIdHash = row['reservationIdHash'];
|
|
209
|
+
if (typeof backend !== 'string' || !ENGINE_IDS.has(backend) ||
|
|
210
|
+
typeof ts !== 'string' || !Number.isFinite(Date.parse(ts)) ||
|
|
211
|
+
new Date(Date.parse(ts)).toISOString() !== ts ||
|
|
212
|
+
(reservationIdHash !== undefined &&
|
|
213
|
+
(typeof reservationIdHash !== 'string' || !SHA256_RE.test(reservationIdHash))))
|
|
214
|
+
return null;
|
|
215
|
+
return {
|
|
216
|
+
backend: backend,
|
|
217
|
+
ts,
|
|
218
|
+
...(typeof reservationIdHash === 'string' ? { reservationIdHash } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function parseStrictQuota(bytes, maxEvents = MAX_EVENTS, maxBytes = MAX_LEDGER_BYTES) {
|
|
222
|
+
if (bytes.length > maxBytes)
|
|
223
|
+
return null;
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
226
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
227
|
+
return null;
|
|
228
|
+
const row = parsed;
|
|
229
|
+
if (Object.keys(row).join(',') !== 'events' || !Array.isArray(row['events']) ||
|
|
230
|
+
row['events'].length > maxEvents)
|
|
231
|
+
return null;
|
|
232
|
+
const events = [];
|
|
233
|
+
for (const value of row['events']) {
|
|
234
|
+
const event = strictQuotaEvent(value);
|
|
235
|
+
if (!event)
|
|
236
|
+
return null;
|
|
237
|
+
events.push(event);
|
|
238
|
+
}
|
|
239
|
+
return { events };
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function readBoundQuotaFile(authority) {
|
|
246
|
+
if (!stableQuotaDirectory(authority))
|
|
247
|
+
return null;
|
|
248
|
+
const path = fleetQuotaReservationPath();
|
|
249
|
+
let fd;
|
|
250
|
+
try {
|
|
251
|
+
fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW);
|
|
252
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
253
|
+
if (!safeQuotaFile(opened) || opened.size > BigInt(MAX_AUTHORITY_LEDGER_BYTES) ||
|
|
254
|
+
!assurePrivateStoragePath(path, 'file', 'inspect-existing', { anchorPath: homedir() }).ok)
|
|
255
|
+
return null;
|
|
256
|
+
const named = lstatSync(path, { bigint: true });
|
|
257
|
+
if (!safeQuotaFile(named) || !sameIdentity(opened, named))
|
|
258
|
+
return null;
|
|
259
|
+
const bytes = Buffer.alloc(Number(opened.size));
|
|
260
|
+
let offset = 0;
|
|
261
|
+
while (offset < bytes.length) {
|
|
262
|
+
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
263
|
+
if (count <= 0)
|
|
264
|
+
return null;
|
|
265
|
+
offset += count;
|
|
266
|
+
}
|
|
267
|
+
const openedAfter = fstatSync(fd, { bigint: true });
|
|
268
|
+
const namedAfter = lstatSync(path, { bigint: true });
|
|
269
|
+
if (!safeQuotaFile(openedAfter) || !safeQuotaFile(namedAfter) ||
|
|
270
|
+
!sameIdentity(opened, openedAfter) || !sameIdentity(openedAfter, namedAfter) ||
|
|
271
|
+
openedAfter.size !== opened.size || openedAfter.mtimeNs !== opened.mtimeNs ||
|
|
272
|
+
openedAfter.ctimeNs !== opened.ctimeNs || !stableQuotaDirectory(authority))
|
|
273
|
+
return null;
|
|
274
|
+
return {
|
|
275
|
+
found: true,
|
|
276
|
+
bytes,
|
|
277
|
+
dev: openedAfter.dev,
|
|
278
|
+
ino: openedAfter.ino,
|
|
279
|
+
size: openedAfter.size,
|
|
280
|
+
mtimeNs: openedAfter.mtimeNs,
|
|
281
|
+
ctimeNs: openedAfter.ctimeNs,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return fd === undefined && error.code === 'ENOENT' &&
|
|
286
|
+
stableQuotaDirectory(authority)
|
|
287
|
+
? { found: false }
|
|
288
|
+
: null;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (fd !== undefined) {
|
|
292
|
+
try {
|
|
293
|
+
closeSync(fd);
|
|
294
|
+
}
|
|
295
|
+
catch { /* best effort */ }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function readBoundLegacyQuotaFile(authority) {
|
|
300
|
+
if (!stableLegacyQuotaDirectory(authority))
|
|
301
|
+
return null;
|
|
302
|
+
const path = fleetQuotaPath();
|
|
303
|
+
let fd;
|
|
304
|
+
try {
|
|
305
|
+
fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW);
|
|
306
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
307
|
+
if (!safeLegacyQuotaFile(opened) || opened.size > BigInt(MAX_LEDGER_BYTES) ||
|
|
308
|
+
!assurePrivateStoragePath(path, 'file', 'inspect-owned', { anchorPath: homedir() }).ok)
|
|
309
|
+
return null;
|
|
310
|
+
const named = lstatSync(path, { bigint: true });
|
|
311
|
+
if (!safeLegacyQuotaFile(named) || !sameIdentity(opened, named))
|
|
312
|
+
return null;
|
|
313
|
+
const bytes = Buffer.alloc(Number(opened.size));
|
|
314
|
+
let offset = 0;
|
|
315
|
+
while (offset < bytes.length) {
|
|
316
|
+
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
317
|
+
if (count <= 0)
|
|
318
|
+
return null;
|
|
319
|
+
offset += count;
|
|
320
|
+
}
|
|
321
|
+
const openedAfter = fstatSync(fd, { bigint: true });
|
|
322
|
+
const namedAfter = lstatSync(path, { bigint: true });
|
|
323
|
+
if (!safeLegacyQuotaFile(openedAfter) || !safeLegacyQuotaFile(namedAfter) ||
|
|
324
|
+
!sameIdentity(opened, openedAfter) || !sameIdentity(openedAfter, namedAfter) ||
|
|
325
|
+
openedAfter.size !== opened.size || openedAfter.mtimeNs !== opened.mtimeNs ||
|
|
326
|
+
openedAfter.ctimeNs !== opened.ctimeNs || !stableLegacyQuotaDirectory(authority))
|
|
327
|
+
return null;
|
|
328
|
+
return {
|
|
329
|
+
found: true,
|
|
330
|
+
bytes,
|
|
331
|
+
dev: openedAfter.dev,
|
|
332
|
+
ino: openedAfter.ino,
|
|
333
|
+
size: openedAfter.size,
|
|
334
|
+
mtimeNs: openedAfter.mtimeNs,
|
|
335
|
+
ctimeNs: openedAfter.ctimeNs,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
return fd === undefined && error.code === 'ENOENT' &&
|
|
340
|
+
stableLegacyQuotaDirectory(authority)
|
|
341
|
+
? { found: false }
|
|
342
|
+
: null;
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
if (fd !== undefined) {
|
|
346
|
+
try {
|
|
347
|
+
closeSync(fd);
|
|
348
|
+
}
|
|
349
|
+
catch { /* best effort */ }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function loadStrictQuota(authority) {
|
|
354
|
+
const destination = readBoundQuotaFile(authority);
|
|
355
|
+
if (destination === null)
|
|
356
|
+
return null;
|
|
357
|
+
if (!destination.found) {
|
|
358
|
+
// Upgrade compatibility: seed the first authority transaction from the
|
|
359
|
+
// existing attempt ledger so active-window usage cannot be forgotten.
|
|
360
|
+
const legacy = readBoundLegacyQuotaFile(authority);
|
|
361
|
+
if (legacy === null)
|
|
362
|
+
return null;
|
|
363
|
+
if (!legacy.found)
|
|
364
|
+
return { ledger: freshQuota(), destination };
|
|
365
|
+
const ledger = parseStrictQuota(legacy.bytes);
|
|
366
|
+
return ledger ? { ledger, destination } : null;
|
|
367
|
+
}
|
|
368
|
+
const ledger = parseStrictQuota(destination.bytes, MAX_AUTHORITY_EVENTS, MAX_AUTHORITY_LEDGER_BYTES);
|
|
369
|
+
return ledger ? { ledger, destination } : null;
|
|
370
|
+
}
|
|
371
|
+
function loadQuotaForAuthorityInspection() {
|
|
372
|
+
if (existsSync(fleetQuotaReservationPath())) {
|
|
373
|
+
const authority = pinQuotaDirectory();
|
|
374
|
+
return authority ? loadStrictQuota(authority)?.ledger ?? null : null;
|
|
375
|
+
}
|
|
376
|
+
if (!existsSync(fleetDir()))
|
|
377
|
+
return prospectiveQuotaDirectoryAvailable() ? freshQuota() : null;
|
|
378
|
+
const authority = pinLegacyQuotaDirectory();
|
|
379
|
+
if (!authority)
|
|
380
|
+
return null;
|
|
381
|
+
const legacy = readBoundLegacyQuotaFile(authority);
|
|
382
|
+
if (legacy === null)
|
|
383
|
+
return null;
|
|
384
|
+
if (!legacy.found)
|
|
385
|
+
return freshQuota();
|
|
386
|
+
return parseStrictQuota(legacy.bytes);
|
|
387
|
+
}
|
|
388
|
+
function sameDestination(left, right) {
|
|
389
|
+
if (right === null || left.found !== right.found)
|
|
390
|
+
return false;
|
|
391
|
+
if (!left.found || !right.found)
|
|
392
|
+
return true;
|
|
393
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
|
|
394
|
+
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs &&
|
|
395
|
+
left.bytes.equals(right.bytes);
|
|
396
|
+
}
|
|
397
|
+
function saveQuotaReservation(ledger, authority, expected) {
|
|
398
|
+
const bytes = Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
|
|
399
|
+
if (ledger.events.length > MAX_AUTHORITY_EVENTS ||
|
|
400
|
+
bytes.length > MAX_AUTHORITY_LEDGER_BYTES)
|
|
401
|
+
return false;
|
|
402
|
+
const tmp = join(authority.path, `.quota-${process.pid}-${randomUUID()}.tmp`);
|
|
403
|
+
let fd;
|
|
404
|
+
let tmpIdentity;
|
|
405
|
+
let published = false;
|
|
406
|
+
try {
|
|
407
|
+
if (!stableQuotaDirectory(authority))
|
|
408
|
+
return false;
|
|
409
|
+
fd = openSync(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
410
|
+
tmpIdentity = fstatSync(fd, { bigint: true });
|
|
411
|
+
if (!safeQuotaFile(tmpIdentity) || tmpIdentity.size !== 0n ||
|
|
412
|
+
!assurePrivateStoragePath(tmp, 'file', 'secure-created', { anchorPath: homedir() }).ok)
|
|
413
|
+
return false;
|
|
414
|
+
let offset = 0;
|
|
415
|
+
while (offset < bytes.length) {
|
|
416
|
+
const count = writeSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
417
|
+
if (count <= 0)
|
|
418
|
+
return false;
|
|
419
|
+
offset += count;
|
|
420
|
+
}
|
|
421
|
+
fchmodSync(fd, 0o600);
|
|
422
|
+
fsyncSync(fd);
|
|
423
|
+
const flushed = fstatSync(fd, { bigint: true });
|
|
424
|
+
const named = lstatSync(tmp, { bigint: true });
|
|
425
|
+
if (!safeQuotaFile(flushed) || !safeQuotaFile(named) ||
|
|
426
|
+
!sameIdentity(tmpIdentity, flushed) || !sameIdentity(flushed, named) ||
|
|
427
|
+
flushed.size !== BigInt(bytes.length) || !stableQuotaDirectory(authority) ||
|
|
428
|
+
!sameDestination(expected, readBoundQuotaFile(authority)))
|
|
429
|
+
return false;
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
fd = undefined;
|
|
432
|
+
renameSync(tmp, fleetQuotaReservationPath());
|
|
433
|
+
published = true;
|
|
434
|
+
fsyncDirectory(authority.path, { expectedIdentity: authority });
|
|
435
|
+
const installed = readBoundQuotaFile(authority);
|
|
436
|
+
return installed !== null && installed.found && sameIdentity(tmpIdentity, installed) &&
|
|
437
|
+
installed.size === BigInt(bytes.length) && installed.bytes.equals(bytes) &&
|
|
438
|
+
stableQuotaDirectory(authority);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
if (fd !== undefined) {
|
|
445
|
+
try {
|
|
446
|
+
closeSync(fd);
|
|
447
|
+
}
|
|
448
|
+
catch { /* best effort */ }
|
|
449
|
+
}
|
|
450
|
+
if (!published && tmpIdentity && stableQuotaDirectory(authority)) {
|
|
451
|
+
try {
|
|
452
|
+
const named = lstatSync(tmp, { bigint: true });
|
|
453
|
+
if (safeQuotaFile(named) && sameIdentity(named, tmpIdentity))
|
|
454
|
+
unlinkSync(tmp);
|
|
455
|
+
}
|
|
456
|
+
catch { /* best effort exact-temp cleanup */ }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
64
460
|
// ---------------------------------------------------------------------------
|
|
65
461
|
// Load
|
|
66
462
|
// ---------------------------------------------------------------------------
|
|
@@ -94,46 +490,294 @@ export function loadFleetQuota() {
|
|
|
94
490
|
}
|
|
95
491
|
}
|
|
96
492
|
// ---------------------------------------------------------------------------
|
|
97
|
-
//
|
|
493
|
+
// Record a dispatch (compatibility/operator telemetry only)
|
|
98
494
|
// ---------------------------------------------------------------------------
|
|
99
495
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
496
|
+
* Append an actual dispatch-attempt event for `backend` (ts = now) and
|
|
497
|
+
* persist. Never throws. This is intentionally not launch authority; the
|
|
498
|
+
* strict reservation ledger above is separate so legacy 0644 telemetry files
|
|
499
|
+
* cannot block or overwrite authority state.
|
|
102
500
|
*/
|
|
103
|
-
function
|
|
501
|
+
export function recordUse(backend) {
|
|
502
|
+
let acquired;
|
|
104
503
|
try {
|
|
504
|
+
if (!ENGINE_IDS.has(backend))
|
|
505
|
+
return;
|
|
105
506
|
const dir = fleetDir();
|
|
106
|
-
if (!existsSync(dir))
|
|
507
|
+
if (!existsSync(dir))
|
|
107
508
|
mkdirSync(dir, { recursive: true });
|
|
509
|
+
acquired = acquireLocalStoreLockWithOutcome(fleetQuotaReservationLockPath(), 250, {
|
|
510
|
+
anchorPath: homedir(),
|
|
511
|
+
exactPrivateStorage: true,
|
|
512
|
+
});
|
|
513
|
+
if (acquired.state !== 'acquired')
|
|
514
|
+
return;
|
|
515
|
+
const ledger = loadFleetQuota();
|
|
516
|
+
ledger.events.push({ backend, ts: new Date().toISOString() });
|
|
517
|
+
const bounded = { events: ledger.events.slice(-MAX_EVENTS) };
|
|
518
|
+
const destination = fleetQuotaPath();
|
|
519
|
+
const temporary = `${destination}.tmp`;
|
|
520
|
+
writeFileSync(temporary, `${JSON.stringify(bounded, null, 2)}\n`, 'utf8');
|
|
521
|
+
renameSync(temporary, destination);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// Never throws.
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
if (acquired?.state === 'acquired') {
|
|
528
|
+
try {
|
|
529
|
+
releaseLocalStoreLock(acquired.lock);
|
|
530
|
+
}
|
|
531
|
+
catch { /* telemetry only */ }
|
|
108
532
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function reservationIdHash(dispatchId) {
|
|
536
|
+
return createHash('sha256')
|
|
537
|
+
.update(JSON.stringify([RESERVATION_DOMAIN, dispatchId]))
|
|
538
|
+
.digest('hex');
|
|
539
|
+
}
|
|
540
|
+
function validReservationIdentity(backend, dispatchId) {
|
|
541
|
+
return ENGINE_IDS.has(backend) && typeof dispatchId === 'string' && dispatchId.length > 0 &&
|
|
542
|
+
Buffer.byteLength(dispatchId, 'utf8') <= MAX_DISPATCH_ID_BYTES;
|
|
543
|
+
}
|
|
544
|
+
function configuredReservationLimit(cfg, backend) {
|
|
545
|
+
const configured = cfg?.foundry?.limits?.[backend];
|
|
546
|
+
if (!configured)
|
|
547
|
+
return null;
|
|
548
|
+
const windowMs = typeof configured.window === 'string'
|
|
549
|
+
? reservationWindowMs(configured.window)
|
|
550
|
+
: null;
|
|
551
|
+
if (windowMs === null || !Number.isSafeInteger(configured.max) ||
|
|
552
|
+
configured.max <= 0 || configured.max > MAX_EVENTS)
|
|
553
|
+
return 'invalid';
|
|
554
|
+
return { max: configured.max, windowMs };
|
|
555
|
+
}
|
|
556
|
+
function compactReservationEvents(events, _cfg, nowMs) {
|
|
557
|
+
const maximumWindowMs = reservationWindowMs('30d');
|
|
558
|
+
const retained = events.filter((event) => {
|
|
559
|
+
const ts = Date.parse(event.ts);
|
|
560
|
+
if (!Number.isFinite(ts))
|
|
561
|
+
return false;
|
|
562
|
+
return ts >= nowMs - maximumWindowMs;
|
|
563
|
+
});
|
|
564
|
+
retained.sort((left, right) => Date.parse(left.ts) - Date.parse(right.ts));
|
|
565
|
+
const byBackend = new Map();
|
|
566
|
+
for (const event of retained) {
|
|
567
|
+
const rows = byBackend.get(event.backend) ?? [];
|
|
568
|
+
rows.push(event);
|
|
569
|
+
if (rows.length > MAX_EVENTS)
|
|
570
|
+
rows.shift();
|
|
571
|
+
byBackend.set(event.backend, rows);
|
|
572
|
+
}
|
|
573
|
+
return [...byBackend.values()].flat().sort((left, right) => Date.parse(left.ts) - Date.parse(right.ts));
|
|
574
|
+
}
|
|
575
|
+
function reserveLimitedQuotaUsesLocked(normalized, limited, cfg, authority, loaded) {
|
|
576
|
+
try {
|
|
577
|
+
fsyncDirectory(authority.path, { expectedIdentity: authority });
|
|
116
578
|
}
|
|
117
579
|
catch {
|
|
118
|
-
|
|
580
|
+
return { kind: 'unavailable', launchAuthorized: false };
|
|
581
|
+
}
|
|
582
|
+
if (!stableQuotaDirectory(authority))
|
|
583
|
+
return { kind: 'unavailable', launchAuthorized: false };
|
|
584
|
+
const nowMs = fleetQuotaTestHooks?.now?.() ?? Date.now();
|
|
585
|
+
const compactedEvents = compactReservationEvents(loaded.ledger.events, cfg, nowMs);
|
|
586
|
+
const byHash = new Map(compactedEvents.flatMap((event) => event.reservationIdHash ? [[event.reservationIdHash, event.backend]] : []));
|
|
587
|
+
for (const request of normalized) {
|
|
588
|
+
const existingBackend = byHash.get(request.hash);
|
|
589
|
+
if (existingBackend !== undefined) {
|
|
590
|
+
return {
|
|
591
|
+
kind: existingBackend === request.backend ? 'duplicate' : 'conflict',
|
|
592
|
+
launchAuthorized: false,
|
|
593
|
+
backend: request.backend,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
119
596
|
}
|
|
597
|
+
const usage = new Map();
|
|
598
|
+
const requestedByBackend = new Map();
|
|
599
|
+
for (const request of limited) {
|
|
600
|
+
requestedByBackend.set(request.backend, (requestedByBackend.get(request.backend) ?? 0) + 1);
|
|
601
|
+
}
|
|
602
|
+
for (const request of limited) {
|
|
603
|
+
if (usage.has(request.backend))
|
|
604
|
+
continue;
|
|
605
|
+
const limit = request.limit;
|
|
606
|
+
const cutoff = nowMs - limit.windowMs;
|
|
607
|
+
const used = compactedEvents.reduce((count, event) => event.backend === request.backend && Date.parse(event.ts) >= cutoff ? count + 1 : count, 0);
|
|
608
|
+
usage.set(request.backend, used);
|
|
609
|
+
if (used + (requestedByBackend.get(request.backend) ?? 0) > limit.max) {
|
|
610
|
+
return {
|
|
611
|
+
kind: 'exhausted', launchAuthorized: false, backend: request.backend,
|
|
612
|
+
used, limit: limit.max,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const ts = new Date(nowMs).toISOString();
|
|
617
|
+
const next = {
|
|
618
|
+
events: compactReservationEvents([
|
|
619
|
+
...compactedEvents,
|
|
620
|
+
...limited.map(({ backend, hash }) => ({ backend, ts, reservationIdHash: hash })),
|
|
621
|
+
], cfg, nowMs),
|
|
622
|
+
};
|
|
623
|
+
if (!saveQuotaReservation(next, authority, loaded.destination)) {
|
|
624
|
+
return { kind: 'unavailable', launchAuthorized: false };
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
kind: 'reserved',
|
|
628
|
+
launchAuthorized: true,
|
|
629
|
+
reservations: normalized.map(({ backend, limit }) => {
|
|
630
|
+
if (!limit)
|
|
631
|
+
return { backend, status: 'unlimited', used: 0, limit: null };
|
|
632
|
+
return {
|
|
633
|
+
backend,
|
|
634
|
+
status: 'reserved',
|
|
635
|
+
used: (usage.get(backend) ?? 0) + (requestedByBackend.get(backend) ?? 0),
|
|
636
|
+
limit: limit.max,
|
|
637
|
+
};
|
|
638
|
+
}),
|
|
639
|
+
};
|
|
120
640
|
}
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// Record a dispatch
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
641
|
/**
|
|
125
|
-
*
|
|
642
|
+
* Atomically reserve a complete set of configured backend dispatches before
|
|
643
|
+
* any provider contact. The set is all-or-nothing.
|
|
644
|
+
*
|
|
645
|
+
* An absent limit remains truly unlimited and does not touch local storage;
|
|
646
|
+
* this quota boundary therefore makes no replay/idempotency claim for that
|
|
647
|
+
* backend. Execution ownership remains a separate authority.
|
|
648
|
+
* Configured limits require an unambiguous private ledger, exclusive mutation
|
|
649
|
+
* lock, and durable publication. Duplicate and cross-backend identity reuse are
|
|
650
|
+
* explicit no-launch results: replay safety belongs to the execution authority,
|
|
651
|
+
* not to this quota ledger.
|
|
652
|
+
* This function never throws.
|
|
126
653
|
*/
|
|
127
|
-
|
|
654
|
+
function reserveFleetQuotaUsesInternal(requests, cfg, options = {}) {
|
|
655
|
+
if (!Array.isArray(requests) || requests.length === 0 || requests.length > MAX_BATCH_RESERVATIONS) {
|
|
656
|
+
return { kind: 'invalid', launchAuthorized: false };
|
|
657
|
+
}
|
|
658
|
+
if (options.lockWaitMs !== undefined && !Number.isFinite(options.lockWaitMs)) {
|
|
659
|
+
return { kind: 'invalid', launchAuthorized: false };
|
|
660
|
+
}
|
|
661
|
+
const inputHashes = new Map();
|
|
662
|
+
const normalized = [];
|
|
663
|
+
for (const request of requests) {
|
|
664
|
+
if (!request || !validReservationIdentity(request.backend, request.dispatchId)) {
|
|
665
|
+
return { kind: 'invalid', launchAuthorized: false };
|
|
666
|
+
}
|
|
667
|
+
const hash = reservationIdHash(request.dispatchId);
|
|
668
|
+
const priorBackend = inputHashes.get(hash);
|
|
669
|
+
if (priorBackend !== undefined) {
|
|
670
|
+
return {
|
|
671
|
+
kind: priorBackend === request.backend ? 'duplicate' : 'conflict',
|
|
672
|
+
launchAuthorized: false,
|
|
673
|
+
backend: request.backend,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
inputHashes.set(hash, request.backend);
|
|
677
|
+
const limit = configuredReservationLimit(cfg, request.backend);
|
|
678
|
+
if (limit === null) {
|
|
679
|
+
normalized.push({ backend: request.backend, hash, limit: null });
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (limit === 'invalid') {
|
|
683
|
+
return { kind: 'invalid', launchAuthorized: false, backend: request.backend };
|
|
684
|
+
}
|
|
685
|
+
normalized.push({ backend: request.backend, hash, limit });
|
|
686
|
+
}
|
|
687
|
+
const limited = normalized.filter((request) => request.limit !== null);
|
|
688
|
+
if (limited.length === 0) {
|
|
689
|
+
return {
|
|
690
|
+
kind: 'unlimited',
|
|
691
|
+
launchAuthorized: true,
|
|
692
|
+
reservations: normalized.map(({ backend }) => ({
|
|
693
|
+
backend, status: 'unlimited', used: 0, limit: null,
|
|
694
|
+
})),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const waitMs = options.lockWaitMs === undefined
|
|
698
|
+
? 250
|
|
699
|
+
: Math.max(0, Math.min(2_000, Math.floor(options.lockWaitMs)));
|
|
700
|
+
const acquired = acquireLocalStoreLockWithOutcome(fleetQuotaReservationLockPath(), waitMs, {
|
|
701
|
+
anchorPath: homedir(),
|
|
702
|
+
exactPrivateStorage: true,
|
|
703
|
+
});
|
|
704
|
+
if (acquired.state !== 'acquired')
|
|
705
|
+
return { kind: 'unavailable', launchAuthorized: false };
|
|
706
|
+
let result;
|
|
128
707
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
708
|
+
const authority = pinQuotaDirectory();
|
|
709
|
+
if (!authority)
|
|
710
|
+
result = { kind: 'unavailable', launchAuthorized: false };
|
|
711
|
+
else {
|
|
712
|
+
const loaded = loadStrictQuota(authority);
|
|
713
|
+
result = loaded
|
|
714
|
+
? reserveLimitedQuotaUsesLocked(normalized, limited, cfg, authority, loaded)
|
|
715
|
+
: { kind: 'unavailable', launchAuthorized: false };
|
|
716
|
+
}
|
|
132
717
|
}
|
|
133
718
|
catch {
|
|
134
|
-
|
|
719
|
+
result = { kind: 'unavailable', launchAuthorized: false };
|
|
720
|
+
}
|
|
721
|
+
const released = (fleetQuotaTestHooks?.releaseLock ?? releaseLocalStoreLock)(acquired.lock);
|
|
722
|
+
if (!released) {
|
|
723
|
+
return {
|
|
724
|
+
kind: 'unavailable',
|
|
725
|
+
launchAuthorized: false,
|
|
726
|
+
...(result.kind === 'reserved' ? { reservationConsumed: true } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return result;
|
|
730
|
+
}
|
|
731
|
+
export function reserveFleetQuotaUses(requests, cfg, options = {}) {
|
|
732
|
+
try {
|
|
733
|
+
return reserveFleetQuotaUsesInternal(requests, cfg, options);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
return { kind: 'invalid', launchAuthorized: false };
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** Single-dispatch convenience wrapper over the atomic batch transaction. */
|
|
740
|
+
export function reserveFleetQuotaUse(backend, cfg, dispatchId, options = {}) {
|
|
741
|
+
return reserveFleetQuotaUses([{ backend, dispatchId }], cfg, options);
|
|
742
|
+
}
|
|
743
|
+
function evaluateFleetQuotaAuthority(backend, cfg, now) {
|
|
744
|
+
try {
|
|
745
|
+
const limit = configuredReservationLimit(cfg, backend);
|
|
746
|
+
if (limit === null)
|
|
747
|
+
return { health: 'unlimited', standing: 'unlimited' };
|
|
748
|
+
if (limit === 'invalid')
|
|
749
|
+
return { health: 'invalid', standing: 'over' };
|
|
750
|
+
const ledger = loadQuotaForAuthorityInspection();
|
|
751
|
+
if (!ledger)
|
|
752
|
+
return { health: 'unavailable', standing: 'over' };
|
|
753
|
+
const nowMs = now ?? fleetQuotaTestHooks?.now?.() ?? Date.now();
|
|
754
|
+
const compacted = compactReservationEvents(ledger.events, cfg, nowMs);
|
|
755
|
+
if (compacted.length > MAX_AUTHORITY_EVENTS) {
|
|
756
|
+
return { health: 'capacity', standing: 'over' };
|
|
757
|
+
}
|
|
758
|
+
const cutoff = nowMs - limit.windowMs;
|
|
759
|
+
const used = compacted.reduce((count, event) => event.backend === backend && Date.parse(event.ts) >= cutoff ? count + 1 : count, 0);
|
|
760
|
+
return {
|
|
761
|
+
health: 'healthy',
|
|
762
|
+
standing: used >= limit.max ? 'over' : used >= limit.max * 0.8 ? 'warn' : 'ok',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return { health: 'unavailable', standing: 'over' };
|
|
135
767
|
}
|
|
136
768
|
}
|
|
769
|
+
/** Read-only conservative health projection for operator/status surfaces. */
|
|
770
|
+
export function inspectFleetQuotaAuthority(backend, cfg, now) {
|
|
771
|
+
return evaluateFleetQuotaAuthority(backend, cfg, now).health;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Quota standing derived from reserved authority slots, not attempt telemetry.
|
|
775
|
+
* This intentionally counts conservative pre-effect reservations that may not
|
|
776
|
+
* have reached a provider: those slots still consume the configured window.
|
|
777
|
+
*/
|
|
778
|
+
export function evalFleetQuotaAuthority(backend, cfg, now) {
|
|
779
|
+
return evaluateFleetQuotaAuthority(backend, cfg, now).standing;
|
|
780
|
+
}
|
|
137
781
|
// ---------------------------------------------------------------------------
|
|
138
782
|
// Window counting
|
|
139
783
|
// ---------------------------------------------------------------------------
|
|
@@ -166,15 +810,21 @@ export function usesInWindow(backend, windowMs, now) {
|
|
|
166
810
|
*
|
|
167
811
|
* Mirrors evalBudget's null-cap-disabled logic: when no
|
|
168
812
|
* cfg.foundry.limits[backend] is configured, the backend is unlimited ⇒ always
|
|
169
|
-
* within limit.
|
|
813
|
+
* within limit. Healthy configured state follows reservation standing so a
|
|
814
|
+
* consumed slot can route to an eligible peer. Invalid, unavailable, or
|
|
815
|
+
* capacity-ambiguous authority deliberately remains advisory-eligible here so
|
|
816
|
+
* the final provider-effect reservation returns the structured fail-closed
|
|
817
|
+
* refusal instead of silently capability-downgrading to builtin. `now` is
|
|
170
818
|
* injectable for tests.
|
|
171
819
|
*/
|
|
172
820
|
export function withinLimit(backend, cfg, now) {
|
|
173
821
|
const limit = cfg.foundry?.limits?.[backend];
|
|
174
822
|
if (!limit)
|
|
175
823
|
return true; // no cap configured ⇒ unlimited
|
|
176
|
-
const
|
|
177
|
-
|
|
824
|
+
const evaluation = evaluateFleetQuotaAuthority(backend, cfg, now);
|
|
825
|
+
if (evaluation.health !== 'healthy')
|
|
826
|
+
return true;
|
|
827
|
+
return evaluation.standing !== 'over';
|
|
178
828
|
}
|
|
179
829
|
// ---------------------------------------------------------------------------
|
|
180
830
|
// Three-level quota evaluation (mirrors evalBudget)
|
|
@@ -186,9 +836,13 @@ export function withinLimit(backend, cfg, now) {
|
|
|
186
836
|
*/
|
|
187
837
|
export function evalQuota(backend, cfg, now) {
|
|
188
838
|
const limit = cfg.foundry?.limits?.[backend];
|
|
189
|
-
if (!limit
|
|
190
|
-
return 'ok';
|
|
191
|
-
const
|
|
839
|
+
if (!limit)
|
|
840
|
+
return 'ok';
|
|
841
|
+
const windowMs = reservationWindowMs(limit.window);
|
|
842
|
+
if (windowMs === null || !Number.isSafeInteger(limit.max) ||
|
|
843
|
+
limit.max <= 0 || limit.max > MAX_EVENTS)
|
|
844
|
+
return 'over';
|
|
845
|
+
const used = usesInWindow(backend, windowMs, now);
|
|
192
846
|
if (used >= limit.max)
|
|
193
847
|
return 'over';
|
|
194
848
|
if (used >= limit.max * 0.8)
|