pi-mega-compact 0.20.8 → 0.20.10
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/dist/config/vector-cortex.js +29 -0
- package/dist/config.js +1 -1
- package/dist/extensions/dashboard-server/route-dispatch.js +8 -1
- package/dist/extensions/dashboard-server/routes-rag-settings-vector-cortex.js +2 -0
- package/dist/extensions/dashboard-server/routes-vector-cortex-heal.js +42 -3
- package/dist/extensions/dashboard-server/routes-vector-cortex-repair.js +63 -0
- package/dist/extensions/dashboard-server/routes-vector-cortex.js +3 -1
- package/dist/extensions/dashboard-server/routes.js +1 -1
- package/dist/src/config/vector-cortex.js +29 -0
- package/dist/src/config.js +1 -1
- package/dist/src/vector-cortex/heal/_repair-fixture.js +70 -0
- package/dist/src/vector-cortex/heal/_restore-fixture.js +96 -0
- package/dist/src/vector-cortex/heal/_vc6b-helpers.js +35 -0
- package/dist/src/vector-cortex/heal/controller.js +165 -0
- package/dist/src/vector-cortex/heal/rebuild.js +136 -0
- package/dist/src/vector-cortex/heal/repair-emit.js +83 -0
- package/dist/src/vector-cortex/heal/repair-types.js +74 -0
- package/dist/src/vector-cortex/heal/restore-emit.js +70 -0
- package/dist/src/vector-cortex/heal/restore-readers.js +114 -0
- package/dist/src/vector-cortex/heal/restore-types.js +66 -0
- package/dist/src/vector-cortex/heal/restore.js +140 -0
- package/dist/src/vector-cortex/heal/types.js +2 -0
- package/dist/src/vector-cortex/heal/verify.js +75 -0
- package/dist/vector-cortex/heal/_repair-fixture.js +70 -0
- package/dist/vector-cortex/heal/_restore-fixture.js +96 -0
- package/dist/vector-cortex/heal/_vc6b-helpers.js +35 -0
- package/dist/vector-cortex/heal/controller.js +165 -0
- package/dist/vector-cortex/heal/rebuild.js +136 -0
- package/dist/vector-cortex/heal/repair-emit.js +83 -0
- package/dist/vector-cortex/heal/repair-types.js +74 -0
- package/dist/vector-cortex/heal/restore-emit.js +70 -0
- package/dist/vector-cortex/heal/restore-readers.js +114 -0
- package/dist/vector-cortex/heal/restore-types.js +66 -0
- package/dist/vector-cortex/heal/restore.js +140 -0
- package/dist/vector-cortex/heal/types.js +2 -0
- package/dist/vector-cortex/heal/verify.js +75 -0
- package/extensions/dashboard-client/dist/assets/{AreaChart-5yxaI8IO.js → AreaChart-BDMjyRQp.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AreaChart-5yxaI8IO.js.map → AreaChart-BDMjyRQp.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{BarChart-BoHhCqH-.js → BarChart-CqzPzOkj.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{BarChart-BoHhCqH-.js.map → BarChart-CqzPzOkj.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-Di6L3CsF.js → CacheTab-Bo8SH9q8.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-Di6L3CsF.js.map → CacheTab-Bo8SH9q8.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-Cdff3h6s.js → EventsTab-DIEs6zC-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-Cdff3h6s.js.map → EventsTab-DIEs6zC-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{HealthTab-MthYYVLw.js → HealthTab-3a8IjwIK.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{HealthTab-MthYYVLw.js.map → HealthTab-3a8IjwIK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-D9G4ntsG.js → MaintenanceTab-CdKwAcXo.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-D9G4ntsG.js.map → MaintenanceTab-CdKwAcXo.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-CN8Lr5Qf.js → MemoryMapTab-D2hHuNj-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-CN8Lr5Qf.js.map → MemoryMapTab-D2hHuNj-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CgO32kSo.js → MetricsTab-CFhIhqrr.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CgO32kSo.js.map → MetricsTab-CFhIhqrr.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Cb4kR0h1.js → OverviewTab-Dzp5ZoiA.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Cb4kR0h1.js.map → OverviewTab-Dzp5ZoiA.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-Dt1FGnMR.js → ReposTab-CFJT9mDR.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-Dt1FGnMR.js.map → ReposTab-CFJT9mDR.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-D7D2RW6U.js → SessionsTab-BhP1z6_y.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-D7D2RW6U.js.map → SessionsTab-BhP1z6_y.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-CgV5xCzz.js → SetupTab-2Glh7MKk.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-CgV5xCzz.js.map → SetupTab-2Glh7MKk.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CCNuJjyO.js → TimeSavedCard-DmwBDhkw.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CCNuJjyO.js.map → TimeSavedCard-DmwBDhkw.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cv1OCttJ.js → TurnsTab-CYIaOUUZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cv1OCttJ.js.map → TurnsTab-CYIaOUUZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DD7vGaRS.js +2 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DD7vGaRS.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{WikiTab-B48QnQrK.js → WikiTab-CP-JEd17.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{WikiTab-B48QnQrK.js.map → WikiTab-CP-JEd17.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{button-BBQl2MiF.js → button-B1RhLsGs.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{button-BBQl2MiF.js.map → button-B1RhLsGs.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{card-BYbefUur.js → card-BLPT2-8G.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{card-BYbefUur.js.map → card-BLPT2-8G.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-swm0YZe0.js → generateCategoricalChart-DVOMJzL2.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-swm0YZe0.js.map → generateCategoricalChart-DVOMJzL2.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-DeIky7xm.js → index-CuLdiHRl.js} +3 -3
- package/extensions/dashboard-client/dist/assets/{index-DeIky7xm.js.map → index-CuLdiHRl.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{switch-CctU78ww.js → switch-C5tqzhcl.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{switch-CctU78ww.js.map → switch-C5tqzhcl.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{toggle-BRCNCoiI.js → toggle-HcM6W2Yl.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{toggle-BRCNCoiI.js.map → toggle-HcM6W2Yl.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-WfOiWDH_.js → useSSE-BtWUs2kL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-WfOiWDH_.js.map → useSSE-BtWUs2kL.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/api/vector-cortex.ts +18 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexRepairCard.tsx +43 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexRestoreCard.tsx +42 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexShardsCard.tsx +89 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexTab.tsx +22 -60
- package/extensions/dashboard-client/src/types/vector-cortex.ts +33 -0
- package/extensions/dashboard-server/api-contracts/vector-cortex-heal.ts +68 -0
- package/extensions/dashboard-server/api-contracts/vector-cortex.ts +5 -1
- package/extensions/dashboard-server/route-dispatch.ts +6 -0
- package/extensions/dashboard-server/routes-rag-settings-vector-cortex.ts +12 -0
- package/extensions/dashboard-server/routes-vector-cortex-heal.ts +51 -4
- package/extensions/dashboard-server/routes-vector-cortex-repair.ts +72 -0
- package/extensions/dashboard-server/routes-vector-cortex.ts +6 -1
- package/extensions/dashboard-server/routes.ts +1 -0
- package/package.json +1 -1
- package/src/config/vector-cortex.ts +31 -0
- package/src/config.ts +2 -0
- package/src/vector-cortex/heal/_repair-fixture.ts +138 -0
- package/src/vector-cortex/heal/_restore-fixture.ts +163 -0
- package/src/vector-cortex/heal/_vc6b-helpers.ts +55 -0
- package/src/vector-cortex/heal/controller.ts +189 -0
- package/src/vector-cortex/heal/rebuild.ts +200 -0
- package/src/vector-cortex/heal/repair-emit.ts +118 -0
- package/src/vector-cortex/heal/repair-types.ts +227 -0
- package/src/vector-cortex/heal/restore-emit.ts +94 -0
- package/src/vector-cortex/heal/restore-readers.ts +145 -0
- package/src/vector-cortex/heal/restore-types.ts +185 -0
- package/src/vector-cortex/heal/restore.ts +165 -0
- package/src/vector-cortex/heal/types.ts +50 -0
- package/src/vector-cortex/heal/verify.ts +93 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DTabQo6h.js +0 -2
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DTabQo6h.js.map +0 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-cortex/heal/restore.ts — VC6B exact source restoration orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Turns a `RestoreRequestV1` into a `RestoreResultV1` by consulting, per span,
|
|
5
|
+
* the two EXACT sources in `restore-readers.ts` — an indexed exact shard first,
|
|
6
|
+
* then a ledger range scan — and inserting bytes only after they hash to the
|
|
7
|
+
* digest the request pinned.
|
|
8
|
+
*
|
|
9
|
+
* ORDER OF OPERATIONS IS THE CONTRACT.
|
|
10
|
+
*
|
|
11
|
+
* 1. BOUNDS, BEFORE ANY READER TOUCH. The span count and the aggregate
|
|
12
|
+
* requested byte span are computed PURELY FROM THE REQUEST (`byteEnd -
|
|
13
|
+
* byteStart`, which needs no source at all) and checked first. On breach we
|
|
14
|
+
* return immediately, having never read `reader.exactShards` or
|
|
15
|
+
* `reader.ledgerEvents`. This is HEAL-LIMIT-002: an oversized request must
|
|
16
|
+
* not be able to make the restorer walk the ledger even once, so the check
|
|
17
|
+
* cannot be "inside the loop, before the read" — it must be before the loop
|
|
18
|
+
* exists. The acceptance corpus proves it by passing EMPTY readers with an
|
|
19
|
+
* over-limit request: a reader-touching implementation would still return
|
|
20
|
+
* "missing" rather than "limit".
|
|
21
|
+
*
|
|
22
|
+
* 2. EXACT SHARD, then LEDGER, then MISSING. Sources are tried strongest-first.
|
|
23
|
+
* A digest mismatch anywhere is recorded and the span is NOT restored — the
|
|
24
|
+
* restorer never downgrades to "closest available bytes".
|
|
25
|
+
*
|
|
26
|
+
* 3. MODE from what actually happened, not from what was attempted. A = every
|
|
27
|
+
* span came from a shard; B = all restored, at least one via ledger scan;
|
|
28
|
+
* C = something is missing, and mode C STATES its semantic loss rather than
|
|
29
|
+
* hiding an incomplete restoration behind a successful-looking result.
|
|
30
|
+
*
|
|
31
|
+
* PURE. No storage, no console, no clock, no network — `node:crypto` (a Node
|
|
32
|
+
* built-in) is the only dependency beyond types (PREVENT-PI-004 / PREVENT-011).
|
|
33
|
+
* The reporter seam in `restore-emit.ts` is flag-gated; THIS arithmetic is not,
|
|
34
|
+
* so flag-off is byte-identical.
|
|
35
|
+
*/
|
|
36
|
+
import { RESTORE_LIMIT_BYTES, RESTORE_LIMIT_SPANS } from "./restore-types.js";
|
|
37
|
+
import { readExactShard, readLedgerSpan } from "./restore-readers.js";
|
|
38
|
+
/**
|
|
39
|
+
* Fixed code ordering so a result's `codes` array is deterministic regardless of
|
|
40
|
+
* which span failed first. Deterministic output is what lets the conformance
|
|
41
|
+
* corpus pin an exact expected value.
|
|
42
|
+
*/
|
|
43
|
+
const CODE_ORDER = [
|
|
44
|
+
"HEAL_RESTORE_LIMIT",
|
|
45
|
+
"HEAL_RESTORE_DIGEST_MISMATCH",
|
|
46
|
+
"HEAL_RESTORE_SOURCE_MISSING",
|
|
47
|
+
"HEAL_RESTORE_RANGE_MISMATCH",
|
|
48
|
+
];
|
|
49
|
+
/** Deduplicate + sort codes into the fixed priority order. */
|
|
50
|
+
export function orderCodes(codes) {
|
|
51
|
+
const seen = new Set(codes);
|
|
52
|
+
return CODE_ORDER.filter((c) => seen.has(c));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Total bytes the request ASKS for, derived from the ranges alone. Negative or
|
|
56
|
+
* inverted ranges contribute 0 rather than reducing the total — an inverted span
|
|
57
|
+
* must never be usable to smuggle a large request under the bound.
|
|
58
|
+
*/
|
|
59
|
+
function requestedBytes(spans) {
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (const s of spans) {
|
|
62
|
+
const len = s.range.byteEnd - s.range.byteStart;
|
|
63
|
+
total += len > 0 ? len : 0;
|
|
64
|
+
}
|
|
65
|
+
return total;
|
|
66
|
+
}
|
|
67
|
+
/** The immediate over-limit result: nothing read, everything missing. */
|
|
68
|
+
function limitExceeded(request) {
|
|
69
|
+
return {
|
|
70
|
+
schema: "restore-result-v1",
|
|
71
|
+
sessionId: request.sessionId,
|
|
72
|
+
mode: "C",
|
|
73
|
+
restored: [],
|
|
74
|
+
missing: request.spans.map((s) => s.nodeId),
|
|
75
|
+
semanticLossStated: true,
|
|
76
|
+
codes: ["HEAL_RESTORE_LIMIT"],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Restore the exact original bytes for every requested span.
|
|
81
|
+
*
|
|
82
|
+
* Never throws: a request naming spans no source covers yields a mode-C result
|
|
83
|
+
* that discloses the loss, not an exception (PRACTICES: non-fatal — a failed
|
|
84
|
+
* restoration must degrade the prompt, never break the agent loop).
|
|
85
|
+
*/
|
|
86
|
+
export function restoreSources(request, reader) {
|
|
87
|
+
// (1) Bounds first — computed from the request only. `reader` is not touched
|
|
88
|
+
// on this path, which is exactly what HEAL-LIMIT-002 asserts.
|
|
89
|
+
if (request.spans.length > RESTORE_LIMIT_SPANS ||
|
|
90
|
+
requestedBytes(request.spans) > RESTORE_LIMIT_BYTES) {
|
|
91
|
+
return limitExceeded(request);
|
|
92
|
+
}
|
|
93
|
+
const restored = [];
|
|
94
|
+
const missing = [];
|
|
95
|
+
const codes = [];
|
|
96
|
+
let usedLedger = false;
|
|
97
|
+
// (2) Per span, strongest source first, in request order.
|
|
98
|
+
for (const span of request.spans) {
|
|
99
|
+
const exact = readExactShard(reader.exactShards, span.range, span.digest);
|
|
100
|
+
if (exact.kind === "bytes") {
|
|
101
|
+
restored.push({
|
|
102
|
+
nodeId: span.nodeId,
|
|
103
|
+
source: "exact-shard",
|
|
104
|
+
bytes: exact.bytes,
|
|
105
|
+
digest: span.digest,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (exact.kind === "digest-mismatch")
|
|
110
|
+
codes.push("HEAL_RESTORE_DIGEST_MISMATCH");
|
|
111
|
+
const ledger = readLedgerSpan(reader.ledgerEvents, span.range, span.digest);
|
|
112
|
+
if (ledger.kind === "bytes") {
|
|
113
|
+
usedLedger = true;
|
|
114
|
+
restored.push({
|
|
115
|
+
nodeId: span.nodeId,
|
|
116
|
+
source: "ledger-scan",
|
|
117
|
+
bytes: ledger.bytes,
|
|
118
|
+
digest: span.digest,
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ledger.kind === "digest-mismatch")
|
|
123
|
+
codes.push("HEAL_RESTORE_DIGEST_MISMATCH");
|
|
124
|
+
// (3) Neither exact source answered. Identity only — never bytes.
|
|
125
|
+
missing.push(span.nodeId);
|
|
126
|
+
codes.push("HEAL_RESTORE_SOURCE_MISSING");
|
|
127
|
+
}
|
|
128
|
+
const complete = missing.length === 0 && restored.length === request.spans.length;
|
|
129
|
+
const mode = !complete ? "C" : usedLedger ? "B" : "A";
|
|
130
|
+
return {
|
|
131
|
+
schema: "restore-result-v1",
|
|
132
|
+
sessionId: request.sessionId,
|
|
133
|
+
mode,
|
|
134
|
+
restored,
|
|
135
|
+
missing,
|
|
136
|
+
// Mode C omits spans, so it MUST disclose that the old context is gone.
|
|
137
|
+
semanticLossStated: mode === "C",
|
|
138
|
+
codes: orderCodes(codes),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -40,3 +40,5 @@ export const HEAL_NAMED_IDS = [
|
|
|
40
40
|
"HEAL-PROTECT-002",
|
|
41
41
|
"HEAL-PROOF-003",
|
|
42
42
|
];
|
|
43
|
+
export { RESTORE_IDS, RESTORE_NAMED_IDS, RESTORE_LIMIT_SPANS, RESTORE_LIMIT_BYTES, } from "./restore-types.js";
|
|
44
|
+
export { REPAIR_IDS, REPAIR_NAMED_IDS, REPAIR_RATE_LIMIT_MS, REPAIR_BACKOFF_BASE_MS, REPAIR_BACKOFF_CAP_MS, REPAIR_BACKOFF_JITTER, } from "./repair-types.js";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-cortex/heal/verify.ts — VC6B pre-insertion verification (task 3).
|
|
3
|
+
*
|
|
4
|
+
* `restoreSources` already hashes every span before it puts it in the result, so
|
|
5
|
+
* why hash again here?
|
|
6
|
+
*
|
|
7
|
+
* Because the result is a VALUE that travels. Between restoration and insertion
|
|
8
|
+
* it is passed across module boundaries, possibly cached, possibly assembled from
|
|
9
|
+
* more than one restore call, possibly reordered. `verifyRestored` is the gate
|
|
10
|
+
* immediately before bytes enter the reconstruction, and it re-derives its facts
|
|
11
|
+
* from scratch: it hashes the bytes the result ACTUALLY CARRIES and cross-checks
|
|
12
|
+
* them against the digest THE REQUEST asked for. A result object mutated after
|
|
13
|
+
* `restoreSources` returned — the classic "verified then swapped" injection —
|
|
14
|
+
* fails here.
|
|
15
|
+
*
|
|
16
|
+
* TWO DISTINCT INVARIANTS, TWO DISTINCT CODES.
|
|
17
|
+
* - `HEAL_RESTORE_DIGEST_MISMATCH` — the bytes do not hash to their OWN stated
|
|
18
|
+
* digest. The span is internally inconsistent (tampering or truncation).
|
|
19
|
+
* - `HEAL_RESTORE_RANGE_MISMATCH` — the bytes are internally consistent but do
|
|
20
|
+
* not answer the question that was asked: the nodeId was never requested, or
|
|
21
|
+
* the digest disagrees with the digest the request pinned for that nodeId.
|
|
22
|
+
* This is the "right bytes, wrong span" failure — substituting one real,
|
|
23
|
+
* correctly-hashed span for another would otherwise pass a naive check.
|
|
24
|
+
*
|
|
25
|
+
* WHOLESALE, NOT PER-SPAN. `insertable` returns the restored spans only when the
|
|
26
|
+
* entire result verifies. Inserting the good half of a result whose other half
|
|
27
|
+
* failed verification would splice a partially-corrupt transcript into the
|
|
28
|
+
* reconstruction without the caller ever seeing a mode-C disclosure.
|
|
29
|
+
*
|
|
30
|
+
* Pure: `node:crypto` only, no storage/console/network (PREVENT-PI-004 /
|
|
31
|
+
* PREVENT-011).
|
|
32
|
+
*/
|
|
33
|
+
import { sha256Hex } from "./restore-readers.js";
|
|
34
|
+
import { orderCodes } from "./restore.js";
|
|
35
|
+
/**
|
|
36
|
+
* Re-verify every restored span against the request that asked for it.
|
|
37
|
+
*
|
|
38
|
+
* Returns `{ok:true}` only when EVERY restored span hashes to its own digest AND
|
|
39
|
+
* that digest is the one the request pinned for that node.
|
|
40
|
+
*/
|
|
41
|
+
export function verifyRestored(result, request) {
|
|
42
|
+
const requestedDigests = new Map();
|
|
43
|
+
for (const span of request.spans)
|
|
44
|
+
requestedDigests.set(span.nodeId, span.digest);
|
|
45
|
+
const codes = [];
|
|
46
|
+
for (const span of result.restored) {
|
|
47
|
+
// (1) Internal consistency: do these bytes hash to the digest they claim?
|
|
48
|
+
if (sha256Hex(span.bytes) !== span.digest) {
|
|
49
|
+
codes.push("HEAL_RESTORE_DIGEST_MISMATCH");
|
|
50
|
+
// A span that fails its own digest cannot meaningfully be checked against
|
|
51
|
+
// the request's digest as well; one code per real defect keeps the verdict
|
|
52
|
+
// readable.
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// (2) Provenance: was this node requested, and with THIS digest? An
|
|
56
|
+
// unrequested node or a digest swap is a range mismatch, not a hash failure.
|
|
57
|
+
const wanted = requestedDigests.get(span.nodeId);
|
|
58
|
+
if (wanted === undefined || wanted !== span.digest) {
|
|
59
|
+
codes.push("HEAL_RESTORE_RANGE_MISMATCH");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (codes.length === 0)
|
|
63
|
+
return { ok: true };
|
|
64
|
+
return { ok: false, codes: orderCodes(codes) };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The spans that may be inserted: all of them, or none.
|
|
68
|
+
*
|
|
69
|
+
* A caller should never need to decide which half of a partially-verified result
|
|
70
|
+
* is safe — that decision is the whole point of the digest contract, so it is
|
|
71
|
+
* made here, once, in the strict direction.
|
|
72
|
+
*/
|
|
73
|
+
export function insertable(result, request) {
|
|
74
|
+
return verifyRestored(result, request).ok ? result.restored : [];
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heal/_repair-fixture.ts — conformance fixture I/O for VC6C healing-controller rows.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `_restore-fixture.ts` (VC6B) and `_acceptance-fixture.ts` (VC6A);
|
|
5
|
+
* split out so no loader approaches the 300-line soft limit and so the
|
|
6
|
+
* BigInt/base64 decoding lives next to the contract it reconstitutes.
|
|
7
|
+
*
|
|
8
|
+
* DECODING IS THE POINT. Fixtures are canonical JSON, which cannot express
|
|
9
|
+
* bigints or bytes, so the corpus stores numeric `nowMs` / high-waters and
|
|
10
|
+
* base64 source bytes. These loaders turn those back into the REAL `RepairState`
|
|
11
|
+
* / `RebuildInput` objects the production controller consumes — no mocks, no
|
|
12
|
+
* stubs, no parallel "test shape". A lossy decode would make the digests or the
|
|
13
|
+
* planned ranges disagree and the acceptance test would fail loudly.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { V2, readManifest } from "./_acceptance-fixture.js";
|
|
19
|
+
/** Read one registered healing-controller fixture (asserting it IS registered). */
|
|
20
|
+
export function repairFixture(id) {
|
|
21
|
+
const m = readManifest();
|
|
22
|
+
const row = m.fixtures.find((f) => f.id === id && f.path.startsWith("healing-controller/"));
|
|
23
|
+
assert.ok(row, `fixture ${id} registered under healing-controller/ in manifest`);
|
|
24
|
+
return JSON.parse(readFileSync(join(V2, row.path), "utf8"));
|
|
25
|
+
}
|
|
26
|
+
/** JSON numbers -> the bigint fields `RepairState` declares. */
|
|
27
|
+
export function decodeState(s) {
|
|
28
|
+
return {
|
|
29
|
+
subsystem: s.subsystem,
|
|
30
|
+
derivedHighWater: BigInt(s.derivedHighWater),
|
|
31
|
+
authorityHighWater: BigInt(s.authorityHighWater),
|
|
32
|
+
lastRebuildAt: s.lastRebuildAt === null ? null : BigInt(s.lastRebuildAt),
|
|
33
|
+
generation: s.generation,
|
|
34
|
+
mode: s.mode,
|
|
35
|
+
...(s.failedAttempts !== undefined ? { failedAttempts: s.failedAttempts } : {}),
|
|
36
|
+
...(s.authorityFrozen !== undefined ? { authorityFrozen: s.authorityFrozen } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Reconstitute a real `RebuildInput` from its fixture row. */
|
|
40
|
+
export function decodeRebuild(r) {
|
|
41
|
+
return {
|
|
42
|
+
subsystem: r.subsystem,
|
|
43
|
+
range: {
|
|
44
|
+
sessionId: r.subsystem,
|
|
45
|
+
seqStart: 0n,
|
|
46
|
+
seqEnd: 0n,
|
|
47
|
+
byteStart: 0,
|
|
48
|
+
byteEnd: 0,
|
|
49
|
+
},
|
|
50
|
+
generation: r.generation,
|
|
51
|
+
sourceBytes: new Uint8Array(Buffer.from(r.sourceBytesBase64, "base64")),
|
|
52
|
+
expectedDigest: r.expectedDigest,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Flag-pinned wrapper: VC6C gated by MEGACOMPACT_VC6C (defaults ON). */
|
|
56
|
+
export function withVc6cFlagsOn(fn) {
|
|
57
|
+
return () => {
|
|
58
|
+
const saved = process.env.MEGACOMPACT_VC6C;
|
|
59
|
+
process.env.MEGACOMPACT_VC6C = "1";
|
|
60
|
+
try {
|
|
61
|
+
fn();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (saved === undefined)
|
|
65
|
+
delete process.env.MEGACOMPACT_VC6C;
|
|
66
|
+
else
|
|
67
|
+
process.env.MEGACOMPACT_VC6C = saved;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heal/_restore-fixture.ts — conformance fixture I/O for VC6B restoration rows.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `_acceptance-fixture.ts` (which owns the VC6A closure-optimization
|
|
5
|
+
* rows); split out so neither file approaches the 300-line soft limit and so the
|
|
6
|
+
* base64/BigInt decoding lives next to the contract it reconstitutes.
|
|
7
|
+
*
|
|
8
|
+
* DECODING IS THE POINT. Fixtures are canonical JSON, which cannot express bytes
|
|
9
|
+
* or bigints, so the corpus stores `originalBytesBase64` + numeric `seq`. These
|
|
10
|
+
* loaders turn those back into the REAL `ExactShardV1` / `EventV2` objects the
|
|
11
|
+
* production readers consume — no mocks, no stubs, no parallel "test shape". If
|
|
12
|
+
* the decode were lossy the digests would not match and the acceptance test would
|
|
13
|
+
* fail loudly, which is exactly the guarantee we want from a byte-identity sprint.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { V2, readManifest } from "./_acceptance-fixture.js";
|
|
19
|
+
/** Read one registered restoration fixture (asserting it IS registered). */
|
|
20
|
+
export function restorationFixture(id) {
|
|
21
|
+
const m = readManifest();
|
|
22
|
+
const row = m.fixtures.find((f) => f.id === id && f.path.startsWith("restoration/"));
|
|
23
|
+
assert.ok(row, `fixture ${id} registered under restoration/ in manifest`);
|
|
24
|
+
return JSON.parse(readFileSync(join(V2, row.path), "utf8"));
|
|
25
|
+
}
|
|
26
|
+
/** base64 -> exact bytes, byte-for-byte (invalid UTF-8 included). */
|
|
27
|
+
export function decodeBytes(base64) {
|
|
28
|
+
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
29
|
+
}
|
|
30
|
+
/** JSON number seq bounds -> the bigint bounds `ShardRange` declares. */
|
|
31
|
+
export function decodeRange(r) {
|
|
32
|
+
return {
|
|
33
|
+
sessionId: r.sessionId,
|
|
34
|
+
seqStart: BigInt(r.seqStart),
|
|
35
|
+
seqEnd: BigInt(r.seqEnd),
|
|
36
|
+
byteStart: r.byteStart,
|
|
37
|
+
byteEnd: r.byteEnd,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Reconstitute a real `ExactShardV1` from its fixture row. */
|
|
41
|
+
export function decodeShard(s) {
|
|
42
|
+
return {
|
|
43
|
+
schema: "exact-shard-v1",
|
|
44
|
+
sessionId: s.sessionId,
|
|
45
|
+
range: decodeRange(s.range),
|
|
46
|
+
kind: "exact",
|
|
47
|
+
originalBytes: decodeBytes(s.originalBytesBase64),
|
|
48
|
+
digest: s.digest,
|
|
49
|
+
byteCount: s.byteCount,
|
|
50
|
+
case: s.case,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reconstitute a real `EventV2`. `utf8` is re-derived by strict classification
|
|
55
|
+
* (never lossy replacement) so an invalid-UTF-8 fixture produces the
|
|
56
|
+
* `{valid:false, base64}` discriminant the VC1A contract requires.
|
|
57
|
+
*/
|
|
58
|
+
export function decodeEvent(e) {
|
|
59
|
+
const bytes = decodeBytes(e.originalBytesBase64);
|
|
60
|
+
let utf8;
|
|
61
|
+
try {
|
|
62
|
+
utf8 = { valid: true, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
utf8 = { valid: false, base64: e.originalBytesBase64 };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
schema: "event-v2",
|
|
69
|
+
sessionId: e.sessionId,
|
|
70
|
+
seq: BigInt(e.seq),
|
|
71
|
+
eventId: e.eventId,
|
|
72
|
+
role: e.role,
|
|
73
|
+
kind: e.kind,
|
|
74
|
+
originalBytes: bytes,
|
|
75
|
+
bytesDigest: e.bytesDigest,
|
|
76
|
+
utf8,
|
|
77
|
+
...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}),
|
|
78
|
+
occurredAtMs: BigInt(e.occurredAtMs),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Flag-pinned wrapper: VC6B gated by MEGACOMPACT_VC6B (defaults ON). */
|
|
82
|
+
export function withVc6bFlagsOn(fn) {
|
|
83
|
+
return () => {
|
|
84
|
+
const saved = process.env.MEGACOMPACT_VC6B;
|
|
85
|
+
process.env.MEGACOMPACT_VC6B = "1";
|
|
86
|
+
try {
|
|
87
|
+
fn();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (saved === undefined)
|
|
91
|
+
delete process.env.MEGACOMPACT_VC6B;
|
|
92
|
+
else
|
|
93
|
+
process.env.MEGACOMPACT_VC6B = saved;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heal/_vc6b-helpers.ts — shared decode/run helpers for VC6B acceptance tests.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from vc6b-acceptance.test.ts so no single test file crosses the
|
|
5
|
+
* 300-line soft limit (soft-as-hard gate). Each sibling test file imports from
|
|
6
|
+
* here rather than duplicating the decode/runReal logic.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { restoreSources } from "./restore.js";
|
|
10
|
+
import { verifyRestored } from "./verify.js";
|
|
11
|
+
import { decodeRange, decodeShard, decodeEvent } from "./_restore-fixture.js";
|
|
12
|
+
export const enc = (s) => new Uint8Array(Buffer.from(s));
|
|
13
|
+
export const hex = (b) => createHash("sha256").update(b).digest("hex");
|
|
14
|
+
export function decodeFx(fx) {
|
|
15
|
+
return {
|
|
16
|
+
request: {
|
|
17
|
+
schema: "restore-request-v1",
|
|
18
|
+
sessionId: fx.input.sessionId,
|
|
19
|
+
spans: fx.input.request.spans.map((s) => ({
|
|
20
|
+
nodeId: s.nodeId,
|
|
21
|
+
range: decodeRange(s.range),
|
|
22
|
+
digest: s.digest,
|
|
23
|
+
})),
|
|
24
|
+
},
|
|
25
|
+
reader: {
|
|
26
|
+
exactShards: fx.input.exactShards.map(decodeShard),
|
|
27
|
+
ledgerEvents: fx.input.ledgerEvents.map(decodeEvent),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function runReal(fx) {
|
|
32
|
+
const { request, reader } = decodeFx(fx);
|
|
33
|
+
const result = restoreSources(request, reader);
|
|
34
|
+
return { request, reader, result, verification: verifyRestored(result, request) };
|
|
35
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-cortex/heal/controller.ts — VC6C gap detection + rebuild planning.
|
|
3
|
+
*
|
|
4
|
+
* Compares each derived subsystem's high-water to the durable authority
|
|
5
|
+
* high-water and emits a `RepairPlanV1` for every gap that is real, actionable,
|
|
6
|
+
* and not rate-limited. The controller DECIDES; `rebuild.ts` executes.
|
|
7
|
+
*
|
|
8
|
+
* THE AUTHORITY IS NEVER WRITTEN. Every function here takes `RepairState` (whose
|
|
9
|
+
* `authorityHighWater` is readonly) and returns plans. There is no code path,
|
|
10
|
+
* guarded or otherwise, that mutates authority — derived state is rebuildable,
|
|
11
|
+
* authority is not, so repair is strictly one-directional.
|
|
12
|
+
*
|
|
13
|
+
* FOUR REASONS NOT TO PLAN, in priority order. The order is the contract: a
|
|
14
|
+
* frozen authority outranks a rate limit, because "the frontier is not real yet"
|
|
15
|
+
* is a statement about CORRECTNESS while "you rebuilt recently" is only about
|
|
16
|
+
* pacing. Reporting RATE_LIMITED during an outage would tell an operator to wait
|
|
17
|
+
* five minutes for a rebuild that must never happen at all.
|
|
18
|
+
*
|
|
19
|
+
* 1. AUTHORITY FROZEN. During an outage the durable high-water freezes while
|
|
20
|
+
* the spool keeps accepting frames (TRIAD_RESILIENCE §frontier). A derived
|
|
21
|
+
* subsystem behind a frozen frontier is CORRECT, so we refuse to plan rather
|
|
22
|
+
* than chase the spool tail and materialize non-durable frames.
|
|
23
|
+
* 2. NO GAP. `derived >= authority` — nothing to do. Note `>`: a derived source
|
|
24
|
+
* AHEAD of authority is not repaired by rebuilding a backwards range (that
|
|
25
|
+
* would produce an inverted range); it is left alone for the breaker.
|
|
26
|
+
* 3. MODE C. Derived state is disabled for this subsystem. Re-planning a
|
|
27
|
+
* subsystem whose derived tier is intentionally off is a rebuild loop.
|
|
28
|
+
* 4. RATE LIMITED. One rebuild per subsystem per 5 minutes.
|
|
29
|
+
*
|
|
30
|
+
* DETERMINISTIC JITTER. The ±10% spread comes from a SHA-256 of the subsystem
|
|
31
|
+
* name plus the attempt, never `Math.random`. Two subsystems desynchronize (no
|
|
32
|
+
* thundering herd) while a single subsystem's schedule is byte-reproducible — a
|
|
33
|
+
* fixture can pin `backoffMs` exactly, which a PRNG would make untestable.
|
|
34
|
+
*
|
|
35
|
+
* PURE. `node:crypto` is the only dependency beyond types: no storage, no
|
|
36
|
+
* console, no network, and no clock — `nowMs` is always injected, which is what
|
|
37
|
+
* makes the fake-clock restart fixtures possible (PREVENT-PI-004 / PREVENT-011).
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
import { REPAIR_BACKOFF_BASE_MS, REPAIR_BACKOFF_CAP_MS, REPAIR_BACKOFF_JITTER, REPAIR_RATE_LIMIT_MS, } from "./repair-types.js";
|
|
41
|
+
/**
|
|
42
|
+
* A stable fraction in [0,1) derived from the subsystem name + attempt.
|
|
43
|
+
*
|
|
44
|
+
* Uses the first 6 hex digits (24 bits) of SHA-256 — plenty of spread for a ±10%
|
|
45
|
+
* window, and small enough to stay exact in a float64 division.
|
|
46
|
+
*/
|
|
47
|
+
function digestFraction(subsystem, attempt) {
|
|
48
|
+
const hex = createHash("sha256")
|
|
49
|
+
.update(`${subsystem}:${attempt}`)
|
|
50
|
+
.digest("hex")
|
|
51
|
+
.slice(0, 6);
|
|
52
|
+
return parseInt(hex, 16) / 0x1000000;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Deterministic exponential backoff: `30s * 2^attempt`, capped at 15 minutes,
|
|
56
|
+
* then spread by ±10% seeded from the subsystem digest.
|
|
57
|
+
*
|
|
58
|
+
* The cap is applied BEFORE the jitter so the jitter is a spread around the cap
|
|
59
|
+
* rather than a way to exceed it; the result is floored to an integer ms so the
|
|
60
|
+
* value is exactly representable in JSON (a fixture pins it verbatim).
|
|
61
|
+
*
|
|
62
|
+
* `attempt` is clamped at 0 and at 30: `2^attempt` for an unclamped large attempt
|
|
63
|
+
* would reach Infinity, and `Infinity * jitter` is NaN — a NaN backoff would
|
|
64
|
+
* schedule a plan that is never eligible and silently wedge the subsystem.
|
|
65
|
+
*/
|
|
66
|
+
export function computeBackoff(subsystem, attempt) {
|
|
67
|
+
const safe = Math.min(Math.max(Math.floor(attempt), 0), 30);
|
|
68
|
+
const raw = REPAIR_BACKOFF_BASE_MS * 2 ** safe;
|
|
69
|
+
const capped = Math.min(raw, REPAIR_BACKOFF_CAP_MS);
|
|
70
|
+
// Map [0,1) onto [-1,+1) then scale to the ±10% window.
|
|
71
|
+
const spread = (digestFraction(subsystem, safe) * 2 - 1) * REPAIR_BACKOFF_JITTER;
|
|
72
|
+
return Math.max(0, Math.floor(capped * (1 + spread)));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* True when this subsystem rebuilt less than 5 minutes ago.
|
|
76
|
+
*
|
|
77
|
+
* A subsystem that has NEVER rebuilt (`lastRebuildAt === null`) is never rate
|
|
78
|
+
* limited — the first repair after a restart must not be delayed by the absence
|
|
79
|
+
* of history.
|
|
80
|
+
*/
|
|
81
|
+
export function isRateLimited(lastRebuildAt, nowMs) {
|
|
82
|
+
if (lastRebuildAt === null)
|
|
83
|
+
return false;
|
|
84
|
+
return lastRebuildAt + BigInt(REPAIR_RATE_LIMIT_MS) > nowMs;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The seq window a rebuild must cover: the first UNBUILT seq through the
|
|
88
|
+
* authority frontier, inclusive.
|
|
89
|
+
*
|
|
90
|
+
* Byte bounds are intentionally 0..0. VC6C plans in SEQ space — the controller
|
|
91
|
+
* knows how far each derived tier has been built, but byte offsets belong to the
|
|
92
|
+
* shard/ledger layer that executes the rebuild. Inventing byte bounds here would
|
|
93
|
+
* be fabricating a fact the controller does not have.
|
|
94
|
+
*/
|
|
95
|
+
function gapRange(state) {
|
|
96
|
+
return {
|
|
97
|
+
sessionId: state.subsystem,
|
|
98
|
+
seqStart: state.derivedHighWater + 1n,
|
|
99
|
+
seqEnd: state.authorityHighWater,
|
|
100
|
+
byteStart: 0,
|
|
101
|
+
byteEnd: 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build the plan for one subsystem's gap.
|
|
106
|
+
*
|
|
107
|
+
* Always targets `generation + 1`: a rebuild NEVER writes into the live
|
|
108
|
+
* generation, so a failed or half-written rebuild cannot corrupt what is
|
|
109
|
+
* currently being served (see `rebuild.ts` copy-then-switch).
|
|
110
|
+
*
|
|
111
|
+
* Exported for direct unit testing and for callers that have already decided a
|
|
112
|
+
* plan is warranted; `detectGaps` is the guarded entry point that applies the
|
|
113
|
+
* four refusal rules first.
|
|
114
|
+
*/
|
|
115
|
+
export function planRebuild(state, nowMs) {
|
|
116
|
+
const backoffMs = computeBackoff(state.subsystem, state.failedAttempts ?? 0);
|
|
117
|
+
return {
|
|
118
|
+
schema: "repair-plan-v1",
|
|
119
|
+
subsystem: state.subsystem,
|
|
120
|
+
range: gapRange(state),
|
|
121
|
+
generation: state.generation + 1,
|
|
122
|
+
backoffMs,
|
|
123
|
+
scheduledAt: nowMs + BigInt(backoffMs),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* True when a subsystem is eligible for a rebuild plan (the four refusal rules).
|
|
128
|
+
* Ordering matters — see the file header.
|
|
129
|
+
*/
|
|
130
|
+
export function isPlannable(state, nowMs) {
|
|
131
|
+
if (state.authorityFrozen === true)
|
|
132
|
+
return false;
|
|
133
|
+
if (state.derivedHighWater >= state.authorityHighWater)
|
|
134
|
+
return false;
|
|
135
|
+
if (state.mode === "C")
|
|
136
|
+
return false;
|
|
137
|
+
if (isRateLimited(state.lastRebuildAt, nowMs))
|
|
138
|
+
return false;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Detect derived gaps and plan a rebuild for each eligible subsystem.
|
|
143
|
+
*
|
|
144
|
+
* Output order follows INPUT order, not subsystem name: the caller controls
|
|
145
|
+
* priority (a topology gap may matter more than a closure gap), and re-sorting
|
|
146
|
+
* here would silently override that. Never throws — an unplannable subsystem is
|
|
147
|
+
* simply absent from the result, so a single wedged tier cannot stop the others
|
|
148
|
+
* from healing (PRACTICES: non-fatal).
|
|
149
|
+
*/
|
|
150
|
+
export function detectGaps(states, nowMs) {
|
|
151
|
+
const plans = [];
|
|
152
|
+
for (const state of states) {
|
|
153
|
+
if (!isPlannable(state, nowMs))
|
|
154
|
+
continue;
|
|
155
|
+
plans.push(planRebuild(state, nowMs));
|
|
156
|
+
}
|
|
157
|
+
return plans;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The bound controller surface. A plain object (not a class): there is no
|
|
161
|
+
* instance state to hold — every input, including the clock, is injected.
|
|
162
|
+
*/
|
|
163
|
+
export function createRepairController() {
|
|
164
|
+
return { detectGaps, planRebuild };
|
|
165
|
+
}
|