@izagood/avcs 0.15.1 → 0.17.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/README.md +3 -3
- package/dist/api/repo.d.ts +101 -1
- package/dist/api/repo.d.ts.map +1 -1
- package/dist/api/repo.js +453 -30
- package/dist/api/repo.js.map +1 -1
- package/dist/cli.js +58 -6
- package/dist/cli.js.map +1 -1
- package/dist/hub/hubClient.d.ts +18 -0
- package/dist/hub/hubClient.d.ts.map +1 -1
- package/dist/hub/hubClient.js +40 -0
- package/dist/hub/hubClient.js.map +1 -1
- package/dist/hub/hubServer.d.ts +5 -2
- package/dist/hub/hubServer.d.ts.map +1 -1
- package/dist/hub/hubServer.js +98 -3
- package/dist/hub/hubServer.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +39 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/objects/types.d.ts +54 -5
- package/dist/objects/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/api/repo.js
CHANGED
|
@@ -60,13 +60,18 @@ export class Repo {
|
|
|
60
60
|
// the rare mutations that can invalidate them (gc deletes; redaction overwrites bytes).
|
|
61
61
|
#opCache = new Map();
|
|
62
62
|
#blobCache = new Map();
|
|
63
|
-
// Last full reduction's snapshot, for
|
|
64
|
-
// main materialize path updates it; reduceIncremental is correct for
|
|
65
|
-
// (harness-proven) and throws NonIncrementalError otherwise (→ fall
|
|
66
|
-
// so no filter key is needed. Opt
|
|
67
|
-
// AVCS_VERIFY_INCREMENTAL=1 cross-checks every incremental result against a full reduce
|
|
63
|
+
// Last full reduction's snapshot, for incremental reduce (docs/11 A6b — DEFAULT ON since
|
|
64
|
+
// Phase 13.3). Only the main materialize path updates it; reduceIncremental is correct for
|
|
65
|
+
// ANY append-superset (harness-proven) and throws NonIncrementalError otherwise (→ fall
|
|
66
|
+
// back to full reduce), so no filter key is needed. Opt OUT via AVCS_INCREMENTAL=0;
|
|
67
|
+
// AVCS_VERIFY_INCREMENTAL=1 cross-checks every incremental result against a full reduce
|
|
68
|
+
// (run as a dedicated CI job, not recommended on the hot path).
|
|
68
69
|
#incSnap = null;
|
|
69
|
-
#forceSnapshot = false; // set by compact() to capture a snapshot
|
|
70
|
+
#forceSnapshot = false; // set by compact() to capture a snapshot even when opted out
|
|
71
|
+
// Op count of the last PERSISTED base per view (Phase 13.3 amortized auto-compaction):
|
|
72
|
+
// once the live snapshot is ≥ AUTO_COMPACT_DELTA ops past it, materialize re-persists.
|
|
73
|
+
#persistedBaseOps = new Map();
|
|
74
|
+
static AUTO_COMPACT_DELTA = 256;
|
|
70
75
|
constructor(dir, store) {
|
|
71
76
|
this.dir = dir;
|
|
72
77
|
this.store = store;
|
|
@@ -770,6 +775,336 @@ export class Repo {
|
|
|
770
775
|
}
|
|
771
776
|
return result;
|
|
772
777
|
}
|
|
778
|
+
// ── integration queue (Phase 14, docs/17) ──────────────────────────────────
|
|
779
|
+
// The end of "head moved — pull and re-reduce first": since ops are an append-only
|
|
780
|
+
// union and reduce is deterministic, a stale submission is never rejected for
|
|
781
|
+
// staleness — the queue re-reduces the frontier UNION on the submitter's behalf.
|
|
782
|
+
// This is a repo API (not hub-only): it also kills the local multi-process funnel.
|
|
783
|
+
/** Causal closure (op oids) of a frontier. Missing objects are skipped — callers gate
|
|
784
|
+
* completeness separately via #missingCausalDeps. */
|
|
785
|
+
async #closureOf(heads) {
|
|
786
|
+
const seen = new Set();
|
|
787
|
+
const stack = [...heads];
|
|
788
|
+
while (stack.length) {
|
|
789
|
+
const id = stack.pop();
|
|
790
|
+
if (seen.has(id))
|
|
791
|
+
continue;
|
|
792
|
+
seen.add(id);
|
|
793
|
+
if (!(await this.store.has(id)))
|
|
794
|
+
continue;
|
|
795
|
+
const op = await this.store.get(id);
|
|
796
|
+
for (const d of op.causalDeps)
|
|
797
|
+
if (!seen.has(d))
|
|
798
|
+
stack.push(d);
|
|
799
|
+
}
|
|
800
|
+
return seen;
|
|
801
|
+
}
|
|
802
|
+
#queueRel(view) {
|
|
803
|
+
return join("queue", `${view}.json`);
|
|
804
|
+
}
|
|
805
|
+
async #readReservation(view) {
|
|
806
|
+
const raw = await this.store.readAux(this.#queueRel(view));
|
|
807
|
+
if (!raw)
|
|
808
|
+
return null;
|
|
809
|
+
try {
|
|
810
|
+
const r = JSON.parse(raw.toString("utf8"));
|
|
811
|
+
return r && typeof r.ticketId === "string" ? r : null;
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async #writeReservation(view, resv) {
|
|
818
|
+
await this.store.writeAux(this.#queueRel(view), JSON.stringify(resv) + "\n");
|
|
819
|
+
}
|
|
820
|
+
/** Record an Integration verdict (append-only audit) and point the idempotency ref at it. */
|
|
821
|
+
async #recordIntegration(fields) {
|
|
822
|
+
const integ = { type: "integration", ...fields, createdAt: new Date().toISOString() };
|
|
823
|
+
const oid = await this.store.put(integ);
|
|
824
|
+
await this.store.setRef(`integration:${fields.view}:${fields.ticketId}`, oid);
|
|
825
|
+
return oid;
|
|
826
|
+
}
|
|
827
|
+
/** Author a checkpoint AT an integrated frontier (never `materialize(view)` — §1-(A):
|
|
828
|
+
* a view materialize would sweep in un-submitted third-party ops). */
|
|
829
|
+
async #authorIntegratedCheckpoint(view, integrated, evidence, evidenceBinding, summary) {
|
|
830
|
+
const v = await this.getView(view);
|
|
831
|
+
const cp = {
|
|
832
|
+
type: "checkpoint",
|
|
833
|
+
viewOid: v.oid,
|
|
834
|
+
headOps: integrated.headOps,
|
|
835
|
+
treeHash: integrated.treeHash,
|
|
836
|
+
policyOid: (await this.store.getRef("policy")),
|
|
837
|
+
materializerVersion: MATERIALIZER_VERSION,
|
|
838
|
+
evidence,
|
|
839
|
+
...(evidenceBinding && Object.keys(evidenceBinding).length ? { evidenceBinding } : {}),
|
|
840
|
+
status: integrated.conflicts.length === 0 ? "verified" : "draft",
|
|
841
|
+
summary,
|
|
842
|
+
createdAt: new Date().toISOString(),
|
|
843
|
+
};
|
|
844
|
+
return this.store.put(cp);
|
|
845
|
+
}
|
|
846
|
+
/** Verified (non-agent, canonically ordered) evidence bound to exactly `treeHash`. */
|
|
847
|
+
async #boundEvidenceFor(treeHash) {
|
|
848
|
+
const out = {};
|
|
849
|
+
const all = this.#verifiedEvidence(await this.store.collect("evidence")).sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) ||
|
|
850
|
+
((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
|
|
851
|
+
for (const ev of all) {
|
|
852
|
+
if (ev.producedBy.kind === "ai_agent")
|
|
853
|
+
continue;
|
|
854
|
+
if (ev.treeHash === treeHash)
|
|
855
|
+
out[ev.kind] = ev.result;
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
/** Keys touched by a set of op oids (contention surface of a delta). */
|
|
860
|
+
async #keysOfOps(oids) {
|
|
861
|
+
const keys = new Set();
|
|
862
|
+
for (const oid of oids) {
|
|
863
|
+
if (!(await this.store.has(oid)))
|
|
864
|
+
continue;
|
|
865
|
+
const op = await this.store.get(oid);
|
|
866
|
+
for (const k of keysOf(op))
|
|
867
|
+
keys.add(k);
|
|
868
|
+
}
|
|
869
|
+
return keys;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Submit a draft checkpoint to the integration queue (docs/17 §14.2). Runs under the
|
|
873
|
+
* same `finalize:<view>` lock as finalize — the existing mkdir lock IS the serializer
|
|
874
|
+
* (no separate queue structure in v1). The outcome is always one of the four verdicts;
|
|
875
|
+
* "pull and redo" does not exist on any path.
|
|
876
|
+
*
|
|
877
|
+
* Idempotency: an `advanced` ticket replays its recorded verdict forever. Non-terminal
|
|
878
|
+
* verdicts (conflict/needs_evidence/rejected/expired) re-evaluate on resubmission —
|
|
879
|
+
* the world legitimately changes under them (a decision lands, evidence arrives, a
|
|
880
|
+
* missing object syncs), and a frozen replay would wedge the ticket.
|
|
881
|
+
*/
|
|
882
|
+
async submitIntegration(args) {
|
|
883
|
+
const view = args.view;
|
|
884
|
+
const ticketId = args.ticketId ?? sha256hex(`${view}:${args.checkpoint}`);
|
|
885
|
+
const result = await this.store.withLock(`finalize:${view}`, async () => {
|
|
886
|
+
// 1. Idempotency — a terminal success replays as-is (safe resubmission).
|
|
887
|
+
const priorRef = await this.store.getRef(`integration:${view}:${ticketId}`);
|
|
888
|
+
if (priorRef && (await this.store.has(priorRef))) {
|
|
889
|
+
const prior = await this.store.get(priorRef);
|
|
890
|
+
if (prior.verdict === "advanced") {
|
|
891
|
+
return { verdict: "advanced", head: prior.resultCheckpoint, integration: priorRef };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// 2. Reservation — one in-flight needs_evidence ticket at a time (TTL-bounded).
|
|
895
|
+
let resv = await this.#readReservation(view);
|
|
896
|
+
if (resv && Date.parse(resv.expiresAt) <= Date.now()) {
|
|
897
|
+
// Expired: audit it and let the queue move on (docs/17 §14 contract test).
|
|
898
|
+
await this.#recordIntegration({
|
|
899
|
+
view, ticketId: resv.ticketId, submittedCheckpoint: resv.submittedCheckpoint,
|
|
900
|
+
baseHead: await this.protectedHead(view), resultCheckpoint: resv.integratedCheckpoint,
|
|
901
|
+
verdict: "expired", reason: `needs_evidence reservation expired at ${resv.expiresAt}`, by: resv.by,
|
|
902
|
+
});
|
|
903
|
+
await this.#writeReservation(view, null);
|
|
904
|
+
resv = null;
|
|
905
|
+
}
|
|
906
|
+
if (resv && resv.ticketId !== ticketId) {
|
|
907
|
+
return { verdict: "queued", behindTicket: resv.ticketId, retryAfterMs: 1000 + Math.floor(Math.random() * 500) };
|
|
908
|
+
}
|
|
909
|
+
// 3. Causal completeness — never judge (or advance to) a partially-synced tree.
|
|
910
|
+
const cp = await this.store.get(args.checkpoint);
|
|
911
|
+
const missing = await this.#missingCausalDeps(cp.headOps);
|
|
912
|
+
if (missing.length) {
|
|
913
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead: await this.protectedHead(view), verdict: "rejected", reason: `incomplete causal history: ${missing.length} object(s) missing`, by: args.by });
|
|
914
|
+
return { verdict: "rejected", reason: `incomplete causal history: ${missing.length} object(s) missing — push them first (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""})` };
|
|
915
|
+
}
|
|
916
|
+
// Role gate (same as finalize).
|
|
917
|
+
const prot = await this.getProtection(view);
|
|
918
|
+
if (prot && !(await this.hasRole(args.by, prot.finalizeRole ?? "maintainer"))) {
|
|
919
|
+
const reason = `${args.by} lacks role ${prot.finalizeRole ?? "maintainer"} to integrate ${view}`;
|
|
920
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead: await this.protectedHead(view), verdict: "rejected", reason, by: args.by });
|
|
921
|
+
return { verdict: "rejected", reason };
|
|
922
|
+
}
|
|
923
|
+
// 4. Integration reduce — the frontier UNION via the materializeAt path (NEVER
|
|
924
|
+
// materialize(view): §1-(A), un-submitted third-party ops must stay out).
|
|
925
|
+
const baseHead = await this.protectedHead(view);
|
|
926
|
+
const curHeads = baseHead ? (await this.store.get(baseHead)).headOps : [];
|
|
927
|
+
const unionHeads = [...new Set([...curHeads, ...cp.headOps])];
|
|
928
|
+
const integrated = await this.materializeAt(unionHeads);
|
|
929
|
+
const subClosure = await this.#closureOf(cp.headOps);
|
|
930
|
+
const fastForward = curHeads.every((h) => subClosure.has(h));
|
|
931
|
+
// 5. Conflicts — the ONLY outcome that needs a human/agent decision, and it
|
|
932
|
+
// arrives as a minimal repair packet with decision memory, not "pull and redo".
|
|
933
|
+
if (integrated.conflicts.length > 0) {
|
|
934
|
+
const packet = { conflicts: [] };
|
|
935
|
+
for (const c of integrated.conflicts) {
|
|
936
|
+
const fc = integrated.fileConflicts.find((f) => `file:${f.file}` === c.key);
|
|
937
|
+
packet.conflicts.push({
|
|
938
|
+
key: c.key,
|
|
939
|
+
reason: c.reason,
|
|
940
|
+
options: c.options.map((o) => ({ op: o.opOid, actor: o.actor, purpose: o.purpose })),
|
|
941
|
+
...(fc ? { regions: fc.regions } : {}),
|
|
942
|
+
priorDecisions: await this.recallDecisions(c.key),
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const integration = await this.#recordIntegration({
|
|
946
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
947
|
+
verdict: "conflict", conflictKeys: packet.conflicts.map((c) => c.key), by: args.by,
|
|
948
|
+
});
|
|
949
|
+
return { verdict: "conflict", packet, integration };
|
|
950
|
+
}
|
|
951
|
+
// 6. Gates: approvals carry from the SUBMITTED checkpoint to the integrated one
|
|
952
|
+
// (GitHub counts PR approvals independently of the merge commit — same isomorphism).
|
|
953
|
+
let carriedApprovals;
|
|
954
|
+
if (prot && (prot.requiredApprovals > 0 || prot.requireOwnerApproval)) {
|
|
955
|
+
const verdicts = await this.#approvalVerdicts(args.checkpoint);
|
|
956
|
+
if ([...verdicts.values()].includes("request_changes")) {
|
|
957
|
+
const reason = "changes requested by a reviewer";
|
|
958
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
959
|
+
return { verdict: "rejected", reason };
|
|
960
|
+
}
|
|
961
|
+
const approvers = [...verdicts].filter(([, v]) => v === "approve").map(([id]) => id);
|
|
962
|
+
if (approvers.length < prot.requiredApprovals) {
|
|
963
|
+
const reason = `needs ${prot.requiredApprovals} approval(s), have ${approvers.length}`;
|
|
964
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
965
|
+
return { verdict: "rejected", reason };
|
|
966
|
+
}
|
|
967
|
+
if (prot.requireOwnerApproval) {
|
|
968
|
+
let owner = false;
|
|
969
|
+
for (const id of approvers)
|
|
970
|
+
if (await this.hasRole(id, "maintainer")) {
|
|
971
|
+
owner = true;
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
if (!owner) {
|
|
975
|
+
const reason = "requires an owner (maintainer+) approval";
|
|
976
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
977
|
+
return { verdict: "rejected", reason };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (prot.integration?.carryApprovals !== false) {
|
|
981
|
+
carriedApprovals = (await this.store.collect("approval"))
|
|
982
|
+
.filter((a) => a.checkpointOid === args.checkpoint)
|
|
983
|
+
.map((a) => a.oid);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const waived = await this.#activeWaivers(view);
|
|
987
|
+
const required = (prot?.requiredChecks ?? []).filter((k) => !waived.has(k));
|
|
988
|
+
const advance = async (headCp, evidenceBinding, resultIsSubmitted = false) => {
|
|
989
|
+
await this.store.setRef(`head:${view}`, headCp);
|
|
990
|
+
const integration = await this.#recordIntegration({
|
|
991
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
992
|
+
resultCheckpoint: resultIsSubmitted ? undefined : headCp,
|
|
993
|
+
verdict: "advanced", evidenceBinding, ...(carriedApprovals ? { carriedApprovals } : {}), by: args.by,
|
|
994
|
+
});
|
|
995
|
+
if (resv?.ticketId === ticketId)
|
|
996
|
+
await this.#writeReservation(view, null);
|
|
997
|
+
this.logger.info("integrate.advanced", { view, ticketId, head: headCp, evidenceBinding });
|
|
998
|
+
return { verdict: "advanced", head: headCp, integration };
|
|
999
|
+
};
|
|
1000
|
+
// 7a. Fast-forward — the current head is inside the submission's causal closure:
|
|
1001
|
+
// classic finalize semantics, fresh binding, no re-authored checkpoint.
|
|
1002
|
+
if (fastForward) {
|
|
1003
|
+
for (const k of required) {
|
|
1004
|
+
if (cp.evidence[k] !== "pass") {
|
|
1005
|
+
const reason = `required check ${k} not pass`;
|
|
1006
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1007
|
+
return { verdict: "rejected", reason };
|
|
1008
|
+
}
|
|
1009
|
+
if (prot?.requireBoundEvidence && cp.evidenceBinding?.[k] !== "bound") {
|
|
1010
|
+
const reason = `required check ${k} passed but its evidence is not bound to this tree`;
|
|
1011
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1012
|
+
return { verdict: "rejected", reason };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return advance(args.checkpoint, "fresh", true);
|
|
1016
|
+
}
|
|
1017
|
+
// 7b. Head moved: the integrated tree differs from the submitted one, so the
|
|
1018
|
+
// submitted evidence does NOT prove it (docs/17 §14.5). Decide by evidence mode.
|
|
1019
|
+
const T = integrated.treeHash;
|
|
1020
|
+
const needsEvidence = async () => {
|
|
1021
|
+
const draft = resv?.ticketId === ticketId && resv.treeHash === T
|
|
1022
|
+
? resv.integratedCheckpoint
|
|
1023
|
+
: await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)} (awaiting evidence)`);
|
|
1024
|
+
const ttl = prot?.integration?.reserveTtlMs ?? 10 * 60_000;
|
|
1025
|
+
await this.#writeReservation(view, {
|
|
1026
|
+
ticketId, submittedCheckpoint: args.checkpoint, integratedCheckpoint: draft,
|
|
1027
|
+
treeHash: T, requiredChecks: required, by: args.by,
|
|
1028
|
+
expiresAt: new Date(Date.now() + ttl).toISOString(),
|
|
1029
|
+
});
|
|
1030
|
+
// What the submitter is missing locally to reproduce T: the head-side delta ops
|
|
1031
|
+
// plus the blobs they reference (determinism does the rest — docs/17 §14.5 fresh).
|
|
1032
|
+
const headClosure = await this.#closureOf(curHeads);
|
|
1033
|
+
const missingLocally = [];
|
|
1034
|
+
for (const oid of headClosure) {
|
|
1035
|
+
if (subClosure.has(oid))
|
|
1036
|
+
continue;
|
|
1037
|
+
missingLocally.push(oid);
|
|
1038
|
+
if (await this.store.has(oid)) {
|
|
1039
|
+
const op = await this.store.get(oid);
|
|
1040
|
+
for (const b of [op.body.blobOid, op.body.baseBlobOid])
|
|
1041
|
+
if (b)
|
|
1042
|
+
missingLocally.push(b);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const integration = await this.#recordIntegration({
|
|
1046
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
1047
|
+
resultCheckpoint: draft, verdict: "needs_evidence", by: args.by,
|
|
1048
|
+
});
|
|
1049
|
+
return { verdict: "needs_evidence", integratedCheckpoint: draft, treeHash: T, requiredChecks: required, missingLocally, ticketId, integration };
|
|
1050
|
+
};
|
|
1051
|
+
// Resubmission holding the reservation: accept iff fresh evidence bound to the
|
|
1052
|
+
// reserved tree now covers the required checks — exactly one validation run.
|
|
1053
|
+
if (resv && resv.ticketId === ticketId && resv.treeHash === T) {
|
|
1054
|
+
const bound = await this.#boundEvidenceFor(T);
|
|
1055
|
+
if (required.every((k) => bound[k] === "pass")) {
|
|
1056
|
+
const binding = {};
|
|
1057
|
+
for (const k of Object.keys(bound))
|
|
1058
|
+
binding[k] = "bound";
|
|
1059
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, bound, binding, `integration ${ticketId.slice(0, 12)}`);
|
|
1060
|
+
return advance(finalCp, "fresh");
|
|
1061
|
+
}
|
|
1062
|
+
return needsEvidence(); // reservation refreshed; still exactly one validation owed
|
|
1063
|
+
}
|
|
1064
|
+
if (required.length === 0) {
|
|
1065
|
+
// Nothing to prove — integrate directly (evidence-less views).
|
|
1066
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)}`);
|
|
1067
|
+
return advance(finalCp, "fresh");
|
|
1068
|
+
}
|
|
1069
|
+
const mode = prot?.integration?.evidenceMode ?? "carry-disjoint";
|
|
1070
|
+
let carry = mode === "carry-always";
|
|
1071
|
+
if (mode === "carry-disjoint") {
|
|
1072
|
+
const headClosure = await this.#closureOf(curHeads);
|
|
1073
|
+
const oursOnly = [...subClosure].filter((o) => !headClosure.has(o));
|
|
1074
|
+
const theirsOnly = [...headClosure].filter((o) => !subClosure.has(o));
|
|
1075
|
+
const ourKeys = await this.#keysOfOps(oursOnly);
|
|
1076
|
+
const theirKeys = await this.#keysOfOps(theirsOnly);
|
|
1077
|
+
// Disjoint deltas + zero new conflicts (checked above) ⇒ the same risk a git
|
|
1078
|
+
// user already accepts when merging two independently-green branches — but
|
|
1079
|
+
// machine-checked, recorded, and opt-out-able (docs/17 §14.5).
|
|
1080
|
+
carry = [...ourKeys].every((k) => !theirKeys.has(k));
|
|
1081
|
+
}
|
|
1082
|
+
// Carried evidence is not tree-bound; a protection that demands bound evidence
|
|
1083
|
+
// therefore forces the fresh path whenever the head has moved.
|
|
1084
|
+
if (prot?.requireBoundEvidence)
|
|
1085
|
+
carry = false;
|
|
1086
|
+
if (carry) {
|
|
1087
|
+
for (const k of required) {
|
|
1088
|
+
if (cp.evidence[k] !== "pass") {
|
|
1089
|
+
const reason = `required check ${k} not pass`;
|
|
1090
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1091
|
+
return { verdict: "rejected", reason };
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// The carry is never silent: recorded on BOTH the checkpoint and the Integration.
|
|
1095
|
+
const binding = {};
|
|
1096
|
+
for (const k of Object.keys(cp.evidence))
|
|
1097
|
+
binding[k] = "carried";
|
|
1098
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, cp.evidence, binding, `integration ${ticketId.slice(0, 12)} (carried evidence)`);
|
|
1099
|
+
return advance(finalCp, "carried");
|
|
1100
|
+
}
|
|
1101
|
+
return needsEvidence();
|
|
1102
|
+
});
|
|
1103
|
+
if (result.verdict !== "advanced") {
|
|
1104
|
+
this.logger.info("integrate.verdict", { view, ticketId, verdict: result.verdict });
|
|
1105
|
+
}
|
|
1106
|
+
return result;
|
|
1107
|
+
}
|
|
773
1108
|
// ── security (Phase 12) ────────────────────────────────────────────────────
|
|
774
1109
|
/**
|
|
775
1110
|
* Redact (tombstone) a blob's bytes — for a leaked secret. Admin-only. The oid is
|
|
@@ -997,6 +1332,47 @@ export class Repo {
|
|
|
997
1332
|
throw new Error(`unknown remote: ${nameOrUrl} (run \`avcs remote add ${nameOrUrl} <url>\`)`);
|
|
998
1333
|
return r.url;
|
|
999
1334
|
}
|
|
1335
|
+
/** Public remote-name → hub-URL resolution (a literal URL passes through). */
|
|
1336
|
+
async remoteUrl(nameOrUrl) {
|
|
1337
|
+
return this.#resolveRemote(nameOrUrl);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Submit a draft checkpoint to a REMOTE hub's integration queue (Phase 14, docs/17
|
|
1341
|
+
* §14.4), with capability detection: a hub advertising `integrate` on GET /version
|
|
1342
|
+
* gets the queue path (one judgment, no redo); an older hub falls back to the legacy
|
|
1343
|
+
* finalize + pull retry funnel (bounded) — the exact loop the queue exists to kill,
|
|
1344
|
+
* kept only for backward compatibility.
|
|
1345
|
+
*/
|
|
1346
|
+
async integrateHub(remoteOrUrl, args) {
|
|
1347
|
+
const url = await this.#resolveRemote(remoteOrUrl);
|
|
1348
|
+
const signWith = args.signWith ?? (await this.#resolveHubSigner(args.by));
|
|
1349
|
+
let hasIntegrate = false;
|
|
1350
|
+
try {
|
|
1351
|
+
const v = (await (await fetch(`${url}/version`)).json());
|
|
1352
|
+
hasIntegrate = v.integrate === true;
|
|
1353
|
+
}
|
|
1354
|
+
catch { /* unreachable /version → treat as legacy */ }
|
|
1355
|
+
if (hasIntegrate) {
|
|
1356
|
+
const { integrateWithHub } = await import("../hub/hubClient.js");
|
|
1357
|
+
const r = await integrateWithHub(this.dir, url, { ...args, signWith });
|
|
1358
|
+
// needs_evidence pulled delta objects through a separate store — refresh caches.
|
|
1359
|
+
this.#blobCache.clear();
|
|
1360
|
+
return r;
|
|
1361
|
+
}
|
|
1362
|
+
// Legacy fallback (old hub): finalize CAS + pull, bounded retries. This CAN lose
|
|
1363
|
+
// races (that is exactly the funnel Phase 14 removes) — surfaced honestly.
|
|
1364
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1365
|
+
await this.pullHub(url);
|
|
1366
|
+
const parentHead = await this.protectedHead(args.view);
|
|
1367
|
+
await this.pushHub(url, { as: args.by });
|
|
1368
|
+
const r = await this.finalizeHub(url, { view: args.view, newCheckpoint: args.checkpoint, parentHead, by: args.by, signWith });
|
|
1369
|
+
if (r.finalized)
|
|
1370
|
+
return { verdict: "advanced", head: r.head ?? args.checkpoint, legacy: true };
|
|
1371
|
+
if (!/head moved/.test(r.reason ?? ""))
|
|
1372
|
+
return { verdict: "rejected", reason: r.reason ?? `finalize failed (${r.status})`, legacy: true };
|
|
1373
|
+
}
|
|
1374
|
+
return { verdict: "rejected", reason: "legacy hub: lost the finalize CAS race 3 times — retry, or upgrade the hub for queue semantics", legacy: true };
|
|
1375
|
+
}
|
|
1000
1376
|
/**
|
|
1001
1377
|
* Bidirectional convergence with a named remote (default "origin"): pull what the hub
|
|
1002
1378
|
* has that we lack, then push what we have that it lacks. Pure object gossip — union
|
|
@@ -1047,9 +1423,11 @@ export class Repo {
|
|
|
1047
1423
|
}
|
|
1048
1424
|
async materialize(viewName = "main", opts) {
|
|
1049
1425
|
this.metrics.inc("materialize.calls");
|
|
1050
|
-
// Compaction (B3): on a cold instance, seed the incremental base
|
|
1051
|
-
// snapshot so this materialize re-reduces only ops added since it,
|
|
1052
|
-
|
|
1426
|
+
// Compaction (B3, default since 13.3): on a cold instance, seed the incremental base
|
|
1427
|
+
// from the persisted snapshot so this materialize re-reduces only ops added since it,
|
|
1428
|
+
// not all history. A corrupt/stale/version-mismatched snapshot is discarded (→ full
|
|
1429
|
+
// reduce), so correctness never depends on the file.
|
|
1430
|
+
if (process.env.AVCS_INCREMENTAL !== "0" && !this.#incSnap)
|
|
1053
1431
|
await this.#loadPersistedSnapshot(viewName);
|
|
1054
1432
|
const view = await this.getView(viewName);
|
|
1055
1433
|
const q = view.query;
|
|
@@ -1101,11 +1479,37 @@ export class Repo {
|
|
|
1101
1479
|
// A caller may override the view's default status filter (e.g. to project pending/gated
|
|
1102
1480
|
// ops so their computed 3-way merge can be inspected before acceptance — issue #13).
|
|
1103
1481
|
const includeStatuses = opts?.includeStatuses ?? q.includeStatuses;
|
|
1104
|
-
const res = await this.#reduceOpSet(kept, includeStatuses, true); // main path:
|
|
1482
|
+
const res = await this.#reduceOpSet(kept, includeStatuses, true); // main path: incremental by default
|
|
1105
1483
|
for (const oid of quarantined)
|
|
1106
1484
|
res.statuses.set(oid, "quarantined");
|
|
1485
|
+
await this.#maybeAutoCompact(viewName, res);
|
|
1107
1486
|
return res;
|
|
1108
1487
|
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Amortized compaction (Phase 13.3): after a main-path materialize, re-persist the base
|
|
1490
|
+
* snapshot once the live snapshot is ≥ AUTO_COMPACT_DELTA ops past the last persisted
|
|
1491
|
+
* base, so a cold start never replays an unbounded history. The treeHash guard ties the
|
|
1492
|
+
* in-memory snapshot to THIS view's result (a reduce-cache hit may have left #incSnap
|
|
1493
|
+
* pointing at another view's reduction). Best-effort: a persist failure only logs — the
|
|
1494
|
+
* read path never depends on it.
|
|
1495
|
+
*/
|
|
1496
|
+
async #maybeAutoCompact(viewName, res) {
|
|
1497
|
+
if (process.env.AVCS_INCREMENTAL === "0")
|
|
1498
|
+
return;
|
|
1499
|
+
const snap = this.#incSnap;
|
|
1500
|
+
if (!snap || snap.result.treeHash !== res.treeHash)
|
|
1501
|
+
return;
|
|
1502
|
+
const base = this.#persistedBaseOps.get(viewName) ?? 0;
|
|
1503
|
+
if (snap.input.ops.length - base < _a.AUTO_COMPACT_DELTA)
|
|
1504
|
+
return;
|
|
1505
|
+
try {
|
|
1506
|
+
await this.store.withLock(`snapshot:${viewName}`, () => this.#persistSnapshot(viewName, snap));
|
|
1507
|
+
this.metrics.inc("snapshot.auto.persisted");
|
|
1508
|
+
}
|
|
1509
|
+
catch (e) {
|
|
1510
|
+
this.logger.warn("snapshot.auto.failed", { view: viewName, error: e.message });
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1109
1513
|
/**
|
|
1110
1514
|
* Partition candidate ops into those whose transitive causalDeps are all PRESENT in
|
|
1111
1515
|
* the store vs those still waiting on a missing dep (E4). A dep absent from the store
|
|
@@ -1236,19 +1640,18 @@ export class Repo {
|
|
|
1236
1640
|
};
|
|
1237
1641
|
}
|
|
1238
1642
|
/**
|
|
1239
|
-
* Pass-1 reduce (docs/11 A6b). With
|
|
1240
|
-
* only the delta via `reduceIncremental` (falling back to a full
|
|
1241
|
-
* preconditions don't hold — e.g. policy changed, or `base` is not
|
|
1242
|
-
* the snapshot).
|
|
1243
|
-
* materialize path passes `useInc`, so
|
|
1244
|
-
* never read or pollute the snapshot.
|
|
1245
|
-
* incremental result against a full reduce
|
|
1643
|
+
* Pass-1 reduce (docs/11 A6b — incremental is the DEFAULT since Phase 13.3). With a prior
|
|
1644
|
+
* snapshot, re-reduce only the delta via `reduceIncremental` (falling back to a full
|
|
1645
|
+
* `snapshotReduce` if the preconditions don't hold — e.g. policy changed, or `base` is not
|
|
1646
|
+
* an append-superset of the snapshot). Opt OUT with AVCS_INCREMENTAL=0 (plain full
|
|
1647
|
+
* `reduce`, the pre-13.3 default). Only the main materialize path passes `useInc`, so
|
|
1648
|
+
* subset reducers (materializeAt/history/bisect) never read or pollute the snapshot.
|
|
1649
|
+
* AVCS_VERIFY_INCREMENTAL=1 cross-checks each incremental result against a full reduce
|
|
1650
|
+
* and throws on any divergence (runs as a dedicated CI job).
|
|
1246
1651
|
*/
|
|
1247
1652
|
#pass1Reduce(base, useInc) {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const optIn = useInc && (process.env.AVCS_INCREMENTAL === "1" || process.env.AVCS_COMPACT === "1" || this.#forceSnapshot);
|
|
1251
|
-
if (!optIn)
|
|
1653
|
+
const on = useInc && (process.env.AVCS_INCREMENTAL !== "0" || this.#forceSnapshot);
|
|
1654
|
+
if (!on)
|
|
1252
1655
|
return reduce(base);
|
|
1253
1656
|
let snap;
|
|
1254
1657
|
if (this.#incSnap) {
|
|
@@ -1672,7 +2075,7 @@ export class Repo {
|
|
|
1672
2075
|
}
|
|
1673
2076
|
/**
|
|
1674
2077
|
* Compaction (docs/11 B3): persist the current reduction of `view` as a durable base
|
|
1675
|
-
* snapshot. A
|
|
2078
|
+
* snapshot. A COLD materialize loads it BY DEFAULT (Phase 13.3) and `reduceIncremental`s
|
|
1676
2079
|
* only the ops added since — folding settled history into the base instead of replaying
|
|
1677
2080
|
* it — while the original ops stay on disk (append-only audit preserved). Correctness is
|
|
1678
2081
|
* the same invariant as Track A: reduceIncremental(base, current) ≡ full reduce, gated by
|
|
@@ -1688,25 +2091,45 @@ export class Repo {
|
|
|
1688
2091
|
}
|
|
1689
2092
|
if (!this.#incSnap)
|
|
1690
2093
|
return { baseOps: 0 };
|
|
1691
|
-
|
|
1692
|
-
// writeAux routes through the store's temp→fsync→rename→fsync-dir path. A torn read
|
|
1693
|
-
// would still fall back to a full reduce (#loadPersistedSnapshot catches decode
|
|
1694
|
-
// errors), but a durable atomic write means the base is never silently corrupt.
|
|
1695
|
-
await this.store.writeAux(join("snapshot", `${view}.cbor`), encodeCbor(serializeSnapshot(this.#incSnap)));
|
|
2094
|
+
await this.#persistSnapshot(view, this.#incSnap);
|
|
1696
2095
|
const baseOps = this.#incSnap.input.ops.length;
|
|
1697
2096
|
this.logger.info("compact", { view, baseOps });
|
|
1698
2097
|
return { baseOps };
|
|
1699
2098
|
}
|
|
1700
|
-
/**
|
|
2099
|
+
/**
|
|
2100
|
+
* Persist a snapshot as the view's durable compaction base, stamped with the
|
|
2101
|
+
* materializer version + active policy oid (Phase 13.3): a cold load rejects the file
|
|
2102
|
+
* when either changed, so a merge-algorithm or policy update silently invalidates stale
|
|
2103
|
+
* bases (the warm path's invalidation is NonIncrementalError, handled in #pass1Reduce).
|
|
2104
|
+
* Atomic write (D2): writeAux routes through the store's temp→fsync→rename→fsync-dir
|
|
2105
|
+
* path, so a reader sees old-or-complete — never a torn CBOR file.
|
|
2106
|
+
*/
|
|
2107
|
+
async #persistSnapshot(view, snap) {
|
|
2108
|
+
const header = { materializerVersion: MATERIALIZER_VERSION, policyOid: (await this.store.getRef("policy")) ?? "default" };
|
|
2109
|
+
await this.store.writeAux(join("snapshot", `${view}.cbor`), encodeCbor({ header, snapshot: serializeSnapshot(snap) }));
|
|
2110
|
+
this.#persistedBaseOps.set(view, snap.input.ops.length);
|
|
2111
|
+
}
|
|
2112
|
+
/** Load a persisted compaction base into the in-memory incremental snapshot (B3).
|
|
2113
|
+
* Rejects (and ignores) a corrupt file, a pre-13.3 headerless file, or a header whose
|
|
2114
|
+
* materializer version / policy oid no longer matches — full reduce is always correct. */
|
|
1701
2115
|
async #loadPersistedSnapshot(view) {
|
|
1702
2116
|
const p = join(this.dir, ".avcs", "snapshot", `${view}.cbor`);
|
|
1703
2117
|
if (!existsSync(p))
|
|
1704
2118
|
return;
|
|
1705
2119
|
try {
|
|
1706
|
-
|
|
2120
|
+
const raw = decodeCbor(await readFile(p));
|
|
2121
|
+
const policyOid = (await this.store.getRef("policy")) ?? "default";
|
|
2122
|
+
if (raw.header?.materializerVersion !== MATERIALIZER_VERSION || raw.header?.policyOid !== policyOid || raw.snapshot === undefined) {
|
|
2123
|
+
this.metrics.inc("snapshot.cold.rejected");
|
|
2124
|
+
return; // stale/incompatible base → full reduce
|
|
2125
|
+
}
|
|
2126
|
+
this.#incSnap = deserializeSnapshot(raw.snapshot);
|
|
2127
|
+
this.#persistedBaseOps.set(view, this.#incSnap.input.ops.length);
|
|
2128
|
+
this.metrics.inc("snapshot.cold.loaded");
|
|
1707
2129
|
}
|
|
1708
2130
|
catch {
|
|
1709
|
-
this.#incSnap = null; // corrupt
|
|
2131
|
+
this.#incSnap = null; // corrupt snapshot → full reduce (always correct)
|
|
2132
|
+
this.metrics.inc("snapshot.cold.rejected");
|
|
1710
2133
|
}
|
|
1711
2134
|
}
|
|
1712
2135
|
/**
|