knosky 0.6.3 → 0.7.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 +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
// KnoSky L3 swarm coordinator floor (DEC-113) — thin local control plane.
|
|
2
|
+
// Built on Mode B domain leases (domain-store) + audit-writer multi-agent events.
|
|
3
|
+
// Pure ESM, Node stdlib. Windows-safe atomic writes (no mandatory fsync).
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import {
|
|
15
|
+
loadDomain,
|
|
16
|
+
resolveDomainRoot,
|
|
17
|
+
registerAgentWithLease,
|
|
18
|
+
} from './domain-store.mjs';
|
|
19
|
+
import { writeDecisionReceipt } from './audit-writer.mjs';
|
|
20
|
+
import { CODES } from './decision-codes.mjs';
|
|
21
|
+
import {
|
|
22
|
+
assertOperator,
|
|
23
|
+
assertOperatorQuorum,
|
|
24
|
+
operatorCount,
|
|
25
|
+
sanitizeClasses,
|
|
26
|
+
ELEVATED,
|
|
27
|
+
} from './operator-auth.mjs';
|
|
28
|
+
import { resolveLeaseIdentity } from './local-ipc-identity.mjs';
|
|
29
|
+
|
|
30
|
+
/** Default climate knobs (simple counter model). */
|
|
31
|
+
export const DEFAULT_SWARM_QUOTAS = Object.freeze({
|
|
32
|
+
maxClaimsPerAgent: 8,
|
|
33
|
+
maxActiveLeasesPerAgent: 4,
|
|
34
|
+
maxActionsPerWindow: 32,
|
|
35
|
+
windowMs: 60_000,
|
|
36
|
+
antiProbeDenyThreshold: 12,
|
|
37
|
+
antiProbeWindowMs: 10_000,
|
|
38
|
+
enableFifoWait: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Soft atomic JSON write — no fsync (Windows EPERM-safe).
|
|
43
|
+
* @param {string} path
|
|
44
|
+
* @param {object} obj
|
|
45
|
+
*/
|
|
46
|
+
function atomicWriteSoft(path, obj) {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
const tmp = path + '.tmp';
|
|
49
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
50
|
+
renameSync(tmp, path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} domainRoot
|
|
55
|
+
*/
|
|
56
|
+
export function swarmPaths(domainRoot) {
|
|
57
|
+
const swarmDir = join(domainRoot, 'swarm');
|
|
58
|
+
return {
|
|
59
|
+
swarmDir,
|
|
60
|
+
heatmapPath: join(swarmDir, 'heatmap.json'),
|
|
61
|
+
claimsPath: join(swarmDir, 'claims.json'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nowIso() {
|
|
66
|
+
return new Date().toISOString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mintId(prefix) {
|
|
70
|
+
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pruneWindow(tsArr, windowMs, now = Date.now()) {
|
|
74
|
+
const cut = now - windowMs;
|
|
75
|
+
return tsArr.filter((t) => t >= cut);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a local L3 swarm coordinator bound to a trust domain.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} [opts]
|
|
82
|
+
* @param {string} [opts.domainRoot]
|
|
83
|
+
* @param {string} [opts.cityPath]
|
|
84
|
+
* @param {ReturnType<typeof loadDomain>} [opts.domain]
|
|
85
|
+
* @param {Partial<typeof DEFAULT_SWARM_QUOTAS>} [opts.quotas]
|
|
86
|
+
*/
|
|
87
|
+
export function createSwarmCoordinator(opts = {}) {
|
|
88
|
+
const domainRoot = resolveDomainRoot(opts.cityPath, opts.domainRoot);
|
|
89
|
+
const domain = opts.domain || loadDomain(domainRoot);
|
|
90
|
+
const paths = swarmPaths(domainRoot);
|
|
91
|
+
mkdirSync(paths.swarmDir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
const quotas = { ...DEFAULT_SWARM_QUOTAS, ...(opts.quotas || {}) };
|
|
94
|
+
|
|
95
|
+
/** @type {Map<string, { claimId: string, agentId: string, kind: string, resource: string, since: string }>} */
|
|
96
|
+
const claims = new Map();
|
|
97
|
+
/** FIFO waiters per resource key */
|
|
98
|
+
/** @type {Map<string, string[]>} */
|
|
99
|
+
const waiters = new Map();
|
|
100
|
+
/** Per-agent counters / sliding windows */
|
|
101
|
+
/** @type {Map<string, { actionTs: number[], denyTs: number[], probeTrips: number }>} */
|
|
102
|
+
const agentStats = new Map();
|
|
103
|
+
|
|
104
|
+
// Restore durable claims if present (best-effort).
|
|
105
|
+
if (existsSync(paths.claimsPath)) {
|
|
106
|
+
try {
|
|
107
|
+
const doc = JSON.parse(readFileSync(paths.claimsPath, 'utf8'));
|
|
108
|
+
for (const [key, rec] of Object.entries(doc.claims || {})) {
|
|
109
|
+
if (rec && rec.agentId && rec.resource) claims.set(key, rec);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore corrupt; rebuild in memory */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statsFor(agentId) {
|
|
117
|
+
if (!agentStats.has(agentId)) {
|
|
118
|
+
agentStats.set(agentId, { actionTs: [], denyTs: [], probeTrips: 0 });
|
|
119
|
+
}
|
|
120
|
+
return agentStats.get(agentId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function claimCount(agentId) {
|
|
124
|
+
let n = 0;
|
|
125
|
+
for (const c of claims.values()) if (c.agentId === agentId) n += 1;
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function activeLeaseCount(agentId) {
|
|
130
|
+
let n = 0;
|
|
131
|
+
for (const rec of domain.leaseStore.values()) {
|
|
132
|
+
if (rec.agentId === agentId && rec.status === 'active') {
|
|
133
|
+
if (rec.expires_at && Date.parse(rec.expires_at) <= Date.now()) continue;
|
|
134
|
+
n += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function persistClaims() {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [k, v] of claims.entries()) out[k] = v;
|
|
143
|
+
atomicWriteSoft(paths.claimsPath, { v: 1, claims: out, updated_at: nowIso() });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function emit(decision_code, agentId, tool, meta = {}) {
|
|
147
|
+
return writeDecisionReceipt({
|
|
148
|
+
domainRoot,
|
|
149
|
+
decision_code,
|
|
150
|
+
agent_id: agentId,
|
|
151
|
+
tool,
|
|
152
|
+
mode: 'B',
|
|
153
|
+
destination: null,
|
|
154
|
+
meta: {
|
|
155
|
+
swarm: true,
|
|
156
|
+
layer: 'L3',
|
|
157
|
+
...meta,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function noteAction(agentId) {
|
|
163
|
+
const s = statsFor(agentId);
|
|
164
|
+
const t = Date.now();
|
|
165
|
+
s.actionTs = pruneWindow(s.actionTs.concat(t), quotas.windowMs, t);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function noteDeny(agentId, reason) {
|
|
169
|
+
const s = statsFor(agentId);
|
|
170
|
+
const t = Date.now();
|
|
171
|
+
s.denyTs = pruneWindow(s.denyTs.concat(t), quotas.antiProbeWindowMs, t);
|
|
172
|
+
s.actionTs = pruneWindow(s.actionTs.concat(t), quotas.windowMs, t);
|
|
173
|
+
let probe = false;
|
|
174
|
+
if (s.denyTs.length >= quotas.antiProbeDenyThreshold) {
|
|
175
|
+
s.probeTrips += 1;
|
|
176
|
+
probe = true;
|
|
177
|
+
// Clear window so each trip is distinct
|
|
178
|
+
s.denyTs = [];
|
|
179
|
+
}
|
|
180
|
+
return { probe, reason };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function backpressureHit(agentId) {
|
|
184
|
+
const s = statsFor(agentId);
|
|
185
|
+
const t = Date.now();
|
|
186
|
+
s.actionTs = pruneWindow(s.actionTs, quotas.windowMs, t);
|
|
187
|
+
return s.actionTs.length >= quotas.maxActionsPerWindow;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resourceKey(kind, resource) {
|
|
191
|
+
return `${kind}:${resource}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Ensure agent is registered in the domain store (idempotent on same id).
|
|
196
|
+
* Elevated classes require TWO distinct operator tokens (same Rule 3 as domain-store).
|
|
197
|
+
* @param {{ agentId: string, role?: string, classes?: string[], operatorToken?: string, operatorToken2?: string }} agent
|
|
198
|
+
*/
|
|
199
|
+
function registerAgent(agent) {
|
|
200
|
+
const agentId = agent.agentId;
|
|
201
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
202
|
+
return { ok: false, code: CODES.ERROR_INVALID_INPUT, reason: 'agentId_required' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!domain.agents[agentId]) {
|
|
206
|
+
const issued = registerAgentWithLease(
|
|
207
|
+
domain,
|
|
208
|
+
{
|
|
209
|
+
agentId,
|
|
210
|
+
role: agent.role || 'coder',
|
|
211
|
+
classes: Array.isArray(agent.classes) ? agent.classes : ['public', 'internal'],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
operatorToken: agent.operatorToken || process.env.KC_OPERATOR_TOKEN,
|
|
215
|
+
operatorToken2: agent.operatorToken2 || process.env.KC_OPERATOR_TOKEN_2,
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
if (!issued.ok) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
code: CODES.DENY_IDENTITY,
|
|
222
|
+
reason: issued.reason,
|
|
223
|
+
next_action: issued.next_action,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// Reload after registerAgentWithLease mutated disk
|
|
227
|
+
const fresh = loadDomain(domainRoot);
|
|
228
|
+
domain.agents = fresh.agents;
|
|
229
|
+
domain.policy = fresh.policy;
|
|
230
|
+
domain.leaseStore = fresh.leaseStore;
|
|
231
|
+
}
|
|
232
|
+
emit(CODES.ALLOW, agentId, 'swarm_register', { event: 'agent_register' });
|
|
233
|
+
noteAction(agentId);
|
|
234
|
+
return { ok: true, code: CODES.ALLOW, agentId, agent: domain.agents[agentId] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Issue a fresh active lease for an agent (wraps domain-store).
|
|
239
|
+
* Registers agent if missing (subject to operator gates / quorum).
|
|
240
|
+
* @param {{ agentId: string, role?: string, classes?: string[], ttlMs?: number|null, operatorToken?: string, operatorToken2?: string }} opts
|
|
241
|
+
*/
|
|
242
|
+
function issueLease(optsIn = {}) {
|
|
243
|
+
const agentId = optsIn.agentId;
|
|
244
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
245
|
+
return { ok: false, code: CODES.ERROR_INVALID_INPUT, reason: 'agentId_required' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (backpressureHit(agentId)) {
|
|
249
|
+
const d = noteDeny(agentId, 'backpressure');
|
|
250
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_lease_issue', {
|
|
251
|
+
event: 'lease_issue_denied',
|
|
252
|
+
reason: 'backpressure',
|
|
253
|
+
probe: d.probe,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
code: CODES.DENY,
|
|
258
|
+
reason: 'backpressure',
|
|
259
|
+
backpressure: true,
|
|
260
|
+
probe: d.probe,
|
|
261
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (activeLeaseCount(agentId) >= quotas.maxActiveLeasesPerAgent) {
|
|
266
|
+
const d = noteDeny(agentId, 'quota_leases');
|
|
267
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_lease_issue', {
|
|
268
|
+
event: 'lease_issue_denied',
|
|
269
|
+
reason: 'quota_leases',
|
|
270
|
+
probe: d.probe,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
code: CODES.DENY,
|
|
275
|
+
reason: 'quota_leases',
|
|
276
|
+
backpressure: true,
|
|
277
|
+
probe: d.probe,
|
|
278
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Always mint via domain-store for mode-B compatible records.
|
|
283
|
+
// registerAgentWithLease overwrites agent classes — preserve existing if present.
|
|
284
|
+
const existing = domain.agents[agentId];
|
|
285
|
+
const classes = optsIn.classes || existing?.classes || ['public', 'internal'];
|
|
286
|
+
const role = optsIn.role || existing?.role || 'coder';
|
|
287
|
+
const operatorToken = optsIn.operatorToken || process.env.KC_OPERATOR_TOKEN;
|
|
288
|
+
const operatorToken2 = optsIn.operatorToken2 || process.env.KC_OPERATOR_TOKEN_2;
|
|
289
|
+
|
|
290
|
+
// If agent already registered, mint lease without clobbering registry fields hard:
|
|
291
|
+
let leaseId;
|
|
292
|
+
if (existing) {
|
|
293
|
+
leaseId = mintId('lease');
|
|
294
|
+
const ttlMs = optsIn.ttlMs == null ? null : Number(optsIn.ttlMs);
|
|
295
|
+
const rec = {
|
|
296
|
+
leaseId,
|
|
297
|
+
agentId,
|
|
298
|
+
status: 'active',
|
|
299
|
+
created_at: nowIso(),
|
|
300
|
+
expires_at:
|
|
301
|
+
ttlMs && Number.isFinite(ttlMs) && ttlMs > 0
|
|
302
|
+
? new Date(Date.now() + ttlMs).toISOString()
|
|
303
|
+
: null,
|
|
304
|
+
};
|
|
305
|
+
domain.leaseStore.set(leaseId, rec);
|
|
306
|
+
domain.saveLeases();
|
|
307
|
+
} else {
|
|
308
|
+
const issued = registerAgentWithLease(
|
|
309
|
+
domain,
|
|
310
|
+
{ agentId, classes, role },
|
|
311
|
+
{ operatorToken, operatorToken2 },
|
|
312
|
+
);
|
|
313
|
+
if (!issued.ok) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
code: CODES.DENY_IDENTITY,
|
|
317
|
+
reason: issued.reason,
|
|
318
|
+
next_action: issued.next_action,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
leaseId = issued.leaseId;
|
|
322
|
+
if (optsIn.ttlMs && Number.isFinite(optsIn.ttlMs) && optsIn.ttlMs > 0) {
|
|
323
|
+
const rec = domain.leaseStore.get(leaseId);
|
|
324
|
+
if (rec) {
|
|
325
|
+
rec.expires_at = new Date(Date.now() + optsIn.ttlMs).toISOString();
|
|
326
|
+
domain.leaseStore.set(leaseId, rec);
|
|
327
|
+
domain.saveLeases();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
noteAction(agentId);
|
|
333
|
+
const rc = emit(CODES.ALLOW, agentId, 'swarm_lease_issue', {
|
|
334
|
+
event: 'lease_issued',
|
|
335
|
+
lease_id: leaseId,
|
|
336
|
+
});
|
|
337
|
+
writeHeatmap();
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
code: CODES.ALLOW,
|
|
341
|
+
agentId,
|
|
342
|
+
leaseId,
|
|
343
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Expire a lease (TTL / sweep). Self-service expiration for system clock only
|
|
349
|
+
* via expireDueLeases; manual expire requires operator OR the holder agentId.
|
|
350
|
+
* @param {string} leaseId
|
|
351
|
+
* @param {{ operatorToken?: string, callerAgentId?: string, system?: boolean }} [auth]
|
|
352
|
+
*/
|
|
353
|
+
function expireLease(leaseId, auth = {}) {
|
|
354
|
+
const lease = domain.leaseStore.get(leaseId);
|
|
355
|
+
if (!lease) {
|
|
356
|
+
return { ok: false, code: CODES.DENY_IDENTITY, reason: 'unknown_lease_id' };
|
|
357
|
+
}
|
|
358
|
+
if (lease.status !== 'active') {
|
|
359
|
+
return { ok: true, code: CODES.ALLOW, leaseId, status: lease.status, noop: true };
|
|
360
|
+
}
|
|
361
|
+
if (!auth.system) {
|
|
362
|
+
const gate = authorizeLeaseAdmin(lease, auth);
|
|
363
|
+
if (!gate.ok) return gate;
|
|
364
|
+
}
|
|
365
|
+
lease.status = 'expired';
|
|
366
|
+
lease.expired_at = nowIso();
|
|
367
|
+
domain.leaseStore.set(leaseId, lease);
|
|
368
|
+
domain.saveLeases();
|
|
369
|
+
const rc = emit(CODES.ALLOW, lease.agentId, 'swarm_lease_expire', {
|
|
370
|
+
event: 'lease_expired',
|
|
371
|
+
lease_id: leaseId,
|
|
372
|
+
});
|
|
373
|
+
writeHeatmap();
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
code: CODES.ALLOW,
|
|
377
|
+
leaseId,
|
|
378
|
+
agentId: lease.agentId,
|
|
379
|
+
status: 'expired',
|
|
380
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Revoke a lease (security / operator OR holder self-revoke).
|
|
386
|
+
* Foreign revocation requires operatorToken — never unilateral third-party.
|
|
387
|
+
* @param {string} leaseId
|
|
388
|
+
* @param {{ reason?: string, operatorToken?: string, callerAgentId?: string }} [meta]
|
|
389
|
+
*/
|
|
390
|
+
function revokeLease(leaseId, meta = {}) {
|
|
391
|
+
const lease = domain.leaseStore.get(leaseId);
|
|
392
|
+
if (!lease) {
|
|
393
|
+
return { ok: false, code: CODES.DENY_IDENTITY, reason: 'unknown_lease_id' };
|
|
394
|
+
}
|
|
395
|
+
const gate = authorizeLeaseAdmin(lease, meta);
|
|
396
|
+
if (!gate.ok) return gate;
|
|
397
|
+
|
|
398
|
+
lease.status = 'revoked';
|
|
399
|
+
lease.revoked_at = nowIso();
|
|
400
|
+
lease.revoke_reason = meta.reason || (gate.mode === 'holder' ? 'self_revoke' : 'operator');
|
|
401
|
+
lease.revoked_by = gate.mode === 'operator' ? gate.operatorId : lease.agentId;
|
|
402
|
+
domain.leaseStore.set(leaseId, lease);
|
|
403
|
+
domain.saveLeases();
|
|
404
|
+
const rc = emit(CODES.ALLOW, lease.agentId, 'swarm_lease_revoke', {
|
|
405
|
+
event: 'lease_revoked',
|
|
406
|
+
lease_id: leaseId,
|
|
407
|
+
reason: lease.revoke_reason,
|
|
408
|
+
by: lease.revoked_by,
|
|
409
|
+
});
|
|
410
|
+
writeHeatmap();
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
code: CODES.ALLOW,
|
|
414
|
+
leaseId,
|
|
415
|
+
agentId: lease.agentId,
|
|
416
|
+
status: 'revoked',
|
|
417
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** @param {any} lease @param {{ operatorToken?: string, callerAgentId?: string }} auth */
|
|
422
|
+
function authorizeLeaseAdmin(lease, auth = {}) {
|
|
423
|
+
const token = auth.operatorToken || process.env.KC_OPERATOR_TOKEN;
|
|
424
|
+
const op = assertOperator(domainRoot, token);
|
|
425
|
+
if (op.ok) {
|
|
426
|
+
return { ok: true, mode: 'operator', operatorId: op.operatorId };
|
|
427
|
+
}
|
|
428
|
+
// Holder may expire/revoke own lease (self only).
|
|
429
|
+
if (auth.callerAgentId && auth.callerAgentId === lease.agentId) {
|
|
430
|
+
return { ok: true, mode: 'holder' };
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
code: CODES.DENY_IDENTITY,
|
|
435
|
+
reason: 'operator_or_holder_required',
|
|
436
|
+
next_action: 'Provide operatorToken or callerAgentId matching the lease holder',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Sweep leases past expires_at → expired (system; no operator needed).
|
|
442
|
+
*/
|
|
443
|
+
function expireDueLeases() {
|
|
444
|
+
const out = [];
|
|
445
|
+
const t = Date.now();
|
|
446
|
+
for (const [id, rec] of domain.leaseStore.entries()) {
|
|
447
|
+
if (rec.status !== 'active') continue;
|
|
448
|
+
if (rec.expires_at && Date.parse(rec.expires_at) <= t) {
|
|
449
|
+
const r = expireLease(id, { system: true });
|
|
450
|
+
out.push(r);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* File or district claim with conflict deny + optional FIFO wait.
|
|
458
|
+
* leaseId is REQUIRED (Mode B / L3 lease-bound identity).
|
|
459
|
+
* @param {{ agentId: string, leaseId: string, kind?: 'file'|'district', resource: string, enqueue?: boolean }} req
|
|
460
|
+
*/
|
|
461
|
+
function claim(req = {}) {
|
|
462
|
+
const agentId = req.agentId;
|
|
463
|
+
const kind = req.kind === 'district' ? 'district' : 'file';
|
|
464
|
+
const resource = typeof req.resource === 'string' ? req.resource.trim() : '';
|
|
465
|
+
if (!agentId || !resource) {
|
|
466
|
+
return { ok: false, code: CODES.ERROR_INVALID_INPUT, reason: 'agentId_and_resource_required' };
|
|
467
|
+
}
|
|
468
|
+
if (!req.leaseId || typeof req.leaseId !== 'string') {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
code: CODES.DENY_IDENTITY,
|
|
472
|
+
reason: 'lease_required',
|
|
473
|
+
next_action: 'Pass leaseId from knosky agent-register / issueLease',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
expireDueLeases();
|
|
478
|
+
|
|
479
|
+
// Required lease bind check (anti-spoof)
|
|
480
|
+
{
|
|
481
|
+
const lease = domain.leaseStore.get(req.leaseId);
|
|
482
|
+
if (!lease || lease.status !== 'active' || lease.agentId !== agentId) {
|
|
483
|
+
const d = noteDeny(agentId, 'bad_lease');
|
|
484
|
+
const rc = emit(CODES.DENY_IDENTITY, agentId, 'swarm_claim', {
|
|
485
|
+
event: 'claim_denied',
|
|
486
|
+
reason: 'bad_lease',
|
|
487
|
+
kind,
|
|
488
|
+
resource: resource.slice(0, 200),
|
|
489
|
+
probe: d.probe,
|
|
490
|
+
});
|
|
491
|
+
writeHeatmap();
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
code: CODES.DENY_IDENTITY,
|
|
495
|
+
reason: 'bad_lease',
|
|
496
|
+
probe: d.probe,
|
|
497
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (lease.expires_at && Date.parse(lease.expires_at) <= Date.now()) {
|
|
501
|
+
expireLease(req.leaseId, { system: true });
|
|
502
|
+
const d = noteDeny(agentId, 'lease_expired');
|
|
503
|
+
const rc = emit(CODES.DENY_IDENTITY, agentId, 'swarm_claim', {
|
|
504
|
+
event: 'claim_denied',
|
|
505
|
+
reason: 'lease_expired',
|
|
506
|
+
kind,
|
|
507
|
+
resource: resource.slice(0, 200),
|
|
508
|
+
probe: d.probe,
|
|
509
|
+
});
|
|
510
|
+
writeHeatmap();
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
code: CODES.DENY_IDENTITY,
|
|
514
|
+
reason: 'lease_expired',
|
|
515
|
+
probe: d.probe,
|
|
516
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (backpressureHit(agentId)) {
|
|
522
|
+
const d = noteDeny(agentId, 'backpressure');
|
|
523
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_claim', {
|
|
524
|
+
event: 'claim_denied',
|
|
525
|
+
reason: 'backpressure',
|
|
526
|
+
kind,
|
|
527
|
+
resource: resource.slice(0, 200),
|
|
528
|
+
probe: d.probe,
|
|
529
|
+
});
|
|
530
|
+
writeHeatmap();
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
code: CODES.DENY,
|
|
534
|
+
reason: 'backpressure',
|
|
535
|
+
backpressure: true,
|
|
536
|
+
probe: d.probe,
|
|
537
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (claimCount(agentId) >= quotas.maxClaimsPerAgent) {
|
|
542
|
+
const d = noteDeny(agentId, 'quota_claims');
|
|
543
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_claim', {
|
|
544
|
+
event: 'claim_denied',
|
|
545
|
+
reason: 'quota_claims',
|
|
546
|
+
kind,
|
|
547
|
+
resource: resource.slice(0, 200),
|
|
548
|
+
probe: d.probe,
|
|
549
|
+
});
|
|
550
|
+
writeHeatmap();
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
code: CODES.DENY,
|
|
554
|
+
reason: 'quota_claims',
|
|
555
|
+
backpressure: true,
|
|
556
|
+
probe: d.probe,
|
|
557
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const key = resourceKey(kind, resource);
|
|
562
|
+
const held = claims.get(key);
|
|
563
|
+
if (held && held.agentId !== agentId) {
|
|
564
|
+
const d = noteDeny(agentId, 'claim_conflict');
|
|
565
|
+
const enqueue = req.enqueue !== false && quotas.enableFifoWait;
|
|
566
|
+
if (enqueue) {
|
|
567
|
+
const q = waiters.get(key) || [];
|
|
568
|
+
if (!q.includes(agentId)) q.push(agentId);
|
|
569
|
+
waiters.set(key, q);
|
|
570
|
+
}
|
|
571
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_claim', {
|
|
572
|
+
event: 'claim_conflict',
|
|
573
|
+
reason: 'claim_conflict',
|
|
574
|
+
kind,
|
|
575
|
+
resource: resource.slice(0, 200),
|
|
576
|
+
holder: held.agentId,
|
|
577
|
+
wait_position: enqueue ? (waiters.get(key) || []).indexOf(agentId) + 1 : null,
|
|
578
|
+
probe: d.probe,
|
|
579
|
+
});
|
|
580
|
+
writeHeatmap();
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
code: CODES.DENY,
|
|
584
|
+
reason: 'claim_conflict',
|
|
585
|
+
holder: held.agentId,
|
|
586
|
+
wait_position: enqueue ? (waiters.get(key) || []).indexOf(agentId) + 1 : null,
|
|
587
|
+
probe: d.probe,
|
|
588
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (held && held.agentId === agentId) {
|
|
593
|
+
noteAction(agentId);
|
|
594
|
+
return { ok: true, code: CODES.ALLOW, claimId: held.claimId, agentId, kind, resource, refreshed: true };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const claimId = mintId('claim');
|
|
598
|
+
const rec = {
|
|
599
|
+
claimId,
|
|
600
|
+
agentId,
|
|
601
|
+
kind,
|
|
602
|
+
resource,
|
|
603
|
+
since: nowIso(),
|
|
604
|
+
};
|
|
605
|
+
// Audit first — fail closed before mutating claims map
|
|
606
|
+
const rc = emit(CODES.ALLOW, agentId, 'swarm_claim', {
|
|
607
|
+
event: 'claim_granted',
|
|
608
|
+
claim_id: claimId,
|
|
609
|
+
kind,
|
|
610
|
+
resource: resource.slice(0, 200),
|
|
611
|
+
});
|
|
612
|
+
if (!rc.ok) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
code: CODES.DENY_AUDIT,
|
|
616
|
+
reason: 'audit_write_failed',
|
|
617
|
+
next_action: rc.reason || 'audit ledger unavailable',
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
claims.set(key, rec);
|
|
621
|
+
persistClaims();
|
|
622
|
+
noteAction(agentId);
|
|
623
|
+
writeHeatmap();
|
|
624
|
+
return {
|
|
625
|
+
ok: true,
|
|
626
|
+
code: CODES.ALLOW,
|
|
627
|
+
claimId,
|
|
628
|
+
agentId,
|
|
629
|
+
kind,
|
|
630
|
+
resource,
|
|
631
|
+
receipt_id: rc.receipt_id,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Release a file/district claim; promote FIFO waiter if any.
|
|
637
|
+
* @param {{ agentId: string, kind?: 'file'|'district', resource: string }} req
|
|
638
|
+
*/
|
|
639
|
+
function releaseClaim(req = {}) {
|
|
640
|
+
const agentId = req.agentId;
|
|
641
|
+
const kind = req.kind === 'district' ? 'district' : 'file';
|
|
642
|
+
const resource = typeof req.resource === 'string' ? req.resource.trim() : '';
|
|
643
|
+
if (!agentId || !resource) {
|
|
644
|
+
return { ok: false, code: CODES.ERROR_INVALID_INPUT, reason: 'agentId_and_resource_required' };
|
|
645
|
+
}
|
|
646
|
+
const key = resourceKey(kind, resource);
|
|
647
|
+
const held = claims.get(key);
|
|
648
|
+
if (!held) {
|
|
649
|
+
return { ok: true, code: CODES.ALLOW, noop: true, agentId, resource };
|
|
650
|
+
}
|
|
651
|
+
if (held.agentId !== agentId) {
|
|
652
|
+
const d = noteDeny(agentId, 'not_holder');
|
|
653
|
+
const rc = emit(CODES.DENY, agentId, 'swarm_release', {
|
|
654
|
+
event: 'release_denied',
|
|
655
|
+
reason: 'not_holder',
|
|
656
|
+
holder: held.agentId,
|
|
657
|
+
kind,
|
|
658
|
+
resource: resource.slice(0, 200),
|
|
659
|
+
probe: d.probe,
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
code: CODES.DENY,
|
|
664
|
+
reason: 'not_holder',
|
|
665
|
+
holder: held.agentId,
|
|
666
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
claims.delete(key);
|
|
670
|
+
persistClaims();
|
|
671
|
+
noteAction(agentId);
|
|
672
|
+
const rc = emit(CODES.ALLOW, agentId, 'swarm_release', {
|
|
673
|
+
event: 'claim_released',
|
|
674
|
+
claim_id: held.claimId,
|
|
675
|
+
kind,
|
|
676
|
+
resource: resource.slice(0, 200),
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Fairness: promote next FIFO waiter if any (do not auto-grant if they now exceed quota —
|
|
680
|
+
// leave them first in queue and let them call claim()).
|
|
681
|
+
const q = waiters.get(key) || [];
|
|
682
|
+
const next = q.shift();
|
|
683
|
+
if (q.length) waiters.set(key, q);
|
|
684
|
+
else waiters.delete(key);
|
|
685
|
+
|
|
686
|
+
writeHeatmap();
|
|
687
|
+
return {
|
|
688
|
+
ok: true,
|
|
689
|
+
code: CODES.ALLOW,
|
|
690
|
+
released: held.claimId,
|
|
691
|
+
next_waiter: next || null,
|
|
692
|
+
receipt_id: rc.ok ? rc.receipt_id : undefined,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Build operator heatmap snapshot (in-memory).
|
|
698
|
+
*/
|
|
699
|
+
function buildHeatmap() {
|
|
700
|
+
expireDueLeases();
|
|
701
|
+
const agents = {};
|
|
702
|
+
for (const [agentId, rec] of Object.entries(domain.agents || {})) {
|
|
703
|
+
const s = statsFor(agentId);
|
|
704
|
+
const t = Date.now();
|
|
705
|
+
s.actionTs = pruneWindow(s.actionTs, quotas.windowMs, t);
|
|
706
|
+
s.denyTs = pruneWindow(s.denyTs, quotas.antiProbeWindowMs, t);
|
|
707
|
+
agents[agentId] = {
|
|
708
|
+
role: rec.role || null,
|
|
709
|
+
active_leases: activeLeaseCount(agentId),
|
|
710
|
+
active_claims: claimCount(agentId),
|
|
711
|
+
actions_in_window: s.actionTs.length,
|
|
712
|
+
denies_in_probe_window: s.denyTs.length,
|
|
713
|
+
probe_trips: s.probeTrips,
|
|
714
|
+
backpressure:
|
|
715
|
+
s.actionTs.length >= quotas.maxActionsPerWindow ||
|
|
716
|
+
claimCount(agentId) >= quotas.maxClaimsPerAgent,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const claimList = [];
|
|
721
|
+
const byDistrict = {};
|
|
722
|
+
const byFilePrefix = {};
|
|
723
|
+
for (const c of claims.values()) {
|
|
724
|
+
claimList.push({
|
|
725
|
+
claim_id: c.claimId,
|
|
726
|
+
agent_id: c.agentId,
|
|
727
|
+
kind: c.kind,
|
|
728
|
+
resource: c.resource,
|
|
729
|
+
since: c.since,
|
|
730
|
+
});
|
|
731
|
+
if (c.kind === 'district') {
|
|
732
|
+
byDistrict[c.resource] = (byDistrict[c.resource] || 0) + 1;
|
|
733
|
+
} else {
|
|
734
|
+
const prefix = c.resource.split(/[\\/]/).slice(0, 2).join('/') || c.resource;
|
|
735
|
+
byFilePrefix[prefix] = (byFilePrefix[prefix] || 0) + 1;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let activeLeases = 0;
|
|
740
|
+
let expiredLeases = 0;
|
|
741
|
+
let revokedLeases = 0;
|
|
742
|
+
for (const rec of domain.leaseStore.values()) {
|
|
743
|
+
if (rec.status === 'active') activeLeases += 1;
|
|
744
|
+
else if (rec.status === 'expired') expiredLeases += 1;
|
|
745
|
+
else if (rec.status === 'revoked') revokedLeases += 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const waiting = {};
|
|
749
|
+
for (const [k, q] of waiters.entries()) waiting[k] = [...q];
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
v: 1,
|
|
753
|
+
layer: 'L3',
|
|
754
|
+
ts: nowIso(),
|
|
755
|
+
domain_root: domainRoot,
|
|
756
|
+
quotas: { ...quotas },
|
|
757
|
+
totals: {
|
|
758
|
+
agents: Object.keys(agents).length,
|
|
759
|
+
active_leases: activeLeases,
|
|
760
|
+
expired_leases: expiredLeases,
|
|
761
|
+
revoked_leases: revokedLeases,
|
|
762
|
+
active_claims: claimList.length,
|
|
763
|
+
wait_queues: Object.keys(waiting).length,
|
|
764
|
+
},
|
|
765
|
+
agents,
|
|
766
|
+
claims: claimList,
|
|
767
|
+
heat: {
|
|
768
|
+
districts: byDistrict,
|
|
769
|
+
file_prefixes: byFilePrefix,
|
|
770
|
+
},
|
|
771
|
+
waiters: waiting,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Write heatmap snapshot to `.knosky/swarm/heatmap.json`.
|
|
777
|
+
*/
|
|
778
|
+
function writeHeatmap() {
|
|
779
|
+
const snap = buildHeatmap();
|
|
780
|
+
atomicWriteSoft(paths.heatmapPath, snap);
|
|
781
|
+
return { ok: true, path: paths.heatmapPath, snapshot: snap };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Read heatmap from disk (or build live if missing).
|
|
786
|
+
*/
|
|
787
|
+
function status() {
|
|
788
|
+
if (existsSync(paths.heatmapPath)) {
|
|
789
|
+
try {
|
|
790
|
+
const disk = JSON.parse(readFileSync(paths.heatmapPath, 'utf8'));
|
|
791
|
+
// Refresh live counters on read
|
|
792
|
+
const live = buildHeatmap();
|
|
793
|
+
return { ok: true, path: paths.heatmapPath, heatmap: live, disk_ts: disk.ts || null };
|
|
794
|
+
} catch {
|
|
795
|
+
/* fall through */
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const live = writeHeatmap();
|
|
799
|
+
return { ok: true, path: paths.heatmapPath, heatmap: live.snapshot, disk_ts: live.snapshot.ts };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function listLeases() {
|
|
803
|
+
expireDueLeases();
|
|
804
|
+
const out = [];
|
|
805
|
+
for (const [id, rec] of domain.leaseStore.entries()) {
|
|
806
|
+
out.push({
|
|
807
|
+
leaseId: id,
|
|
808
|
+
agentId: rec.agentId,
|
|
809
|
+
status: rec.status,
|
|
810
|
+
created_at: rec.created_at || null,
|
|
811
|
+
expires_at: rec.expires_at || null,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function listAgents() {
|
|
818
|
+
return { ...domain.agents };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
domainRoot,
|
|
823
|
+
domain,
|
|
824
|
+
paths,
|
|
825
|
+
quotas,
|
|
826
|
+
registerAgent,
|
|
827
|
+
issueLease,
|
|
828
|
+
expireLease,
|
|
829
|
+
revokeLease,
|
|
830
|
+
expireDueLeases,
|
|
831
|
+
claim,
|
|
832
|
+
releaseClaim,
|
|
833
|
+
buildHeatmap,
|
|
834
|
+
writeHeatmap,
|
|
835
|
+
status,
|
|
836
|
+
listLeases,
|
|
837
|
+
listAgents,
|
|
838
|
+
/** @internal test helpers */
|
|
839
|
+
_claims: claims,
|
|
840
|
+
_waiters: waiters,
|
|
841
|
+
_stats: agentStats,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Convenience: read heatmap JSON for CLI without creating a full coordinator.
|
|
847
|
+
* @param {string} [domainRoot]
|
|
848
|
+
* @param {string} [cityPath]
|
|
849
|
+
*/
|
|
850
|
+
export function readSwarmHeatmap(domainRoot, cityPath) {
|
|
851
|
+
const root = resolveDomainRoot(cityPath, domainRoot);
|
|
852
|
+
const { heatmapPath } = swarmPaths(root);
|
|
853
|
+
if (!existsSync(heatmapPath)) {
|
|
854
|
+
return { ok: false, reason: 'no_heatmap', path: heatmapPath, domainRoot: root };
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const heatmap = JSON.parse(readFileSync(heatmapPath, 'utf8'));
|
|
858
|
+
return { ok: true, path: heatmapPath, domainRoot: root, heatmap };
|
|
859
|
+
} catch (err) {
|
|
860
|
+
return {
|
|
861
|
+
ok: false,
|
|
862
|
+
reason: err && err.message ? err.message : String(err),
|
|
863
|
+
path: heatmapPath,
|
|
864
|
+
domainRoot: root,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|