@izagood/avcs 0.16.0 → 0.18.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 +177 -0
- package/dist/api/repo.d.ts.map +1 -1
- package/dist/api/repo.js +592 -5
- package/dist/api/repo.js.map +1 -1
- package/dist/cli.js +86 -0
- 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 +12 -2
- package/dist/hub/hubServer.d.ts.map +1 -1
- package/dist/hub/hubServer.js +201 -4
- package/dist/hub/hubServer.js.map +1 -1
- package/dist/hub/syncWatch.d.ts +56 -0
- package/dist/hub/syncWatch.d.ts.map +1 -0
- package/dist/hub/syncWatch.js +186 -0
- package/dist/hub/syncWatch.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +70 -5
- 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/dist/store/lock.d.ts +8 -0
- package/dist/store/lock.d.ts.map +1 -1
- package/dist/store/lock.js +33 -0
- package/dist/store/lock.js.map +1 -1
- package/package.json +1 -1
package/dist/api/repo.js
CHANGED
|
@@ -18,7 +18,7 @@ import { encodeCbor, decodeCbor } from "../core/cbor.js";
|
|
|
18
18
|
import { computeReliability } from "../policy/reliability.js";
|
|
19
19
|
import { defaultPolicy, MATERIALIZER_VERSION } from "../reducer/policy.js";
|
|
20
20
|
import { Keyring, generateKeypair, signMessage, } from "../core/identity.js";
|
|
21
|
-
import { checkLease, isActive } from "../concurrency/lease.js";
|
|
21
|
+
import { checkLease, isActive, scopesOverlap } from "../concurrency/lease.js";
|
|
22
22
|
import { Metrics } from "../observe/metrics.js";
|
|
23
23
|
import { silentLogger } from "../observe/logger.js";
|
|
24
24
|
// Sidecar: ignore EVERYTHING under .avcs/ (the `*` also ignores this file itself), so the
|
|
@@ -205,6 +205,17 @@ export class Repo {
|
|
|
205
205
|
* a no-auth/read-public hub, so signing is opt-in by having a key, not mandatory.
|
|
206
206
|
*/
|
|
207
207
|
async #resolveHubSigner(explicitActorId) {
|
|
208
|
+
const actorId = await this.localActorId(explicitActorId);
|
|
209
|
+
if (!actorId)
|
|
210
|
+
return undefined;
|
|
211
|
+
const privateKey = await this.loadLocalKey(actorId);
|
|
212
|
+
return privateKey ? { keyId: actorId, privateKey } : undefined;
|
|
213
|
+
}
|
|
214
|
+
/** The replica's local actor identity, resolved by the same order #resolveHubSigner
|
|
215
|
+
* uses (explicit → AVCS_ACTOR → config.json → sole private key) but WITHOUT requiring
|
|
216
|
+
* a private key to exist — a contention check (Phase 15.3) needs a perspective, not a
|
|
217
|
+
* credential. Returns undefined when nothing resolves. */
|
|
218
|
+
async localActorId(explicitActorId) {
|
|
208
219
|
let actorId = explicitActorId ?? process.env.AVCS_ACTOR;
|
|
209
220
|
if (!actorId) {
|
|
210
221
|
const cfg = await this.#readConfig();
|
|
@@ -219,10 +230,7 @@ export class Repo {
|
|
|
219
230
|
}
|
|
220
231
|
catch { /* no private keystore yet */ }
|
|
221
232
|
}
|
|
222
|
-
|
|
223
|
-
return undefined;
|
|
224
|
-
const privateKey = await this.loadLocalKey(actorId);
|
|
225
|
-
return privateKey ? { keyId: actorId, privateKey } : undefined;
|
|
233
|
+
return actorId;
|
|
226
234
|
}
|
|
227
235
|
/**
|
|
228
236
|
* Provision an owner key: mint a keypair, register the public half as trusted, and
|
|
@@ -386,6 +394,18 @@ export class Repo {
|
|
|
386
394
|
// Maintain the entity index (Phase 9): key → op oids for fast history/blame.
|
|
387
395
|
for (const key of keysOf({ ...op, oid }))
|
|
388
396
|
await this.store.appendEntityIndex(key, oid);
|
|
397
|
+
if (args.warnContention) {
|
|
398
|
+
const warnings = await this.contention({ keys: [...keysOf({ ...op, oid })], actorId: op.actor.id, line: args.line });
|
|
399
|
+
for (const w of warnings) {
|
|
400
|
+
this.metrics.inc("contention.warnings");
|
|
401
|
+
this.logger.warn("contention.warn", {
|
|
402
|
+
key: w.key,
|
|
403
|
+
op: oid,
|
|
404
|
+
theirs: w.theirs.map((t) => `${t.actor}:${t.op.slice(0, 16)}`),
|
|
405
|
+
leaseHolders: w.leaseHolders.map((l) => l.actor),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
389
409
|
return oid;
|
|
390
410
|
}
|
|
391
411
|
/** Convenience: write file content as a blob + a put_file operation. */
|
|
@@ -403,6 +423,7 @@ export class Repo {
|
|
|
403
423
|
line: args.line,
|
|
404
424
|
workspace: args.workspace,
|
|
405
425
|
signWith: args.signWith,
|
|
426
|
+
warnContention: args.warnContention,
|
|
406
427
|
});
|
|
407
428
|
}
|
|
408
429
|
/**
|
|
@@ -429,6 +450,7 @@ export class Repo {
|
|
|
429
450
|
line: args.line,
|
|
430
451
|
workspace: args.workspace,
|
|
431
452
|
signWith: args.signWith,
|
|
453
|
+
warnContention: args.warnContention,
|
|
432
454
|
});
|
|
433
455
|
}
|
|
434
456
|
async attachEvidence(args) {
|
|
@@ -492,6 +514,88 @@ export class Repo {
|
|
|
492
514
|
return { granted: true, leaseOid: await this.store.put(lease) };
|
|
493
515
|
});
|
|
494
516
|
}
|
|
517
|
+
// ── contention: early conflict warning (Phase 15.3, docs/17 §15.3) ──────────
|
|
518
|
+
/**
|
|
519
|
+
* Report contention on entity keys BEFORE finalize would discover it: for each key,
|
|
520
|
+
* the operations by other actors that the caller has not built on (outside the
|
|
521
|
+
* caller's causal closure) and are still live (neither decision-rejected nor built
|
|
522
|
+
* upon by a later op on the key), plus other actors' active leases overlapping the
|
|
523
|
+
* key. Discovery is via the entity index — O(ops-on-key), no reduce.
|
|
524
|
+
*
|
|
525
|
+
* Perspective resolution ("mine"), first hit wins:
|
|
526
|
+
* - `sessionOid`: that session's actor; its ops seed both the key set and the closure.
|
|
527
|
+
* - `actorId` (+ optional `keys`): that actor's ops on the resolved keys seed the
|
|
528
|
+
* closure; with no `keys` given, every key the actor has authored on is checked.
|
|
529
|
+
* - `keys` alone: no closure filter — everything live by anyone on the key reports.
|
|
530
|
+
*/
|
|
531
|
+
async contention(args) {
|
|
532
|
+
const line = args.line ?? "main";
|
|
533
|
+
let mine = args.actorId;
|
|
534
|
+
const keys = new Set(args.keys ?? []);
|
|
535
|
+
const myOpOids = [];
|
|
536
|
+
if (args.sessionOid) {
|
|
537
|
+
const sess = await this.store.get(args.sessionOid);
|
|
538
|
+
mine ??= sess.actor.id;
|
|
539
|
+
for (const op of await this.#allOpsTailed()) {
|
|
540
|
+
if (op.sessionOid !== args.sessionOid)
|
|
541
|
+
continue;
|
|
542
|
+
myOpOids.push(op.oid);
|
|
543
|
+
for (const k of keysOf(op))
|
|
544
|
+
keys.add(k);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else if (mine) {
|
|
548
|
+
for (const op of await this.#allOpsTailed()) {
|
|
549
|
+
if (op.actor.id !== mine)
|
|
550
|
+
continue;
|
|
551
|
+
if (keys.size && ![...keysOf(op)].some((k) => keys.has(k)))
|
|
552
|
+
continue;
|
|
553
|
+
myOpOids.push(op.oid);
|
|
554
|
+
if (!args.keys?.length)
|
|
555
|
+
for (const k of keysOf(op))
|
|
556
|
+
keys.add(k);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (!keys.size)
|
|
560
|
+
return [];
|
|
561
|
+
// Ops I've already seen/built on are not surprises — they're my history.
|
|
562
|
+
const myClosure = myOpOids.length ? await this.#closureOf(myOpOids) : new Set();
|
|
563
|
+
const rejected = new Set((await this.store.collect("decision")).flatMap((d) => d.rejectedOps));
|
|
564
|
+
const leases = await this.activeLeases();
|
|
565
|
+
const out = [];
|
|
566
|
+
for (const key of [...keys].sort()) {
|
|
567
|
+
const ops = [];
|
|
568
|
+
for (const oid of await this.store.readEntityIndex(key)) {
|
|
569
|
+
if (!(await this.store.has(oid)))
|
|
570
|
+
continue; // GC'd since indexed
|
|
571
|
+
const op = await this.store.get(oid);
|
|
572
|
+
if ((op.line ?? "main") !== line || op.private)
|
|
573
|
+
continue;
|
|
574
|
+
ops.push({ ...op, oid });
|
|
575
|
+
}
|
|
576
|
+
// An op some later op (on any key) causally builds on is superseded work, not
|
|
577
|
+
// contention — one ancestry walk over the union of the key ops' deps finds them.
|
|
578
|
+
const builtUpon = await this.#closureOf(ops.flatMap((o) => o.causalDeps));
|
|
579
|
+
const theirs = ops
|
|
580
|
+
.filter((o) => {
|
|
581
|
+
const oid = o.oid;
|
|
582
|
+
return o.actor.id !== mine && !myClosure.has(oid) && !rejected.has(oid) && !builtUpon.has(oid);
|
|
583
|
+
})
|
|
584
|
+
.sort((a, b) => a.lamport - b.lamport || String(a.oid).localeCompare(String(b.oid)))
|
|
585
|
+
.map((o) => ({ op: o.oid, actor: o.actor.id, lamport: o.lamport, purpose: o.declaredPurpose, createdAt: o.createdAt }));
|
|
586
|
+
const leaseHolders = leases
|
|
587
|
+
.filter((l) => l.actor.id !== mine && l.writeScopes.some((s) => scopesOverlap(key, s)))
|
|
588
|
+
.map((l) => ({
|
|
589
|
+
actor: l.actor.id,
|
|
590
|
+
leaseOid: l.oid,
|
|
591
|
+
scope: l.writeScopes.find((s) => scopesOverlap(key, s)),
|
|
592
|
+
expiresAt: l.expiresAt,
|
|
593
|
+
}));
|
|
594
|
+
if (theirs.length || leaseHolders.length)
|
|
595
|
+
out.push({ key, theirs, leaseHolders });
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
495
599
|
/** Build a minimal repair packet for ops whose validation failed. */
|
|
496
600
|
async repairContext(opOids) {
|
|
497
601
|
const { buildRepairContext } = await import("../validation/repair.js");
|
|
@@ -775,6 +879,336 @@ export class Repo {
|
|
|
775
879
|
}
|
|
776
880
|
return result;
|
|
777
881
|
}
|
|
882
|
+
// ── integration queue (Phase 14, docs/17) ──────────────────────────────────
|
|
883
|
+
// The end of "head moved — pull and re-reduce first": since ops are an append-only
|
|
884
|
+
// union and reduce is deterministic, a stale submission is never rejected for
|
|
885
|
+
// staleness — the queue re-reduces the frontier UNION on the submitter's behalf.
|
|
886
|
+
// This is a repo API (not hub-only): it also kills the local multi-process funnel.
|
|
887
|
+
/** Causal closure (op oids) of a frontier. Missing objects are skipped — callers gate
|
|
888
|
+
* completeness separately via #missingCausalDeps. */
|
|
889
|
+
async #closureOf(heads) {
|
|
890
|
+
const seen = new Set();
|
|
891
|
+
const stack = [...heads];
|
|
892
|
+
while (stack.length) {
|
|
893
|
+
const id = stack.pop();
|
|
894
|
+
if (seen.has(id))
|
|
895
|
+
continue;
|
|
896
|
+
seen.add(id);
|
|
897
|
+
if (!(await this.store.has(id)))
|
|
898
|
+
continue;
|
|
899
|
+
const op = await this.store.get(id);
|
|
900
|
+
for (const d of op.causalDeps)
|
|
901
|
+
if (!seen.has(d))
|
|
902
|
+
stack.push(d);
|
|
903
|
+
}
|
|
904
|
+
return seen;
|
|
905
|
+
}
|
|
906
|
+
#queueRel(view) {
|
|
907
|
+
return join("queue", `${view}.json`);
|
|
908
|
+
}
|
|
909
|
+
async #readReservation(view) {
|
|
910
|
+
const raw = await this.store.readAux(this.#queueRel(view));
|
|
911
|
+
if (!raw)
|
|
912
|
+
return null;
|
|
913
|
+
try {
|
|
914
|
+
const r = JSON.parse(raw.toString("utf8"));
|
|
915
|
+
return r && typeof r.ticketId === "string" ? r : null;
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
async #writeReservation(view, resv) {
|
|
922
|
+
await this.store.writeAux(this.#queueRel(view), JSON.stringify(resv) + "\n");
|
|
923
|
+
}
|
|
924
|
+
/** Record an Integration verdict (append-only audit) and point the idempotency ref at it. */
|
|
925
|
+
async #recordIntegration(fields) {
|
|
926
|
+
const integ = { type: "integration", ...fields, createdAt: new Date().toISOString() };
|
|
927
|
+
const oid = await this.store.put(integ);
|
|
928
|
+
await this.store.setRef(`integration:${fields.view}:${fields.ticketId}`, oid);
|
|
929
|
+
return oid;
|
|
930
|
+
}
|
|
931
|
+
/** Author a checkpoint AT an integrated frontier (never `materialize(view)` — §1-(A):
|
|
932
|
+
* a view materialize would sweep in un-submitted third-party ops). */
|
|
933
|
+
async #authorIntegratedCheckpoint(view, integrated, evidence, evidenceBinding, summary) {
|
|
934
|
+
const v = await this.getView(view);
|
|
935
|
+
const cp = {
|
|
936
|
+
type: "checkpoint",
|
|
937
|
+
viewOid: v.oid,
|
|
938
|
+
headOps: integrated.headOps,
|
|
939
|
+
treeHash: integrated.treeHash,
|
|
940
|
+
policyOid: (await this.store.getRef("policy")),
|
|
941
|
+
materializerVersion: MATERIALIZER_VERSION,
|
|
942
|
+
evidence,
|
|
943
|
+
...(evidenceBinding && Object.keys(evidenceBinding).length ? { evidenceBinding } : {}),
|
|
944
|
+
status: integrated.conflicts.length === 0 ? "verified" : "draft",
|
|
945
|
+
summary,
|
|
946
|
+
createdAt: new Date().toISOString(),
|
|
947
|
+
};
|
|
948
|
+
return this.store.put(cp);
|
|
949
|
+
}
|
|
950
|
+
/** Verified (non-agent, canonically ordered) evidence bound to exactly `treeHash`. */
|
|
951
|
+
async #boundEvidenceFor(treeHash) {
|
|
952
|
+
const out = {};
|
|
953
|
+
const all = this.#verifiedEvidence(await this.store.collect("evidence")).sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) ||
|
|
954
|
+
((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
|
|
955
|
+
for (const ev of all) {
|
|
956
|
+
if (ev.producedBy.kind === "ai_agent")
|
|
957
|
+
continue;
|
|
958
|
+
if (ev.treeHash === treeHash)
|
|
959
|
+
out[ev.kind] = ev.result;
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
/** Keys touched by a set of op oids (contention surface of a delta). */
|
|
964
|
+
async #keysOfOps(oids) {
|
|
965
|
+
const keys = new Set();
|
|
966
|
+
for (const oid of oids) {
|
|
967
|
+
if (!(await this.store.has(oid)))
|
|
968
|
+
continue;
|
|
969
|
+
const op = await this.store.get(oid);
|
|
970
|
+
for (const k of keysOf(op))
|
|
971
|
+
keys.add(k);
|
|
972
|
+
}
|
|
973
|
+
return keys;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Submit a draft checkpoint to the integration queue (docs/17 §14.2). Runs under the
|
|
977
|
+
* same `finalize:<view>` lock as finalize — the existing mkdir lock IS the serializer
|
|
978
|
+
* (no separate queue structure in v1). The outcome is always one of the four verdicts;
|
|
979
|
+
* "pull and redo" does not exist on any path.
|
|
980
|
+
*
|
|
981
|
+
* Idempotency: an `advanced` ticket replays its recorded verdict forever. Non-terminal
|
|
982
|
+
* verdicts (conflict/needs_evidence/rejected/expired) re-evaluate on resubmission —
|
|
983
|
+
* the world legitimately changes under them (a decision lands, evidence arrives, a
|
|
984
|
+
* missing object syncs), and a frozen replay would wedge the ticket.
|
|
985
|
+
*/
|
|
986
|
+
async submitIntegration(args) {
|
|
987
|
+
const view = args.view;
|
|
988
|
+
const ticketId = args.ticketId ?? sha256hex(`${view}:${args.checkpoint}`);
|
|
989
|
+
const result = await this.store.withLock(`finalize:${view}`, async () => {
|
|
990
|
+
// 1. Idempotency — a terminal success replays as-is (safe resubmission).
|
|
991
|
+
const priorRef = await this.store.getRef(`integration:${view}:${ticketId}`);
|
|
992
|
+
if (priorRef && (await this.store.has(priorRef))) {
|
|
993
|
+
const prior = await this.store.get(priorRef);
|
|
994
|
+
if (prior.verdict === "advanced") {
|
|
995
|
+
return { verdict: "advanced", head: prior.resultCheckpoint, integration: priorRef };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// 2. Reservation — one in-flight needs_evidence ticket at a time (TTL-bounded).
|
|
999
|
+
let resv = await this.#readReservation(view);
|
|
1000
|
+
if (resv && Date.parse(resv.expiresAt) <= Date.now()) {
|
|
1001
|
+
// Expired: audit it and let the queue move on (docs/17 §14 contract test).
|
|
1002
|
+
await this.#recordIntegration({
|
|
1003
|
+
view, ticketId: resv.ticketId, submittedCheckpoint: resv.submittedCheckpoint,
|
|
1004
|
+
baseHead: await this.protectedHead(view), resultCheckpoint: resv.integratedCheckpoint,
|
|
1005
|
+
verdict: "expired", reason: `needs_evidence reservation expired at ${resv.expiresAt}`, by: resv.by,
|
|
1006
|
+
});
|
|
1007
|
+
await this.#writeReservation(view, null);
|
|
1008
|
+
resv = null;
|
|
1009
|
+
}
|
|
1010
|
+
if (resv && resv.ticketId !== ticketId) {
|
|
1011
|
+
return { verdict: "queued", behindTicket: resv.ticketId, retryAfterMs: 1000 + Math.floor(Math.random() * 500) };
|
|
1012
|
+
}
|
|
1013
|
+
// 3. Causal completeness — never judge (or advance to) a partially-synced tree.
|
|
1014
|
+
const cp = await this.store.get(args.checkpoint);
|
|
1015
|
+
const missing = await this.#missingCausalDeps(cp.headOps);
|
|
1016
|
+
if (missing.length) {
|
|
1017
|
+
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 });
|
|
1018
|
+
return { verdict: "rejected", reason: `incomplete causal history: ${missing.length} object(s) missing — push them first (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""})` };
|
|
1019
|
+
}
|
|
1020
|
+
// Role gate (same as finalize).
|
|
1021
|
+
const prot = await this.getProtection(view);
|
|
1022
|
+
if (prot && !(await this.hasRole(args.by, prot.finalizeRole ?? "maintainer"))) {
|
|
1023
|
+
const reason = `${args.by} lacks role ${prot.finalizeRole ?? "maintainer"} to integrate ${view}`;
|
|
1024
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead: await this.protectedHead(view), verdict: "rejected", reason, by: args.by });
|
|
1025
|
+
return { verdict: "rejected", reason };
|
|
1026
|
+
}
|
|
1027
|
+
// 4. Integration reduce — the frontier UNION via the materializeAt path (NEVER
|
|
1028
|
+
// materialize(view): §1-(A), un-submitted third-party ops must stay out).
|
|
1029
|
+
const baseHead = await this.protectedHead(view);
|
|
1030
|
+
const curHeads = baseHead ? (await this.store.get(baseHead)).headOps : [];
|
|
1031
|
+
const unionHeads = [...new Set([...curHeads, ...cp.headOps])];
|
|
1032
|
+
const integrated = await this.materializeAt(unionHeads);
|
|
1033
|
+
const subClosure = await this.#closureOf(cp.headOps);
|
|
1034
|
+
const fastForward = curHeads.every((h) => subClosure.has(h));
|
|
1035
|
+
// 5. Conflicts — the ONLY outcome that needs a human/agent decision, and it
|
|
1036
|
+
// arrives as a minimal repair packet with decision memory, not "pull and redo".
|
|
1037
|
+
if (integrated.conflicts.length > 0) {
|
|
1038
|
+
const packet = { conflicts: [] };
|
|
1039
|
+
for (const c of integrated.conflicts) {
|
|
1040
|
+
const fc = integrated.fileConflicts.find((f) => `file:${f.file}` === c.key);
|
|
1041
|
+
packet.conflicts.push({
|
|
1042
|
+
key: c.key,
|
|
1043
|
+
reason: c.reason,
|
|
1044
|
+
options: c.options.map((o) => ({ op: o.opOid, actor: o.actor, purpose: o.purpose })),
|
|
1045
|
+
...(fc ? { regions: fc.regions } : {}),
|
|
1046
|
+
priorDecisions: await this.recallDecisions(c.key),
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
const integration = await this.#recordIntegration({
|
|
1050
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
1051
|
+
verdict: "conflict", conflictKeys: packet.conflicts.map((c) => c.key), by: args.by,
|
|
1052
|
+
});
|
|
1053
|
+
return { verdict: "conflict", packet, integration };
|
|
1054
|
+
}
|
|
1055
|
+
// 6. Gates: approvals carry from the SUBMITTED checkpoint to the integrated one
|
|
1056
|
+
// (GitHub counts PR approvals independently of the merge commit — same isomorphism).
|
|
1057
|
+
let carriedApprovals;
|
|
1058
|
+
if (prot && (prot.requiredApprovals > 0 || prot.requireOwnerApproval)) {
|
|
1059
|
+
const verdicts = await this.#approvalVerdicts(args.checkpoint);
|
|
1060
|
+
if ([...verdicts.values()].includes("request_changes")) {
|
|
1061
|
+
const reason = "changes requested by a reviewer";
|
|
1062
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1063
|
+
return { verdict: "rejected", reason };
|
|
1064
|
+
}
|
|
1065
|
+
const approvers = [...verdicts].filter(([, v]) => v === "approve").map(([id]) => id);
|
|
1066
|
+
if (approvers.length < prot.requiredApprovals) {
|
|
1067
|
+
const reason = `needs ${prot.requiredApprovals} approval(s), have ${approvers.length}`;
|
|
1068
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1069
|
+
return { verdict: "rejected", reason };
|
|
1070
|
+
}
|
|
1071
|
+
if (prot.requireOwnerApproval) {
|
|
1072
|
+
let owner = false;
|
|
1073
|
+
for (const id of approvers)
|
|
1074
|
+
if (await this.hasRole(id, "maintainer")) {
|
|
1075
|
+
owner = true;
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
if (!owner) {
|
|
1079
|
+
const reason = "requires an owner (maintainer+) approval";
|
|
1080
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1081
|
+
return { verdict: "rejected", reason };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (prot.integration?.carryApprovals !== false) {
|
|
1085
|
+
carriedApprovals = (await this.store.collect("approval"))
|
|
1086
|
+
.filter((a) => a.checkpointOid === args.checkpoint)
|
|
1087
|
+
.map((a) => a.oid);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const waived = await this.#activeWaivers(view);
|
|
1091
|
+
const required = (prot?.requiredChecks ?? []).filter((k) => !waived.has(k));
|
|
1092
|
+
const advance = async (headCp, evidenceBinding, resultIsSubmitted = false) => {
|
|
1093
|
+
await this.store.setRef(`head:${view}`, headCp);
|
|
1094
|
+
const integration = await this.#recordIntegration({
|
|
1095
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
1096
|
+
resultCheckpoint: resultIsSubmitted ? undefined : headCp,
|
|
1097
|
+
verdict: "advanced", evidenceBinding, ...(carriedApprovals ? { carriedApprovals } : {}), by: args.by,
|
|
1098
|
+
});
|
|
1099
|
+
if (resv?.ticketId === ticketId)
|
|
1100
|
+
await this.#writeReservation(view, null);
|
|
1101
|
+
this.logger.info("integrate.advanced", { view, ticketId, head: headCp, evidenceBinding });
|
|
1102
|
+
return { verdict: "advanced", head: headCp, integration };
|
|
1103
|
+
};
|
|
1104
|
+
// 7a. Fast-forward — the current head is inside the submission's causal closure:
|
|
1105
|
+
// classic finalize semantics, fresh binding, no re-authored checkpoint.
|
|
1106
|
+
if (fastForward) {
|
|
1107
|
+
for (const k of required) {
|
|
1108
|
+
if (cp.evidence[k] !== "pass") {
|
|
1109
|
+
const reason = `required check ${k} not pass`;
|
|
1110
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1111
|
+
return { verdict: "rejected", reason };
|
|
1112
|
+
}
|
|
1113
|
+
if (prot?.requireBoundEvidence && cp.evidenceBinding?.[k] !== "bound") {
|
|
1114
|
+
const reason = `required check ${k} passed but its evidence is not bound to this tree`;
|
|
1115
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1116
|
+
return { verdict: "rejected", reason };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return advance(args.checkpoint, "fresh", true);
|
|
1120
|
+
}
|
|
1121
|
+
// 7b. Head moved: the integrated tree differs from the submitted one, so the
|
|
1122
|
+
// submitted evidence does NOT prove it (docs/17 §14.5). Decide by evidence mode.
|
|
1123
|
+
const T = integrated.treeHash;
|
|
1124
|
+
const needsEvidence = async () => {
|
|
1125
|
+
const draft = resv?.ticketId === ticketId && resv.treeHash === T
|
|
1126
|
+
? resv.integratedCheckpoint
|
|
1127
|
+
: await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)} (awaiting evidence)`);
|
|
1128
|
+
const ttl = prot?.integration?.reserveTtlMs ?? 10 * 60_000;
|
|
1129
|
+
await this.#writeReservation(view, {
|
|
1130
|
+
ticketId, submittedCheckpoint: args.checkpoint, integratedCheckpoint: draft,
|
|
1131
|
+
treeHash: T, requiredChecks: required, by: args.by,
|
|
1132
|
+
expiresAt: new Date(Date.now() + ttl).toISOString(),
|
|
1133
|
+
});
|
|
1134
|
+
// What the submitter is missing locally to reproduce T: the head-side delta ops
|
|
1135
|
+
// plus the blobs they reference (determinism does the rest — docs/17 §14.5 fresh).
|
|
1136
|
+
const headClosure = await this.#closureOf(curHeads);
|
|
1137
|
+
const missingLocally = [];
|
|
1138
|
+
for (const oid of headClosure) {
|
|
1139
|
+
if (subClosure.has(oid))
|
|
1140
|
+
continue;
|
|
1141
|
+
missingLocally.push(oid);
|
|
1142
|
+
if (await this.store.has(oid)) {
|
|
1143
|
+
const op = await this.store.get(oid);
|
|
1144
|
+
for (const b of [op.body.blobOid, op.body.baseBlobOid])
|
|
1145
|
+
if (b)
|
|
1146
|
+
missingLocally.push(b);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const integration = await this.#recordIntegration({
|
|
1150
|
+
view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
|
|
1151
|
+
resultCheckpoint: draft, verdict: "needs_evidence", by: args.by,
|
|
1152
|
+
});
|
|
1153
|
+
return { verdict: "needs_evidence", integratedCheckpoint: draft, treeHash: T, requiredChecks: required, missingLocally, ticketId, integration };
|
|
1154
|
+
};
|
|
1155
|
+
// Resubmission holding the reservation: accept iff fresh evidence bound to the
|
|
1156
|
+
// reserved tree now covers the required checks — exactly one validation run.
|
|
1157
|
+
if (resv && resv.ticketId === ticketId && resv.treeHash === T) {
|
|
1158
|
+
const bound = await this.#boundEvidenceFor(T);
|
|
1159
|
+
if (required.every((k) => bound[k] === "pass")) {
|
|
1160
|
+
const binding = {};
|
|
1161
|
+
for (const k of Object.keys(bound))
|
|
1162
|
+
binding[k] = "bound";
|
|
1163
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, bound, binding, `integration ${ticketId.slice(0, 12)}`);
|
|
1164
|
+
return advance(finalCp, "fresh");
|
|
1165
|
+
}
|
|
1166
|
+
return needsEvidence(); // reservation refreshed; still exactly one validation owed
|
|
1167
|
+
}
|
|
1168
|
+
if (required.length === 0) {
|
|
1169
|
+
// Nothing to prove — integrate directly (evidence-less views).
|
|
1170
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)}`);
|
|
1171
|
+
return advance(finalCp, "fresh");
|
|
1172
|
+
}
|
|
1173
|
+
const mode = prot?.integration?.evidenceMode ?? "carry-disjoint";
|
|
1174
|
+
let carry = mode === "carry-always";
|
|
1175
|
+
if (mode === "carry-disjoint") {
|
|
1176
|
+
const headClosure = await this.#closureOf(curHeads);
|
|
1177
|
+
const oursOnly = [...subClosure].filter((o) => !headClosure.has(o));
|
|
1178
|
+
const theirsOnly = [...headClosure].filter((o) => !subClosure.has(o));
|
|
1179
|
+
const ourKeys = await this.#keysOfOps(oursOnly);
|
|
1180
|
+
const theirKeys = await this.#keysOfOps(theirsOnly);
|
|
1181
|
+
// Disjoint deltas + zero new conflicts (checked above) ⇒ the same risk a git
|
|
1182
|
+
// user already accepts when merging two independently-green branches — but
|
|
1183
|
+
// machine-checked, recorded, and opt-out-able (docs/17 §14.5).
|
|
1184
|
+
carry = [...ourKeys].every((k) => !theirKeys.has(k));
|
|
1185
|
+
}
|
|
1186
|
+
// Carried evidence is not tree-bound; a protection that demands bound evidence
|
|
1187
|
+
// therefore forces the fresh path whenever the head has moved.
|
|
1188
|
+
if (prot?.requireBoundEvidence)
|
|
1189
|
+
carry = false;
|
|
1190
|
+
if (carry) {
|
|
1191
|
+
for (const k of required) {
|
|
1192
|
+
if (cp.evidence[k] !== "pass") {
|
|
1193
|
+
const reason = `required check ${k} not pass`;
|
|
1194
|
+
await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
|
|
1195
|
+
return { verdict: "rejected", reason };
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// The carry is never silent: recorded on BOTH the checkpoint and the Integration.
|
|
1199
|
+
const binding = {};
|
|
1200
|
+
for (const k of Object.keys(cp.evidence))
|
|
1201
|
+
binding[k] = "carried";
|
|
1202
|
+
const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, cp.evidence, binding, `integration ${ticketId.slice(0, 12)} (carried evidence)`);
|
|
1203
|
+
return advance(finalCp, "carried");
|
|
1204
|
+
}
|
|
1205
|
+
return needsEvidence();
|
|
1206
|
+
});
|
|
1207
|
+
if (result.verdict !== "advanced") {
|
|
1208
|
+
this.logger.info("integrate.verdict", { view, ticketId, verdict: result.verdict });
|
|
1209
|
+
}
|
|
1210
|
+
return result;
|
|
1211
|
+
}
|
|
778
1212
|
// ── security (Phase 12) ────────────────────────────────────────────────────
|
|
779
1213
|
/**
|
|
780
1214
|
* Redact (tombstone) a blob's bytes — for a leaked secret. Admin-only. The oid is
|
|
@@ -1002,6 +1436,47 @@ export class Repo {
|
|
|
1002
1436
|
throw new Error(`unknown remote: ${nameOrUrl} (run \`avcs remote add ${nameOrUrl} <url>\`)`);
|
|
1003
1437
|
return r.url;
|
|
1004
1438
|
}
|
|
1439
|
+
/** Public remote-name → hub-URL resolution (a literal URL passes through). */
|
|
1440
|
+
async remoteUrl(nameOrUrl) {
|
|
1441
|
+
return this.#resolveRemote(nameOrUrl);
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Submit a draft checkpoint to a REMOTE hub's integration queue (Phase 14, docs/17
|
|
1445
|
+
* §14.4), with capability detection: a hub advertising `integrate` on GET /version
|
|
1446
|
+
* gets the queue path (one judgment, no redo); an older hub falls back to the legacy
|
|
1447
|
+
* finalize + pull retry funnel (bounded) — the exact loop the queue exists to kill,
|
|
1448
|
+
* kept only for backward compatibility.
|
|
1449
|
+
*/
|
|
1450
|
+
async integrateHub(remoteOrUrl, args) {
|
|
1451
|
+
const url = await this.#resolveRemote(remoteOrUrl);
|
|
1452
|
+
const signWith = args.signWith ?? (await this.#resolveHubSigner(args.by));
|
|
1453
|
+
let hasIntegrate = false;
|
|
1454
|
+
try {
|
|
1455
|
+
const v = (await (await fetch(`${url}/version`)).json());
|
|
1456
|
+
hasIntegrate = v.integrate === true;
|
|
1457
|
+
}
|
|
1458
|
+
catch { /* unreachable /version → treat as legacy */ }
|
|
1459
|
+
if (hasIntegrate) {
|
|
1460
|
+
const { integrateWithHub } = await import("../hub/hubClient.js");
|
|
1461
|
+
const r = await integrateWithHub(this.dir, url, { ...args, signWith });
|
|
1462
|
+
// needs_evidence pulled delta objects through a separate store — refresh caches.
|
|
1463
|
+
this.#blobCache.clear();
|
|
1464
|
+
return r;
|
|
1465
|
+
}
|
|
1466
|
+
// Legacy fallback (old hub): finalize CAS + pull, bounded retries. This CAN lose
|
|
1467
|
+
// races (that is exactly the funnel Phase 14 removes) — surfaced honestly.
|
|
1468
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1469
|
+
await this.pullHub(url);
|
|
1470
|
+
const parentHead = await this.protectedHead(args.view);
|
|
1471
|
+
await this.pushHub(url, { as: args.by });
|
|
1472
|
+
const r = await this.finalizeHub(url, { view: args.view, newCheckpoint: args.checkpoint, parentHead, by: args.by, signWith });
|
|
1473
|
+
if (r.finalized)
|
|
1474
|
+
return { verdict: "advanced", head: r.head ?? args.checkpoint, legacy: true };
|
|
1475
|
+
if (!/head moved/.test(r.reason ?? ""))
|
|
1476
|
+
return { verdict: "rejected", reason: r.reason ?? `finalize failed (${r.status})`, legacy: true };
|
|
1477
|
+
}
|
|
1478
|
+
return { verdict: "rejected", reason: "legacy hub: lost the finalize CAS race 3 times — retry, or upgrade the hub for queue semantics", legacy: true };
|
|
1479
|
+
}
|
|
1005
1480
|
/**
|
|
1006
1481
|
* Bidirectional convergence with a named remote (default "origin"): pull what the hub
|
|
1007
1482
|
* has that we lack, then push what we have that it lacks. Pure object gossip — union
|
|
@@ -1011,9 +1486,118 @@ export class Repo {
|
|
|
1011
1486
|
const url = await this.#resolveRemote(remote);
|
|
1012
1487
|
const { pulled } = await this.pullHub(url);
|
|
1013
1488
|
const { pushed, rejected } = await this.pushHub(url, opts);
|
|
1489
|
+
await this.#recordSyncAt(remote); // Phase 15.2: the freshness window keys off this stamp
|
|
1014
1490
|
this.logger.info("sync.completed", { remote, url, pulled, pushed, rejected });
|
|
1015
1491
|
return { pulled, pushed, rejected };
|
|
1016
1492
|
}
|
|
1493
|
+
// ── live convergence: freshness window (Phase 15.2, docs/17 §15.2) ─────────
|
|
1494
|
+
// `.avcs/last-sync.json` — remote name → ISO timestamp of the last successful sync.
|
|
1495
|
+
// An aux file like remotes.json: per-replica state, never an object, never gossiped.
|
|
1496
|
+
/** Freshness window applied to an `autoSync` remote that doesn't set `freshnessMs`. */
|
|
1497
|
+
static DEFAULT_FRESHNESS_MS = 30_000;
|
|
1498
|
+
async #readLastSync() {
|
|
1499
|
+
const raw = await this.store.readAux("last-sync.json");
|
|
1500
|
+
if (!raw)
|
|
1501
|
+
return {};
|
|
1502
|
+
try {
|
|
1503
|
+
return JSON.parse(raw.toString("utf8"));
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
return {};
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
async #recordSyncAt(remote) {
|
|
1510
|
+
const last = await this.#readLastSync();
|
|
1511
|
+
last[remote] = new Date().toISOString();
|
|
1512
|
+
await this.store.writeAux("last-sync.json", JSON.stringify(last, null, 2) + "\n");
|
|
1513
|
+
}
|
|
1514
|
+
/** Milliseconds since the last successful sync with `remote` (Infinity when never). */
|
|
1515
|
+
async syncAgeMs(remote = "origin") {
|
|
1516
|
+
const at = Date.parse((await this.#readLastSync())[remote] ?? "");
|
|
1517
|
+
return Number.isFinite(at) ? Date.now() - at : Infinity;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* BLOCKING freshness sync (Phase 15.2): sync each named remote (default: every
|
|
1521
|
+
* `autoSync` remote) whose last successful sync is older than its freshness window.
|
|
1522
|
+
* For callers that must not read stale state (e.g. just before a submit). The read
|
|
1523
|
+
* path itself never calls this — materialize only ever fires a BACKGROUND revalidate.
|
|
1524
|
+
*/
|
|
1525
|
+
async syncIfStale(remote, opts) {
|
|
1526
|
+
const remotes = await this.#readRemotes();
|
|
1527
|
+
const names = remote !== undefined ? [remote] : Object.keys(remotes).filter((n) => remotes[n].autoSync);
|
|
1528
|
+
const synced = [];
|
|
1529
|
+
for (const name of names) {
|
|
1530
|
+
const cfg = remotes[name];
|
|
1531
|
+
if (!cfg && !/^https?:\/\//.test(name))
|
|
1532
|
+
throw new Error(`unknown remote: ${name}`);
|
|
1533
|
+
const freshnessMs = cfg?.freshnessMs ?? _a.DEFAULT_FRESHNESS_MS;
|
|
1534
|
+
if ((await this.syncAgeMs(name)) < freshnessMs)
|
|
1535
|
+
continue;
|
|
1536
|
+
await this.sync(name, opts);
|
|
1537
|
+
synced.push(name);
|
|
1538
|
+
}
|
|
1539
|
+
return { synced };
|
|
1540
|
+
}
|
|
1541
|
+
// Stale-while-revalidate on materialize: when an autoSync remote's window has lapsed,
|
|
1542
|
+
// fire a background sync and return immediately — the read path is the throughput-
|
|
1543
|
+
// critical path and is NEVER blocked on the network. In-flight + a 1s re-check
|
|
1544
|
+
// throttle keep the hot loop to at most one aux read per second.
|
|
1545
|
+
//
|
|
1546
|
+
// The in-flight run is KEPT as a promise rather than a boolean: "fire and forget" with
|
|
1547
|
+
// no handle is unobservable from outside, and a revalidate outlives the observable
|
|
1548
|
+
// effect that a caller would naturally wait on (pull lands the objects, but push and
|
|
1549
|
+
// the last-sync stamp write still follow). Whoever tears the repo down next — a test's
|
|
1550
|
+
// rm, a daemon shutdown — would otherwise race those writes. See settleBackgroundSync.
|
|
1551
|
+
#bgSync = null;
|
|
1552
|
+
#lastFreshnessCheck = 0;
|
|
1553
|
+
/**
|
|
1554
|
+
* Await any in-flight background revalidation, resolving immediately when idle. The
|
|
1555
|
+
* quiesce handle for the fire-and-forget path, mirroring the promise `runSyncWatch`
|
|
1556
|
+
* returns for the daemon: call it before tearing a repo down (shutdown, teardown) so
|
|
1557
|
+
* no `.avcs` write is still outstanding. Never rejects — a failed revalidate is logged
|
|
1558
|
+
* and swallowed, exactly as it is on the read path.
|
|
1559
|
+
*/
|
|
1560
|
+
async settleBackgroundSync() {
|
|
1561
|
+
// Loop rather than a single await: a materialize concurrent with the settle can start
|
|
1562
|
+
// the next run while we're waiting on this one.
|
|
1563
|
+
for (let inFlight = this.#bgSync; inFlight; inFlight = this.#bgSync)
|
|
1564
|
+
await inFlight;
|
|
1565
|
+
}
|
|
1566
|
+
#maybeBackgroundSync() {
|
|
1567
|
+
const now = Date.now();
|
|
1568
|
+
if (this.#bgSync || now - this.#lastFreshnessCheck < 1_000)
|
|
1569
|
+
return;
|
|
1570
|
+
this.#lastFreshnessCheck = now;
|
|
1571
|
+
// Publish the handle BEFORE arranging its clear, and clear by identity: a body that
|
|
1572
|
+
// ever settles without suspending would otherwise null the field first and be
|
|
1573
|
+
// resurrected by this assignment, wedging the guard above at "always in flight".
|
|
1574
|
+
const run = this.#revalidateStaleRemotes();
|
|
1575
|
+
this.#bgSync = run;
|
|
1576
|
+
void run.finally(() => { if (this.#bgSync === run)
|
|
1577
|
+
this.#bgSync = null; });
|
|
1578
|
+
}
|
|
1579
|
+
/** One revalidation pass: sync every autoSync remote past its freshness window.
|
|
1580
|
+
* Never rejects — see the catch. */
|
|
1581
|
+
async #revalidateStaleRemotes() {
|
|
1582
|
+
try {
|
|
1583
|
+
const remotes = await this.#readRemotes();
|
|
1584
|
+
for (const [name, cfg] of Object.entries(remotes)) {
|
|
1585
|
+
if (!cfg.autoSync)
|
|
1586
|
+
continue;
|
|
1587
|
+
const freshnessMs = cfg.freshnessMs ?? _a.DEFAULT_FRESHNESS_MS;
|
|
1588
|
+
const age = await this.syncAgeMs(name);
|
|
1589
|
+
if (age < freshnessMs)
|
|
1590
|
+
continue;
|
|
1591
|
+
this.logger.info("sync.freshness.revalidate", { remote: name, ageMs: age === Infinity ? null : Math.round(age) });
|
|
1592
|
+
await this.sync(name);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
catch (e) {
|
|
1596
|
+
// Background revalidation failing (hub down, network) must never surface into
|
|
1597
|
+
// the read path — log and try again after the next materialize + throttle.
|
|
1598
|
+
this.logger.warn("sync.freshness.fail", { error: String(e.message) });
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1017
1601
|
/** Resolve a view's query into the candidate operation set, then reduce. */
|
|
1018
1602
|
/**
|
|
1019
1603
|
* Workspaces that have LANDED onto their base line (docs/16). A landed workspace's ops
|
|
@@ -1052,6 +1636,9 @@ export class Repo {
|
|
|
1052
1636
|
}
|
|
1053
1637
|
async materialize(viewName = "main", opts) {
|
|
1054
1638
|
this.metrics.inc("materialize.calls");
|
|
1639
|
+
// Phase 15.2 stale-while-revalidate: an autoSync remote past its freshness window
|
|
1640
|
+
// triggers a BACKGROUND sync. Fire-and-forget — this read never waits on the network.
|
|
1641
|
+
this.#maybeBackgroundSync();
|
|
1055
1642
|
// Compaction (B3, default since 13.3): on a cold instance, seed the incremental base
|
|
1056
1643
|
// from the persisted snapshot so this materialize re-reduces only ops added since it,
|
|
1057
1644
|
// not all history. A corrupt/stale/version-mismatched snapshot is discarded (→ full
|