@staix/agent-hub 0.12.31 → 0.12.33
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 +20 -1
- package/README.md +14 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +13 -8
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +141 -25
- package/docs/quickstart.md +10 -6
- package/docs/security.md +18 -9
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verified.json +24 -16
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/reset.ts +13 -1
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +227 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +360 -199
- package/src/cli/upgrade.ts +278 -25
- package/src/hub/bus.ts +144 -14
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +577 -179
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/restart.ts +12 -1
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
package/src/hub/bus.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope
|
|
|
2
2
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
4
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
5
|
+
import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
5
6
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
6
7
|
|
|
7
8
|
export type { DeliveryReceipt } from "./peers.ts";
|
|
@@ -64,6 +65,8 @@ export interface BusSnapshot {
|
|
|
64
65
|
attempts: Record<string, number>;
|
|
65
66
|
withdrawn: string[];
|
|
66
67
|
manualPaused?: string[];
|
|
68
|
+
/** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
|
|
69
|
+
deferredHub?: Envelope[];
|
|
67
70
|
journal?: ReturnType<DeliveryJournal["snapshot"]>;
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -142,6 +145,12 @@ export class Bus {
|
|
|
142
145
|
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
143
146
|
onDeliveryFailed?: (peer: PeerId) => void;
|
|
144
147
|
private recoveryHeld = false;
|
|
148
|
+
private deferHub = false;
|
|
149
|
+
private readonly deferredHub = new Map<string, Envelope>();
|
|
150
|
+
private flushingHub = false;
|
|
151
|
+
private replayingHub = false;
|
|
152
|
+
private deferredEvents: BusEvent[] = [];
|
|
153
|
+
private deferredNudges: (() => void)[] = [];
|
|
145
154
|
private steering = 0;
|
|
146
155
|
private condensing = 0;
|
|
147
156
|
private closed = false;
|
|
@@ -162,6 +171,17 @@ export class Bus {
|
|
|
162
171
|
}
|
|
163
172
|
}
|
|
164
173
|
|
|
174
|
+
/** Apply future batching and admission limits without disturbing queued work or its existing deadline. */
|
|
175
|
+
configureScheduling(config: Pick<BusOptions, "batchMax" | "batchMs" | "queueCap">): void {
|
|
176
|
+
const { batchMax, batchMs, queueCap } = config;
|
|
177
|
+
if (!Number.isSafeInteger(batchMax) || batchMax < 1 || batchMax > 2_147_483_647 ||
|
|
178
|
+
!Number.isFinite(batchMs) || batchMs < 0 || batchMs > 2_147_483_647 ||
|
|
179
|
+
!Number.isSafeInteger(queueCap) || queueCap < 1 || queueCap > 2_147_483_647) {
|
|
180
|
+
throw new Error("invalid bus scheduling configuration");
|
|
181
|
+
}
|
|
182
|
+
Object.assign(this.opts, { batchMax, batchMs, queueCap });
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
add(peer: PeerAdapter): void {
|
|
166
186
|
this.peers.set(peer.id, peer);
|
|
167
187
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
@@ -262,10 +282,61 @@ export class Bus {
|
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
/** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
|
|
265
|
-
setRecoveryHold(held: boolean): void {
|
|
266
|
-
this.
|
|
267
|
-
if (
|
|
268
|
-
|
|
285
|
+
setRecoveryHold(held: boolean, deferHub = false): void {
|
|
286
|
+
if (this.replayingHub) throw new RecoveryDeferredHubError("recovery notice release is already in progress");
|
|
287
|
+
if (held) {
|
|
288
|
+
this.recoveryHeld = true;
|
|
289
|
+
this.deferHub = deferHub;
|
|
290
|
+
// An older target cannot carry this additive field: materialize before commit's expected integrity.
|
|
291
|
+
if (!deferHub) this.flushDeferredHub();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
|
|
295
|
+
// leaves the hold and the original pending head intact; a crash after it resumes from the journal.
|
|
296
|
+
this.flushDeferredHub();
|
|
297
|
+
this.recoveryHeld = false;
|
|
298
|
+
this.onQueues?.();
|
|
299
|
+
for (const id of this.peers.keys()) void this.drain(id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private flushDeferredHub(): void {
|
|
303
|
+
if (this.flushingHub) return;
|
|
304
|
+
if (this.storageError) this.persist();
|
|
305
|
+
this.replayingHub = true;
|
|
306
|
+
try { while (this.deferredHub.size) {
|
|
307
|
+
const env = this.deferredHub.values().next().value!;
|
|
308
|
+
const beforeDeferred = [...this.deferredHub], beforeSeen = [...this.seen], beforeWithdrawn = [...this.withdrawn];
|
|
309
|
+
const affected = new Map(this.audience(env).map(peer => {
|
|
310
|
+
const queue = this.queues.get(peer);
|
|
311
|
+
return [peer, queue ? { queue, contents: [...queue] } : undefined] as const;
|
|
312
|
+
}));
|
|
313
|
+
const outcomes = this.pendingOutcomes.length;
|
|
314
|
+
this.flushingHub = true;
|
|
315
|
+
this.deferredEvents = []; this.deferredNudges = [];
|
|
316
|
+
try {
|
|
317
|
+
this.deferredHub.delete(env.id);
|
|
318
|
+
this.publishNow(env);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// Only publication state is transactional. A live peer's preface, pauses, attempts and
|
|
321
|
+
// recovery mode are independent state, not something a failed checkpoint may reload.
|
|
322
|
+
this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
|
|
323
|
+
this.seen.clear(); for (const [id, seen] of beforeSeen) this.seen.set(id, seen);
|
|
324
|
+
this.withdrawn.clear(); for (const id of beforeWithdrawn) this.withdrawn.add(id);
|
|
325
|
+
for (const [peer, queue] of affected) {
|
|
326
|
+
if (queue) { queue.queue.splice(0, queue.queue.length, ...queue.contents); this.queues.set(peer, queue.queue); }
|
|
327
|
+
else this.queues.delete(peer);
|
|
328
|
+
}
|
|
329
|
+
this.pendingOutcomes.length = outcomes;
|
|
330
|
+
this.deferredEvents = []; this.deferredNudges = [];
|
|
331
|
+
throw new RecoveryDeferredHubError("deferred hub notice checkpoint failed; recovery remains held; restore delivery storage, then retry release or abort");
|
|
332
|
+
} finally { this.flushingHub = false; }
|
|
333
|
+
// Publication observers run only after the durable transition. Reentrant notices join the FIFO tail.
|
|
334
|
+
const events = this.deferredEvents; this.deferredEvents = [];
|
|
335
|
+
for (const event of events) this.emit(event);
|
|
336
|
+
const nudges = this.deferredNudges; this.deferredNudges = [];
|
|
337
|
+
for (const nudge of nudges) nudge();
|
|
338
|
+
this.onQueues?.();
|
|
339
|
+
} } finally { this.replayingHub = false; }
|
|
269
340
|
}
|
|
270
341
|
|
|
271
342
|
get isRecoveryHeld(): boolean {
|
|
@@ -285,7 +356,7 @@ export class Bus {
|
|
|
285
356
|
let delay = 1;
|
|
286
357
|
while (this.draining.size || this.steering || this.condensing) {
|
|
287
358
|
if (Date.now() >= deadline) {
|
|
288
|
-
this.
|
|
359
|
+
if (!heldBefore) this.setRecoveryHold(false);
|
|
289
360
|
throw new Error(`recovery fence timed out after ${timeoutMs} ms with ${this.draining.size + this.steering + this.condensing} deliveries still in flight; retry the operation`);
|
|
290
361
|
}
|
|
291
362
|
await Bun.sleep(delay);
|
|
@@ -303,6 +374,7 @@ export class Bus {
|
|
|
303
374
|
attempts: Object.fromEntries(this.attempts),
|
|
304
375
|
withdrawn: [...this.withdrawn],
|
|
305
376
|
manualPaused: [...this.manualPaused],
|
|
377
|
+
...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
|
|
306
378
|
};
|
|
307
379
|
if (includeJournal && this.journal) out.journal = this.journal.snapshot();
|
|
308
380
|
return out;
|
|
@@ -330,6 +402,10 @@ export class Bus {
|
|
|
330
402
|
}
|
|
331
403
|
|
|
332
404
|
private loadSnapshot(snapshot: BusSnapshot): void {
|
|
405
|
+
const deferred = validateDeferredHub(snapshot.deferredHub, undefined, new Set([...snapshot.seen.map(e => e.id), ...Object.values(snapshot.queues).flat().map(e => e.id)]));
|
|
406
|
+
if (deferred.length) { this.recoveryHeld = true; this.deferHub = true; }
|
|
407
|
+
this.deferredHub.clear();
|
|
408
|
+
for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
|
|
333
409
|
this.queues.clear();
|
|
334
410
|
for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
|
|
335
411
|
this.prefaces.clear();
|
|
@@ -521,7 +597,7 @@ export class Bus {
|
|
|
521
597
|
|
|
522
598
|
queuedBacklogIds(id: PeerId): Set<number> {
|
|
523
599
|
const ids = new Set<number>();
|
|
524
|
-
const retained = [...
|
|
600
|
+
const retained = [...this.pendingEnvelopes(id), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
|
|
525
601
|
for (const env of retained) {
|
|
526
602
|
const backlog = env.refs?.backlog;
|
|
527
603
|
if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
|
|
@@ -591,8 +667,52 @@ export class Bus {
|
|
|
591
667
|
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
592
668
|
}
|
|
593
669
|
|
|
594
|
-
/**
|
|
670
|
+
/** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
|
|
671
|
+
discardDeferredHub(reason: string): string[] {
|
|
672
|
+
if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
|
|
673
|
+
const pending = [...this.deferredHub];
|
|
674
|
+
const settled: string[] = [];
|
|
675
|
+
try {
|
|
676
|
+
this.journal.transaction(() => {
|
|
677
|
+
for (const [, env] of pending) for (const peer of new Set(env.to ?? [])) {
|
|
678
|
+
const id = `q:${peer}:${env.id}`;
|
|
679
|
+
this.journal!.createDelivery({ id, peer, state: "queued", createdAt: env.ts, originals: [env], out: [env] });
|
|
680
|
+
const row = this.journal!.get(id)!;
|
|
681
|
+
this.journal!.resolve(id, row.revision, "discard", reason, row.revision, "user");
|
|
682
|
+
settled.push(id);
|
|
683
|
+
}
|
|
684
|
+
this.deferredHub.clear();
|
|
685
|
+
this.persist();
|
|
686
|
+
});
|
|
687
|
+
} catch (error) {
|
|
688
|
+
this.deferredHub.clear(); for (const [id, env] of pending) this.deferredHub.set(id, env);
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
return settled;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Accepted pending work, including notices withheld from the recovery integrity view. */
|
|
695
|
+
pendingEnvelopes(peer: PeerId): Envelope[] {
|
|
696
|
+
return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Returns the peers the envelope was accepted for, including durable deferred notices. */
|
|
595
700
|
publish(env: Envelope): PeerId[] {
|
|
701
|
+
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
702
|
+
if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
|
|
703
|
+
if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
|
|
704
|
+
if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
|
|
705
|
+
const targets = this.audience(env);
|
|
706
|
+
const copy = structuredClone({ ...env, to: targets });
|
|
707
|
+
this.deferredHub.set(copy.id, copy);
|
|
708
|
+
try { this.persist(); }
|
|
709
|
+
catch (error) { this.deferredHub.delete(copy.id); throw error; }
|
|
710
|
+
return targets;
|
|
711
|
+
}
|
|
712
|
+
return this.publishNow(env);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private publishNow(env: Envelope): PeerId[] {
|
|
596
716
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
597
717
|
if (this.seen.has(env.id)) return [];
|
|
598
718
|
this.seen.set(env.id, env);
|
|
@@ -627,7 +747,7 @@ export class Bus {
|
|
|
627
747
|
} } finally { this.suppressDrain = false; }
|
|
628
748
|
if (this.journal) {
|
|
629
749
|
this.persist();
|
|
630
|
-
this.onQueues?.();
|
|
750
|
+
if (!this.flushingHub) this.onQueues?.();
|
|
631
751
|
for (const id of targets) {
|
|
632
752
|
const peer = this.peers.get(id);
|
|
633
753
|
if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
@@ -670,7 +790,7 @@ export class Bus {
|
|
|
670
790
|
|
|
671
791
|
/** A recently published envelope, for resolving `reply_to`. */
|
|
672
792
|
get(id: string): Envelope | undefined {
|
|
673
|
-
return this.seen.get(id);
|
|
793
|
+
return this.seen.get(id) ?? this.deferredHub.get(id);
|
|
674
794
|
}
|
|
675
795
|
|
|
676
796
|
/** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
|
|
@@ -685,10 +805,10 @@ export class Bus {
|
|
|
685
805
|
*/
|
|
686
806
|
withdraw(envelopeId: string): boolean {
|
|
687
807
|
this.markWithdrawn(envelopeId);
|
|
688
|
-
let removed =
|
|
808
|
+
let removed = this.deferredHub.delete(envelopeId);
|
|
689
809
|
for (const queue of this.queues.values()) {
|
|
690
810
|
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
691
|
-
if (i !== -1)
|
|
811
|
+
if (i !== -1) { queue.splice(i, 1); removed = true; }
|
|
692
812
|
}
|
|
693
813
|
if (removed) this.onQueues?.();
|
|
694
814
|
this.persist();
|
|
@@ -698,8 +818,15 @@ export class Bus {
|
|
|
698
818
|
/** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
|
|
699
819
|
revokeSupervision(peer: PeerId): number {
|
|
700
820
|
const queue = this.queues.get(peer);
|
|
701
|
-
if (!queue && !this.preparing.has(peer)) return 0;
|
|
702
821
|
let removed = 0;
|
|
822
|
+
for (const [id, env] of this.deferredHub) {
|
|
823
|
+
if (!env.refs?.supervision || !env.to?.includes(peer)) continue;
|
|
824
|
+
const remaining = env.to.filter(target => target !== peer);
|
|
825
|
+
if (remaining.length) this.deferredHub.set(id, { ...env, to: remaining });
|
|
826
|
+
else this.deferredHub.delete(id);
|
|
827
|
+
removed++;
|
|
828
|
+
}
|
|
829
|
+
if (!queue && !this.preparing.has(peer)) { if (removed) this.persist(); return removed; }
|
|
703
830
|
const discarded = new Set<string>();
|
|
704
831
|
for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
|
|
705
832
|
const env = queue![i]!;
|
|
@@ -785,9 +912,11 @@ export class Bus {
|
|
|
785
912
|
}
|
|
786
913
|
if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
|
|
787
914
|
// Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
|
|
788
|
-
|
|
915
|
+
const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
|
|
916
|
+
const nudge = () => { try { this.opts.onImportantQueued?.(id, counts); } catch { /* a nudge never stops an enqueue */ } };
|
|
917
|
+
if (this.flushingHub) this.deferredNudges.push(nudge); else nudge();
|
|
789
918
|
}
|
|
790
|
-
this.onQueues?.();
|
|
919
|
+
if (!this.flushingHub) this.onQueues?.();
|
|
791
920
|
if (!this.suppressDrain) this.persist();
|
|
792
921
|
if (!this.suppressDrain) void this.drain(id);
|
|
793
922
|
}
|
|
@@ -1049,6 +1178,7 @@ export class Bus {
|
|
|
1049
1178
|
}
|
|
1050
1179
|
|
|
1051
1180
|
private emit(e: BusEvent): void {
|
|
1181
|
+
if (this.flushingHub) { this.deferredEvents.push(e); return; }
|
|
1052
1182
|
for (const tap of this.taps) tap(e);
|
|
1053
1183
|
}
|
|
1054
1184
|
}
|
package/src/hub/cohorts.ts
CHANGED
|
@@ -102,7 +102,7 @@ export class Cohorts {
|
|
|
102
102
|
* in, records owner changes (each bumps the revision and voids that member's intent), and lifts a silent cohort that
|
|
103
103
|
* an owner without verified facts joins. Returns what happened, for the notices.
|
|
104
104
|
*/
|
|
105
|
-
join(task: Task, others: Task[], gen: (t: Task) => number, handed: (t: Task) => number = () => Date.now()): { cohort: Cohort; formed: boolean; lifted: boolean } | undefined {
|
|
105
|
+
join(task: Task, others: Task[], gen: (t: Task) => number, handed: (t: Task) => number = () => Date.now(), allowSilence = true): { cohort: Cohort; formed: boolean; lifted: boolean } | undefined {
|
|
106
106
|
this.gc();
|
|
107
107
|
const all = [task, ...others].filter((t) => t.owner);
|
|
108
108
|
const found = [...new Set(all.map((t) => this.of(t.id)).filter((c): c is Cohort => !!c))];
|
|
@@ -133,7 +133,7 @@ export class Cohorts {
|
|
|
133
133
|
}
|
|
134
134
|
if (changed) cohort.revision++;
|
|
135
135
|
const owners = [...new Set([...cohort.members.values()].map((m) => m.owner))];
|
|
136
|
-
if (formed) cohort.silent = this.d.silence(owners);
|
|
136
|
+
if (formed) cohort.silent = allowSilence && this.d.silence(owners);
|
|
137
137
|
else if (cohort.silent && changed && !this.d.silence(owners)) cohort.silent = false;
|
|
138
138
|
// Lifted: a silent cohort (or a silent one merged into this) is no longer silent, and its members must hear it.
|
|
139
139
|
return { cohort, formed, lifted: wasSilent && !cohort.silent };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { HubConfig } from "./daemon.ts";
|
|
2
|
+
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
3
|
+
import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
|
|
4
|
+
import { CLASSES } from "./board.ts";
|
|
5
|
+
import { permissionDefaults } from "./permission-mode.ts";
|
|
6
|
+
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
7
|
+
import { catalogAssignments } from "./roles.ts";
|
|
8
|
+
import { resolveProfile } from "./profiles.ts";
|
|
9
|
+
import { taskSweepConfig } from "./task-sweep.ts";
|
|
10
|
+
import { projectionConfig } from "./text-projection.ts";
|
|
11
|
+
|
|
12
|
+
export type ApplyClass = "live" | "next use" | "restart";
|
|
13
|
+
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
14
|
+
export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
|
|
15
|
+
export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
|
|
16
|
+
export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
|
|
17
|
+
export interface ConfigAuthorizationInput {
|
|
18
|
+
key: string;
|
|
19
|
+
before: unknown;
|
|
20
|
+
after: unknown;
|
|
21
|
+
source: "config" | "routing";
|
|
22
|
+
tracked: boolean | undefined;
|
|
23
|
+
authority: "ordinary" | "settings" | "terminal";
|
|
24
|
+
/** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
|
|
25
|
+
confirmed?: boolean;
|
|
26
|
+
/** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
|
|
27
|
+
acceptedFallback?: boolean;
|
|
28
|
+
/** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
|
|
29
|
+
settledHolderChange?: boolean;
|
|
30
|
+
guard?: ReloadGuard;
|
|
31
|
+
}
|
|
32
|
+
export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
|
|
33
|
+
|
|
34
|
+
/** Pure per-leaf authority. Schema validation and named-key selection precede this decision. */
|
|
35
|
+
export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
|
|
36
|
+
const { key, before, after } = input;
|
|
37
|
+
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
38
|
+
if (same(before, after)) return result(true, false, "unchanged");
|
|
39
|
+
const supplied = input.guard;
|
|
40
|
+
const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
|
|
41
|
+
const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
|
|
42
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
|
|
43
|
+
const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
|
|
44
|
+
const numeric = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
45
|
+
let tightening = false;
|
|
46
|
+
if (guard?.floor !== undefined && same(after, guard.floor)) tightening = true;
|
|
47
|
+
if (key.startsWith("tasks.wip_limits.") && numeric(before) && numeric(after) && after <= before) tightening = true;
|
|
48
|
+
if (key === "routing.constraints.pii" && before === "off" && after === "local_only") tightening = true;
|
|
49
|
+
if (key === "routing.signals.pii_screen" && before === "off" && after === "local") tightening = true;
|
|
50
|
+
if (key === "routing.signals.pii_patterns" && strings(after) && (before === undefined || strings(before) && before.every(pattern => after.includes(pattern)))) tightening = true;
|
|
51
|
+
if (key.startsWith("roles.") && subset(after, before)) tightening = true;
|
|
52
|
+
if (key.startsWith("capabilities.") && strings(after) && (before === undefined || subset(after, before))) tightening = true;
|
|
53
|
+
if (["limits.sender_per_min", "limits.pair_per_min", "limits.important_per_hour"].includes(key)
|
|
54
|
+
&& numeric(before) && numeric(after) && after > 0 && (before === 0 || after <= before)) tightening = true;
|
|
55
|
+
if (key === "limits.repeat_window_s" && numeric(before) && numeric(after) && after >= before) tightening = true;
|
|
56
|
+
if (key === "conductor.feed") {
|
|
57
|
+
const levels = ["off", "own", "all"];
|
|
58
|
+
if (typeof before === "string" && typeof after === "string" && levels.includes(before) && levels.includes(after) && levels.indexOf(after) < levels.indexOf(before)) tightening = true;
|
|
59
|
+
}
|
|
60
|
+
if (["inference.enabled", "inference.triage", "review.adaptive"].includes(key) && before === true && after === false) tightening = true;
|
|
61
|
+
if (key === "review.min_reviews" && numeric(before) && numeric(after) && after >= before) tightening = true;
|
|
62
|
+
if (tightening && !input.settledHolderChange) return result(true, false, "semantic tightening");
|
|
63
|
+
if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
|
|
64
|
+
if (input.acceptedFallback && !machine) {
|
|
65
|
+
const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
|
|
66
|
+
return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
|
|
67
|
+
}
|
|
68
|
+
// Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
|
|
69
|
+
if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
|
|
70
|
+
if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
|
|
71
|
+
if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
|
|
72
|
+
if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
|
|
73
|
+
return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
|
|
74
|
+
}
|
|
75
|
+
type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
|
|
76
|
+
type Authority = "ordinary" | "settings" | "terminal";
|
|
77
|
+
type Check = (value: unknown, key: string) => void;
|
|
78
|
+
const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
|
|
79
|
+
const object = (value: unknown, key: string): Record<string, unknown> => {
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail(key, "must be an object");
|
|
81
|
+
return value as Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
const bool: Check = (v, k) => { if (typeof v !== "boolean") fail(k, "must be a boolean"); };
|
|
84
|
+
const text: Check = (v, k) => { if (typeof v !== "string" || v.length > 16_384 || /[\x00-\x1f\x7f]/.test(v)) fail(k, "must be bounded text"); };
|
|
85
|
+
const number = (min = 0, max = Number.MAX_SAFE_INTEGER, integer = false): Check => (v, k) => {
|
|
86
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isSafeInteger(v)) fail(k, "number outside bounds");
|
|
87
|
+
};
|
|
88
|
+
const words = (values: readonly string[]): Check => (v, k) => { if (typeof v !== "string" || !values.includes(v)) fail(k, "unknown choice"); };
|
|
89
|
+
const strings: Check = (v, k) => { if (!Array.isArray(v) || v.length > 1024) return fail(k, "must be a bounded string list"); for (const x of v) text(x, k); };
|
|
90
|
+
const argv: Check = (v, k) => { strings(v, k); if (!(v as string[]).length || (v as string[]).some(x => !x)) fail(k, "must be a nonempty argv"); };
|
|
91
|
+
const shapes = new WeakMap<Check, { fields?: Record<string, Check>; item?: Check; keys?: readonly string[] }>();
|
|
92
|
+
const map = (check: Check, keys?: readonly string[]): Check => {
|
|
93
|
+
const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
94
|
+
if (["__proto__", "constructor", "prototype"].includes(key) || keys && !keys.includes(key)) fail(`${k}.${key}`, "unknown key"); check(value, `${k}.${key}`);
|
|
95
|
+
} };
|
|
96
|
+
shapes.set(fn, { item: check, keys }); return fn;
|
|
97
|
+
};
|
|
98
|
+
const block = (schema: Record<string, Check>): Check => {
|
|
99
|
+
const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
100
|
+
if (!Object.hasOwn(schema, key)) fail(`${k}.${key}`, "unknown key"); if (value !== undefined) schema[key]!(value, `${k}.${key}`);
|
|
101
|
+
} };
|
|
102
|
+
shapes.set(fn, { fields: schema }); return fn;
|
|
103
|
+
};
|
|
104
|
+
const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
|
|
105
|
+
const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
|
|
106
|
+
const schema: Record<string, Check> = {
|
|
107
|
+
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
108
|
+
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
109
|
+
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
110
|
+
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
111
|
+
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
112
|
+
roster: peerMap(bool), roles: map(strings), planner: words(PLANNER_PEERS), profile: words(["dev", "research", "writing", "general"]),
|
|
113
|
+
profile_overrides: (v, k) => { map(() => {})(v, k); const fields = object(v, k); for (const name of ["classes", "role_text", "catalog_roles"]) if (fields[name] !== undefined) map(() => {})(fields[name], `${k}.${name}`); resolveProfile("dev", v); },
|
|
114
|
+
catalog_roles: (v, k) => { map(() => {})(v, k); catalogAssignments(v); },
|
|
115
|
+
pii: block({ paths: strings }), data: block({ readonly: strings }),
|
|
116
|
+
text_projection: v => { projectionConfig(v); },
|
|
117
|
+
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
118
|
+
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
119
|
+
context: block({ gate: number(0, 1), stale_min: timerMinutes }),
|
|
120
|
+
inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
|
|
121
|
+
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
122
|
+
pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
|
|
123
|
+
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
124
|
+
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
125
|
+
local: block({ deny: strings, bash_network: (v, k) => { if (v !== "direct") bool(v, k); }, network_allow: strings, max_steps: number(1, 1_000_000, true), read_allow: strings }),
|
|
126
|
+
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
|
|
127
|
+
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
128
|
+
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
129
|
+
if (value === undefined) continue;
|
|
130
|
+
if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
|
|
131
|
+
else if (key === "runner") words(["container"])(value, `${k}.${key}`);
|
|
132
|
+
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
133
|
+
} },
|
|
134
|
+
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
135
|
+
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), ["pi", "codex", "kimi"])(v, k); peerStartConfig(v); },
|
|
136
|
+
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
137
|
+
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
138
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
139
|
+
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
140
|
+
};
|
|
141
|
+
export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> { block(schema)(raw, "config"); }
|
|
142
|
+
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
143
|
+
const raw = { ...object(value, "config") };
|
|
144
|
+
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
145
|
+
validateRawConfig(raw);
|
|
146
|
+
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
147
|
+
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
148
|
+
if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const restart: Record<string, string> = {
|
|
152
|
+
"pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
|
|
153
|
+
"pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
|
|
154
|
+
"routing.pi": "Pi relay context and model publication are bound to the running relay",
|
|
155
|
+
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
156
|
+
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
157
|
+
checks: "Bounded runner and completion-check contracts are constructed together",
|
|
158
|
+
pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
|
|
159
|
+
"local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
|
|
160
|
+
"local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
|
|
161
|
+
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
162
|
+
text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
163
|
+
};
|
|
164
|
+
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
|
|
165
|
+
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
166
|
+
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
167
|
+
export function configApply(key: string): ApplyRule {
|
|
168
|
+
const top = key.split(".")[0]!;
|
|
169
|
+
if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
|
|
170
|
+
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
171
|
+
const special: Record<string, readonly string[]> = {
|
|
172
|
+
task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
173
|
+
checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
|
|
174
|
+
};
|
|
175
|
+
let check = schema[top];
|
|
176
|
+
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
177
|
+
const shape = check && shapes.get(check);
|
|
178
|
+
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
179
|
+
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
180
|
+
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
181
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
|
|
182
|
+
else return fail(key, "unknown configuration key");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
|
|
186
|
+
return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
|
|
187
|
+
}
|
|
188
|
+
if (nextMemory.has(key)) return { applies: "next use" };
|
|
189
|
+
for (const [path, reason] of Object.entries(restart)) if (key === path || key.startsWith(`${path}.`)) return { applies: "restart", reason };
|
|
190
|
+
if ([...live].some(path => key === path || key.startsWith(`${path}.`))) return { applies: "live" };
|
|
191
|
+
return { applies: "next use" };
|
|
192
|
+
}
|
|
193
|
+
const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
|
|
194
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
195
|
+
const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
|
|
196
|
+
export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
|
|
197
|
+
const changes: ConfigChange[] = [];
|
|
198
|
+
const walk = (before: unknown, after: unknown, key: string) => {
|
|
199
|
+
if (same(before, after)) return;
|
|
200
|
+
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
201
|
+
for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
|
|
202
|
+
} else changes.push({ key, before, after, ...configApply(key) });
|
|
203
|
+
};
|
|
204
|
+
const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
|
|
205
|
+
walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Legacy all-or-nothing authority helper; daemon reload uses authorizeConfigLeaf for scoped publication. */
|
|
209
|
+
export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], previousLayers: readonly TrustLayer[], guardedRules: readonly ReloadGuard[], authority: Authority, defaults?: object): void {
|
|
210
|
+
const complete = [...layers, ...previousLayers.filter(prior => !layers.some(layer => layer.name === prior.name)).map(prior => ({ ...prior, raw: {} }))];
|
|
211
|
+
for (const layer of complete) {
|
|
212
|
+
const prior = previousLayers.find(p => p.name === layer.name)?.raw;
|
|
213
|
+
const routing = layer.name.endsWith(".toml");
|
|
214
|
+
const paths = new Map<string, { path: readonly string[]; floor?: unknown; machine: boolean; registered?: boolean }>();
|
|
215
|
+
if (!routing) for (const key of ALL_MACHINE_LOCAL) {
|
|
216
|
+
const path = key.split("."), value = at(layer.raw, path), old = at(prior, path);
|
|
217
|
+
const children = new Set([...(value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value) : []), ...(old && typeof old === "object" && !Array.isArray(old) ? Object.keys(old) : [])]);
|
|
218
|
+
if (children.size) for (const child of children) paths.set(`${key}.${child}`, { path: [...path, child], machine: true });
|
|
219
|
+
else paths.set(key, { path, machine: true });
|
|
220
|
+
}
|
|
221
|
+
for (const rule of guardedRules) if (rule.risk === "raises" && rule.store === (routing ? "routing" : "config")) {
|
|
222
|
+
const key = rule.path.join(".");
|
|
223
|
+
paths.set(key, { path: rule.path, floor: rule.floor, registered: true, machine: !routing && ALL_MACHINE_LOCAL.some(p => key === p || key.startsWith(`${p}.`)) });
|
|
224
|
+
}
|
|
225
|
+
for (const [key, guard] of paths) {
|
|
226
|
+
const value = at(layer.raw, guard.path), old = at(prior, guard.path);
|
|
227
|
+
if (same(value, old)) continue;
|
|
228
|
+
const inherit = guard.registered && value === undefined && old !== undefined;
|
|
229
|
+
if (!inherit && guard.machine && defaults && same(value, at(defaults, guard.path))) continue;
|
|
230
|
+
// Removal can expose a lower layer: registry inherit is guarded just like a direct raise.
|
|
231
|
+
if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
|
|
232
|
+
// Empty machine-local declarations choose no program/path and are stripped at startup.
|
|
233
|
+
if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
|
|
234
|
+
let tracked = configTracked(cwd, layer.name);
|
|
235
|
+
if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
|
|
236
|
+
if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
|
|
237
|
+
if (guard.machine && authority === "settings" && !guard.registered) fail(`${layer.name}:${key}`, "non-editable machine policy requires terminal authority");
|
|
238
|
+
if (value !== undefined && guard.floor !== undefined && same(value, guard.floor)) continue;
|
|
239
|
+
if (authority === "ordinary") fail(`${layer.name}:${key}`, "guarded change requires settings or terminal authority");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|