@staix/agent-mem 0.6.0 → 0.8.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/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { legacyEntries } from "./bridge.ts";
|
|
3
|
+
import { canonicalJSON, validGeneration } from "./generation.ts";
|
|
4
|
+
import { validatePruneRow, type PruneRow } from "./prune.ts";
|
|
5
|
+
import { shardKeyHash } from "./shard.ts";
|
|
6
|
+
import type { Entry } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export type ShardSyncRealm = { scope: "project"; key: string } | { scope: "user" };
|
|
9
|
+
export type ShardSyncRow = Entry | PruneRow;
|
|
10
|
+
export const SHARD_SYNC_SCHEMA = "agent-mem.shard-sync/v1";
|
|
11
|
+
export const SHARD_PACK_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
|
|
13
|
+
const hash = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
|
|
14
|
+
|
|
15
|
+
export class ShardSyncFormatError extends Error {
|
|
16
|
+
constructor(label: string, reason: string) { super(`${label}: ${reason}`); this.name = "ShardSyncFormatError"; }
|
|
17
|
+
}
|
|
18
|
+
function checkRealm(realm: ShardSyncRealm): void {
|
|
19
|
+
if (!realm || (realm.scope !== "user" && (realm.scope !== "project" || typeof realm.key !== "string" || !realm.key))) {
|
|
20
|
+
throw new ShardSyncFormatError("shard", "invalid realm");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function shardSyncLabel(realm: ShardSyncRealm): string {
|
|
24
|
+
checkRealm(realm);
|
|
25
|
+
return realm.scope === "user" ? "user shard" : `project shard ${shardKeyHash(realm.key)}`;
|
|
26
|
+
}
|
|
27
|
+
export function shardSyncRef(realm: ShardSyncRealm): string {
|
|
28
|
+
checkRealm(realm);
|
|
29
|
+
return realm.scope === "user" ? "refs/heads/amem/u" : `refs/heads/amem/p/${shardKeyHash(realm.key)}`;
|
|
30
|
+
}
|
|
31
|
+
export function shardSyncDescriptor(realm: ShardSyncRealm): string {
|
|
32
|
+
checkRealm(realm);
|
|
33
|
+
return canonicalJSON(realm.scope === "user" ? { schema: SHARD_SYNC_SCHEMA, scope: "user" } : { schema: SHARD_SYNC_SCHEMA, scope: "project", key: realm.key }) + "\n";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Only row shape/public placement here. The sync engine must prove target/basis realm ownership across the full source set. */
|
|
37
|
+
function publicRow(value: unknown, label: string): ShardSyncRow {
|
|
38
|
+
try {
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("row");
|
|
40
|
+
if ((value as { schema?: unknown }).schema === "agent-mem.prune/v1") return validatePruneRow(value, undefined, "synced");
|
|
41
|
+
const raw = Buffer.from(canonicalJSON(value) + "\n");
|
|
42
|
+
const [row] = legacyEntries([{ source: "legacy-main", path: "log/format.jsonl", bytes: raw, offset: raw.length, hash: hash(raw), local: false, tailBytes: 0 }], { strict: true });
|
|
43
|
+
if (!row || row.op === "settle" || row.op === "note" && row.tier !== "long") throw new Error("placement");
|
|
44
|
+
return row;
|
|
45
|
+
} catch { throw new ShardSyncFormatError(label, "invalid public row"); }
|
|
46
|
+
}
|
|
47
|
+
function collision(seen: Map<string, string>, row: ShardSyncRow, line: string, label: string): void {
|
|
48
|
+
const previous = seen.get(row.id);
|
|
49
|
+
if (previous !== undefined && previous !== line) throw new ShardSyncFormatError(label, "row id collision");
|
|
50
|
+
seen.set(row.id, line);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Content-addressed immutable packs retain original attribution, ids and even repeated identical input rows. */
|
|
54
|
+
export function encodeShardPacks(rows: readonly ShardSyncRow[]): Map<string, Buffer> {
|
|
55
|
+
const groups = new Map<string, { id: string; line: string }[]>(), seen = new Map<string, string>();
|
|
56
|
+
const label = "shard pack";
|
|
57
|
+
for (const input of rows) {
|
|
58
|
+
let line: string;
|
|
59
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || typeof input.id !== "string") throw new ShardSyncFormatError(label, "invalid public row");
|
|
60
|
+
try { line = canonicalJSON(input); } catch { throw new ShardSyncFormatError(label, "invalid public row"); }
|
|
61
|
+
// Check before validation too: a conflicting claimed id is never silently preferred over the other payload.
|
|
62
|
+
collision(seen, input, line, label);
|
|
63
|
+
const row = publicRow(input, label);
|
|
64
|
+
if (Buffer.byteLength(line) + 1 > SHARD_PACK_MAX_BYTES) throw new ShardSyncFormatError(label, "row exceeds pack bound");
|
|
65
|
+
const held = groups.get(row.machine) ?? [];
|
|
66
|
+
held.push({ id: row.id, line: line + "\n" }); groups.set(row.machine, held);
|
|
67
|
+
}
|
|
68
|
+
const packs = new Map<string, Buffer>();
|
|
69
|
+
for (const machine of [...groups.keys()].sort()) {
|
|
70
|
+
const group = groups.get(machine)!; group.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
71
|
+
let parts: string[] = [], bytes = 0;
|
|
72
|
+
const publish = () => {
|
|
73
|
+
if (!parts.length) return;
|
|
74
|
+
const body = Buffer.from(parts.join(""));
|
|
75
|
+
const path = `rows/${hash(machine)}/${hash(body)}.jsonl`;
|
|
76
|
+
if (packs.has(path)) throw new ShardSyncFormatError(label, "duplicate immutable pack");
|
|
77
|
+
packs.set(path, body); parts = []; bytes = 0;
|
|
78
|
+
};
|
|
79
|
+
for (const row of group) {
|
|
80
|
+
const size = Buffer.byteLength(row.line);
|
|
81
|
+
if (bytes + size > SHARD_PACK_MAX_BYTES) publish();
|
|
82
|
+
parts.push(row.line); bytes += size;
|
|
83
|
+
}
|
|
84
|
+
publish();
|
|
85
|
+
}
|
|
86
|
+
return packs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function decodeShardPack(path: string, bytes: Uint8Array, realm: ShardSyncRealm, generation: string): ShardSyncRow[] {
|
|
90
|
+
const label = shardSyncLabel(realm), match = PACK.exec(path);
|
|
91
|
+
if (!match || !bytes.byteLength || bytes.byteLength > SHARD_PACK_MAX_BYTES || hash(bytes) !== match[2] || !validGeneration(generation)) {
|
|
92
|
+
throw new ShardSyncFormatError(label, "invalid pack identity or bound");
|
|
93
|
+
}
|
|
94
|
+
let text: string;
|
|
95
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
96
|
+
catch { throw new ShardSyncFormatError(label, "invalid pack encoding"); }
|
|
97
|
+
if (!text.endsWith("\n")) throw new ShardSyncFormatError(label, "incomplete pack tail");
|
|
98
|
+
const rows: ShardSyncRow[] = [], seen = new Map<string, string>();
|
|
99
|
+
for (const line of text.slice(0, -1).split("\n")) {
|
|
100
|
+
let value: unknown;
|
|
101
|
+
try { if (!line.trim()) throw new Error("empty"); value = JSON.parse(line); }
|
|
102
|
+
catch { throw new ShardSyncFormatError(label, "malformed pack row"); }
|
|
103
|
+
const row = publicRow(value, label);
|
|
104
|
+
if (hash(row.machine) !== match[1]) throw new ShardSyncFormatError(label, "pack author mismatch");
|
|
105
|
+
if (row.op === "prune") {
|
|
106
|
+
// ponytail: #404 needs a generation-neutral public-witness transport; never retag or skip a foreign prune.
|
|
107
|
+
if (row.generation !== generation) throw new ShardSyncFormatError(label, "foreign-generation prune");
|
|
108
|
+
} else if (row.op === "note" || row.op === "goal") {
|
|
109
|
+
if (realm.scope === "project" ? row.scope !== "project" || row.project !== realm.key : row.scope !== "shared") {
|
|
110
|
+
throw new ShardSyncFormatError(label, "row scope mismatch");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
collision(seen, row, canonicalJSON(row), label); rows.push(row);
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJSON, fsyncDirectory, hashJSON, readProfileJSON } from "./generation.ts";
|
|
5
|
+
import { generationSyncLock } from "./generation-sync.ts";
|
|
6
|
+
import { acquireMutation } from "./mutation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { reducePruneRows, validatePrunePlan, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow } from "./prune.ts";
|
|
9
|
+
import { assertCuratedShardPaths, isLocalKey, resolveShardProfile } from "./shard.ts";
|
|
10
|
+
import { decodeShardPack, encodeShardPacks, SHARD_PACK_MAX_BYTES, ShardSyncFormatError, shardSyncDescriptor, shardSyncLabel, shardSyncRef, type ShardSyncRealm, type ShardSyncRow } from "./shard-sync-format.ts";
|
|
11
|
+
import { generationSyncGit, remoteVisibility, type SyncOptions, type SyncResult } from "./sync.ts";
|
|
12
|
+
import type { Entry, MemoryStore } from "./store.ts";
|
|
13
|
+
|
|
14
|
+
const OID = /^[a-f0-9]{40}$/, PACK = /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/;
|
|
15
|
+
const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
16
|
+
const zero = "0".repeat(40);
|
|
17
|
+
type PendingPrune = { plan: PrunePlan; row: PruneRow; line: string; offset: number; prefixHash: string };
|
|
18
|
+
type Target = { store: MemoryStore; realm: ShardSyncRealm; rows: Map<string, ShardSyncRow>; fetched: Set<string>; sources: PruneSourceRow[]; pending: PendingPrune[]; generation: string; local?: string; remote?: string; commit?: string };
|
|
19
|
+
class SyncRefusal extends Error {}
|
|
20
|
+
function refuse(reason: string): never { throw new SyncRefusal(reason); }
|
|
21
|
+
function directory(path: string): void {
|
|
22
|
+
const stat = lstatSync(path);
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) refuse("shard sync requires independent private directories");
|
|
24
|
+
}
|
|
25
|
+
function regular(path: string): void {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) refuse("shard sync refuses non-regular transport files");
|
|
28
|
+
}
|
|
29
|
+
function mergeRows(into: Map<string, ShardSyncRow>, rows: readonly ShardSyncRow[], realm: ShardSyncRealm): void {
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const old = into.get(row.id);
|
|
32
|
+
if (old && canonicalJSON(old) !== canonicalJSON(row)) refuse(`${shardSyncLabel(realm)}: conflicting original row`);
|
|
33
|
+
into.set(row.id, row);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function importPrunePlan(store: MemoryStore, realm: ShardSyncRealm, row: PruneRow): PrunePlan {
|
|
37
|
+
const body: Omit<PrunePlan, "seal"> = { schema: "agent-mem.prune-plan/v1", generation: store.profile.generation!,
|
|
38
|
+
selectorRevision: store.prunePort().selectorRevision(), project: realm.scope === "project" ? realm.key : "user",
|
|
39
|
+
policyRevision: hashJSON({ schema: "agent-mem.shard-sync/v1", realm }), rows: [{ placement: "synced", payload: row }],
|
|
40
|
+
counts: { eligible: row.targets.length, selected: row.targets.length, local: 0, synced: row.targets.length }, hasMore: false };
|
|
41
|
+
return validatePrunePlan({ ...body, seal: hashJSON(body) });
|
|
42
|
+
}
|
|
43
|
+
/** Recognize only the exact persisted transport shape; this reads metadata and never resumes an append. */
|
|
44
|
+
function pendingPrunes(store: MemoryStore, realm: ShardSyncRealm): PendingPrune[] {
|
|
45
|
+
const dir = join(store.dir, "prune-plans"), pending: PendingPrune[] = [];
|
|
46
|
+
if (!existsSync(dir)) return pending;
|
|
47
|
+
directory(dir);
|
|
48
|
+
try {
|
|
49
|
+
for (const name of readdirSync(dir).sort()) {
|
|
50
|
+
if (!name.startsWith("pending-log-")) continue;
|
|
51
|
+
if (!/^pending-log-[a-f0-9]{16}\.json$/.test(name)) throw new Error("claim name");
|
|
52
|
+
const claimFile = join(dir, name); regular(claimFile);
|
|
53
|
+
const claim = readProfileJSON(claimFile, 4096) as { schema?: unknown; seal?: unknown; rowId?: unknown; lineHash?: unknown; offset?: unknown; prefixHash?: unknown } | undefined;
|
|
54
|
+
if (!claim || typeof claim !== "object" || Array.isArray(claim) || Object.keys(claim).length !== 6
|
|
55
|
+
|| claim.schema !== "agent-mem.prune-append/v1" || typeof claim.seal !== "string" || !/^[a-f0-9]{64}$/.test(claim.seal)
|
|
56
|
+
|| typeof claim.offset !== "number" || !Number.isSafeInteger(claim.offset) || claim.offset < 0
|
|
57
|
+
|| typeof claim.prefixHash !== "string" || !/^[a-f0-9]{64}$/.test(claim.prefixHash)) throw new Error("claim shape");
|
|
58
|
+
const planFile = join(dir, `${claim.seal}.json`); regular(planFile);
|
|
59
|
+
const plan = validatePrunePlan(readProfileJSON(planFile));
|
|
60
|
+
if (plan.rows.length !== 1) throw new Error("plan shape");
|
|
61
|
+
const row = plan.rows[0]!.payload, line = canonicalJSON(row) + "\n";
|
|
62
|
+
if (plan.seal !== claim.seal || canonicalJSON(plan) !== canonicalJSON(importPrunePlan(store, realm, row))
|
|
63
|
+
|| name !== `pending-log-${row.machine}.json` || claim.rowId !== row.id || claim.lineHash !== digest(line)) throw new Error("plan binding");
|
|
64
|
+
regular(join(store.dir, "log", `${row.machine}.jsonl`));
|
|
65
|
+
pending.push({ plan, row, line, offset: claim.offset, prefixHash: claim.prefixHash });
|
|
66
|
+
}
|
|
67
|
+
return pending;
|
|
68
|
+
} catch { return refuse(`${shardSyncLabel(realm)}: pending prune requires its original plan to resume`); }
|
|
69
|
+
}
|
|
70
|
+
/** Stream complete authored lines without a permanent file-size/history ceiling. */
|
|
71
|
+
function* lines(file: string, claim?: PendingPrune): Generator<string> {
|
|
72
|
+
regular(file);
|
|
73
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
74
|
+
try {
|
|
75
|
+
const before = fstatSync(fd), chunk = Buffer.alloc(64 * 1024), prefixHash = createHash("sha256"), exact = claim ? Buffer.from(claim.line) : undefined;
|
|
76
|
+
if (claim && (before.size < claim.offset || before.size > claim.offset + exact!.length)) refuse("shard sync pending append length mismatch");
|
|
77
|
+
let pending = Buffer.alloc(0), offset = 0;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const n = readSync(fd, chunk, 0, chunk.length, offset); if (!n) break;
|
|
80
|
+
const prefix = claim ? Math.min(n, Math.max(0, claim.offset - offset)) : n;
|
|
81
|
+
if (claim) {
|
|
82
|
+
prefixHash.update(chunk.subarray(0, prefix));
|
|
83
|
+
const start = offset + prefix - claim.offset;
|
|
84
|
+
if (prefix < n && !chunk.subarray(prefix, n).equals(exact!.subarray(start, start + n - prefix))) refuse("shard sync pending append bytes mismatch");
|
|
85
|
+
}
|
|
86
|
+
offset += n;
|
|
87
|
+
// Only the complete prefix is authored input. The pending canonical row must be fetched and proved before replay.
|
|
88
|
+
pending = Buffer.concat([pending, chunk.subarray(0, prefix)]);
|
|
89
|
+
let boundary: number;
|
|
90
|
+
while ((boundary = pending.indexOf(10)) >= 0) {
|
|
91
|
+
if (boundary + 1 > SHARD_PACK_MAX_BYTES) refuse("shard sync authored row exceeds wire pack bound");
|
|
92
|
+
const line = new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, boundary));
|
|
93
|
+
pending = pending.subarray(boundary + 1); if (line.trim()) yield line;
|
|
94
|
+
}
|
|
95
|
+
if (pending.length > SHARD_PACK_MAX_BYTES) refuse("shard sync authored row exceeds wire pack bound");
|
|
96
|
+
}
|
|
97
|
+
const after = fstatSync(fd), named = lstatSync(file);
|
|
98
|
+
if (claim && prefixHash.digest("hex") !== claim.prefixHash) refuse("shard sync pending append prefix mismatch");
|
|
99
|
+
if (pending.length || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs
|
|
100
|
+
|| named.dev !== before.dev || named.ino !== before.ino || offset !== before.size) refuse("shard sync authored segment changed or has an incomplete tail");
|
|
101
|
+
} finally { closeSync(fd); }
|
|
102
|
+
}
|
|
103
|
+
function sourceRows(store: MemoryStore, realm: ShardSyncRealm): { rows: ShardSyncRow[]; sources: PruneSourceRow[]; pending: PendingPrune[] } {
|
|
104
|
+
const pending = pendingPrunes(store, realm), claims = new Map(pending.map(claim => [`${claim.row.machine}.jsonl`, claim]));
|
|
105
|
+
const snapshot = store.prunePort().snapshot();
|
|
106
|
+
const pruned = new Set(snapshot.pruned ?? []);
|
|
107
|
+
// Placement decides transport; the verified snapshot retains separate prune provenance.
|
|
108
|
+
// A local tombstone must prevent the first publication of an unwitnessed original.
|
|
109
|
+
const rows: ShardSyncRow[] = snapshot.rows.filter(source => source.placement === "synced" && (source.publicWitness || !pruned.has(source.row.id))).map(source => source.row);
|
|
110
|
+
const folder = join(store.dir, "log"); directory(folder);
|
|
111
|
+
for (const name of readdirSync(folder).sort()) {
|
|
112
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
113
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) refuse(`${shardSyncLabel(realm)}: invalid authored segment name`);
|
|
114
|
+
for (const line of lines(join(folder, name), claims.get(name))) {
|
|
115
|
+
const value = JSON.parse(line) as { schema?: unknown; op?: unknown };
|
|
116
|
+
if (value.schema === "agent-mem.prune/v1") {
|
|
117
|
+
const row = validatePruneRow(value, store.profile.generation, "synced");
|
|
118
|
+
if (name !== `${row.machine}.jsonl`) refuse(`${shardSyncLabel(realm)}: prune author segment mismatch`);
|
|
119
|
+
rows.push(row);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Round-trip public originals through the wire validator before touching remote refs or imported rows.
|
|
124
|
+
for (const [path, bytes] of encodeShardPacks(rows)) decodeShardPack(path, bytes, realm, store.profile.generation!);
|
|
125
|
+
return { rows, sources: [...snapshot.rows], pending };
|
|
126
|
+
}
|
|
127
|
+
function provePrunes(targets: readonly Target[]): void {
|
|
128
|
+
for (const target of targets) {
|
|
129
|
+
for (const pending of target.pending) {
|
|
130
|
+
const fetched = target.rows.get(pending.row.id);
|
|
131
|
+
if (!target.fetched.has(pending.row.id) || !fetched || canonicalJSON(fetched) + "\n" !== pending.line) refuse(`${shardSyncLabel(target.realm)}: pending prune has no exact fetched transport row`);
|
|
132
|
+
}
|
|
133
|
+
// Each physical shard's reducer must be able to prove its own tombstones after restart.
|
|
134
|
+
// A witness present only in another selected realm does not make this realm replayable.
|
|
135
|
+
const sources = new Map<string, PruneSourceRow>();
|
|
136
|
+
for (const source of target.sources) {
|
|
137
|
+
const old = sources.get(source.row.id);
|
|
138
|
+
if (old && canonicalJSON(old.row) !== canonicalJSON(source.row)) refuse(`${shardSyncLabel(target.realm)}: conflicting witness row`);
|
|
139
|
+
if (!old || source.placement === "synced" && source.publicWitness) sources.set(source.row.id, source);
|
|
140
|
+
}
|
|
141
|
+
// Only verified fetched occurrences add public proof. Locally selected exports do not.
|
|
142
|
+
for (const row of target.rows.values()) if (row.op !== "prune" && target.fetched.has(row.id)) {
|
|
143
|
+
const old = sources.get(row.id);
|
|
144
|
+
if (old && canonicalJSON(old.row) !== canonicalJSON(row)) refuse(`${shardSyncLabel(target.realm)}: conflicting witness row`);
|
|
145
|
+
sources.set(row.id, { row, placement: "synced", publicWitness: true });
|
|
146
|
+
}
|
|
147
|
+
const snapshot: PruneSnapshot = { rows: [...sources.values()], expiries: [] };
|
|
148
|
+
const prunes = [...target.rows.values()].filter((row): row is PruneRow => row.op === "prune");
|
|
149
|
+
const proof = reducePruneRows(prunes.map(row => ({ row, placement: "synced" as const })), snapshot, target.generation);
|
|
150
|
+
if (proof.invalid || proof.pending.length) refuse(`${shardSyncLabel(target.realm)}: prune lacks complete public witnesses`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function installPack(store: MemoryStore, bytes: Buffer): void {
|
|
154
|
+
const dir = join(store.dir, "log"), file = join(dir, `sync-${digest(bytes)}.jsonl`);
|
|
155
|
+
directory(dir);
|
|
156
|
+
if (existsSync(file)) { regular(file); if (!readFileSync(file).equals(bytes)) refuse("shard sync immutable import conflict"); return; }
|
|
157
|
+
const temporary = join(dir, `.sync-${randomBytes(16).toString("hex")}.tmp`);
|
|
158
|
+
let fd: number | undefined;
|
|
159
|
+
try {
|
|
160
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
161
|
+
let written = 0; while (written < bytes.length) { const n = writeSync(fd, bytes, written, bytes.length - written); if (n <= 0) refuse("shard sync import write unavailable"); written += n; }
|
|
162
|
+
fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
163
|
+
try { linkSync(temporary, file); }
|
|
164
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; regular(file); if (!readFileSync(file).equals(bytes)) refuse("shard sync immutable import conflict"); }
|
|
165
|
+
fsyncDirectory(dir);
|
|
166
|
+
} finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); }
|
|
167
|
+
}
|
|
168
|
+
function importRows(target: Target): void {
|
|
169
|
+
const port = target.store.prunePort(), before = port.snapshot();
|
|
170
|
+
const known = new Set(before.rows.filter(source => source.placement === "synced" && source.publicWitness).map(source => source.row.id));
|
|
171
|
+
const ordinary = [...target.rows.values()].filter((row): row is Entry => row.op !== "prune" && target.fetched.has(row.id) && !known.has(row.id));
|
|
172
|
+
for (const bytes of encodeShardPacks(ordinary).values()) installPack(target.store, bytes);
|
|
173
|
+
port.refresh();
|
|
174
|
+
const resumed = new Set<string>();
|
|
175
|
+
// The caller has proved all selected refs and atomically published them. Resume originals before other rows of their author.
|
|
176
|
+
for (const pending of target.pending) {
|
|
177
|
+
port.persistPrunePlan(pending.plan); port.appendPrune(pending.row, pending.line, pending.plan.seal); resumed.add(pending.row.id);
|
|
178
|
+
}
|
|
179
|
+
for (const row of target.rows.values()) if (row.op === "prune") {
|
|
180
|
+
if (resumed.has(row.id)) continue;
|
|
181
|
+
// A transport imports an already-authored person row without changing its author or generation.
|
|
182
|
+
// Native durable-plan append confirms fsync even for an already parsed id after a crash.
|
|
183
|
+
const plan = importPrunePlan(target.store, target.realm, row);
|
|
184
|
+
port.persistPrunePlan(plan); port.appendPrune(row, canonicalJSON(row) + "\n", plan.seal);
|
|
185
|
+
}
|
|
186
|
+
port.refresh();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Sync only the borrowed canonical project and user targets. The internal third argument is a test-only observer, never a CLI/SyncOptions port. */
|
|
190
|
+
export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void): Promise<SyncResult> {
|
|
191
|
+
let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
|
|
192
|
+
const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
|
|
193
|
+
const fail = (error: string): SyncResult => ({ ok: false, pushed, entries: 0, error });
|
|
194
|
+
let root = "", cache = "", targets: Target[] = [];
|
|
195
|
+
const check = () => {
|
|
196
|
+
if (opts.signal?.aborted) refuse("stopped");
|
|
197
|
+
for (const target of targets) {
|
|
198
|
+
const profile = target.store.profile;
|
|
199
|
+
const current = resolveShardProfile(profile.home, target.realm.scope === "user" ? { user: true } : { project: target.realm.key });
|
|
200
|
+
if (current.dir !== profile.dir || current.generation !== target.generation || current.selectorRevision !== profile.selectorRevision) refuse(`${shardSyncLabel(target.realm)}: selected profile changed`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
// Git's auto-maintenance may outlive fetch and remove maintenance.lock during the strict cache walk.
|
|
204
|
+
// ponytail: cache objects retain their full history; any future compaction must hold this transport's mutation/sync locks.
|
|
205
|
+
const gitArgs = (args: string[]) => ["--no-replace-objects", "--git-dir", cache, "-c", "maintenance.auto=false", "-c", "gc.auto=0", ...args];
|
|
206
|
+
const git = async (args: string[], signal = opts.signal) => generationSyncGit(root, gitArgs(args), signal);
|
|
207
|
+
const required = async (args: string[], reason: string): Promise<string> => {
|
|
208
|
+
check(); const result = await git(args); check();
|
|
209
|
+
if (result.code !== 0) refuse(reason);
|
|
210
|
+
return result.out;
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
if (opts.signal?.aborted) return fail("stopped");
|
|
214
|
+
if (!store.profile.sharded || store.profile.kind !== "generation") return fail("shard sync requires a selected curated shard");
|
|
215
|
+
const borrowed = store.openedSyncTargets();
|
|
216
|
+
targets = borrowed.map(opened => {
|
|
217
|
+
const profile = opened.profile;
|
|
218
|
+
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.shardUser && !profile.shardKey) refuse("shard sync target identity unavailable");
|
|
219
|
+
return { store: opened, realm: profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: profile.shardKey! }, rows: new Map<string, ShardSyncRow>(), fetched: new Set<string>(), sources: [], pending: [], generation: profile.generation };
|
|
220
|
+
});
|
|
221
|
+
if (!targets.length || targets.length > 2 || targets.some(target => target.store.profile.home !== store.profile.home)
|
|
222
|
+
|| new Set(targets.map(target => shardSyncRef(target.realm))).size !== targets.length
|
|
223
|
+
|| targets.filter(target => target.realm.scope === "project").some(target => target.realm.scope === "project" && target.realm.key !== store.profile.shardKey)
|
|
224
|
+
|| !targets.some(target => target.store === store)) refuse("shard sync selected targets unavailable");
|
|
225
|
+
for (const target of targets) if (target.realm.scope === "project" && isLocalKey(target.realm.key) && opts.remote === undefined) refuse(`${shardSyncLabel(target.realm)}: local project key requires explicit remote opt-in for this invocation`);
|
|
226
|
+
check();
|
|
227
|
+
root = join(store.profile.home, "shard-sync"); cache = join(root, "objects.git");
|
|
228
|
+
if (existsSync(root)) directory(root);
|
|
229
|
+
const independent = async (): Promise<boolean> => {
|
|
230
|
+
if (!existsSync(cache)) return false;
|
|
231
|
+
directory(cache);
|
|
232
|
+
// Reject indirection throughout the owned cache, not only its top-level directory.
|
|
233
|
+
const folders = [cache];
|
|
234
|
+
while (folders.length) {
|
|
235
|
+
const folder = folders.pop()!;
|
|
236
|
+
for (const name of readdirSync(folder)) {
|
|
237
|
+
const file = join(folder, name), stat = lstatSync(file);
|
|
238
|
+
if (stat.isSymbolicLink() || !stat.isFile() && !stat.isDirectory()) refuse("shard sync cache contains filesystem indirection");
|
|
239
|
+
if (stat.isDirectory()) folders.push(file);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (existsSync(join(cache, "commondir")) || existsSync(join(cache, "objects", "info", "alternates")) || existsSync(join(cache, "shallow")) || existsSync(join(cache, "info", "grafts"))) refuse("shard sync cache must be an independent complete bare repository");
|
|
243
|
+
if (await required(["rev-parse", "--is-bare-repository"], "shard sync cache identity unavailable") !== "true") refuse("shard sync cache must be bare");
|
|
244
|
+
const worktree = await git(["config", "--get", "core.worktree"]);
|
|
245
|
+
if (worktree.code !== 1) refuse("shard sync cache worktree routing refused");
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
const existing = existsSync(root) ? await independent() : false;
|
|
249
|
+
if (!opts.remote && !existing) return fail("no shard sync remote configured");
|
|
250
|
+
const url = opts.remote ?? await required(["remote", "get-url", "origin"], "no shard sync remote configured");
|
|
251
|
+
if (!url || url.length > 4096 || url.startsWith("-") || /[\r\n\0]/.test(url)) return fail("shard sync remote is invalid");
|
|
252
|
+
if (await (opts.visibility ?? remoteVisibility)(url) !== "private") return fail("shard sync requires positively verified private remote visibility");
|
|
253
|
+
check();
|
|
254
|
+
if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 }); directory(root);
|
|
255
|
+
releaseTransport = acquireMutation(root); if (!releaseTransport) return fail("another shard sync mutation is running");
|
|
256
|
+
releaseSync = generationSyncLock(root); if (!releaseSync) return fail("another shard sync is running or its owner is unknown");
|
|
257
|
+
if (!existing) {
|
|
258
|
+
// No worktree or index from the legacy home is ever inherited by this private cache.
|
|
259
|
+
await required(["init", "--bare", "--template=", "--object-format=sha1", "-q", cache], "shard sync cache initialization failed");
|
|
260
|
+
}
|
|
261
|
+
await independent();
|
|
262
|
+
const oldURL = await git(["remote", "get-url", "origin"]);
|
|
263
|
+
if (opts.remote) await required(oldURL.code === 0 ? ["remote", "set-url", "origin", url] : ["remote", "add", "origin", url], "shard sync remote configuration failed");
|
|
264
|
+
const fetchURLs = (await required(["remote", "get-url", "--all", "origin"], "shard sync fetch destination unavailable")).split("\n");
|
|
265
|
+
const pushURLs = (await required(["remote", "get-url", "--push", "--all", "origin"], "shard sync push destination unavailable")).split("\n");
|
|
266
|
+
if (fetchURLs.length !== 1 || fetchURLs[0] !== url || pushURLs.length !== 1 || pushURLs[0] !== url) refuse("shard sync refuses differing or multiple remote destinations");
|
|
267
|
+
// Cooperating source writers stay excluded across remote reads and final imports. No nested withMutation calls.
|
|
268
|
+
for (const target of [...targets].sort((a, b) => a.store.dir < b.store.dir ? -1 : 1)) {
|
|
269
|
+
const release = acquireMutation(target.store.dir); if (!release) refuse(`${shardSyncLabel(target.realm)}: another source mutation is running`); releases.push(release);
|
|
270
|
+
}
|
|
271
|
+
const blobCache = new Map<string, Buffer>(), decoded = new Map<string, ShardSyncRow[]>();
|
|
272
|
+
const blob = async (object: string, path: string): Promise<Buffer> => {
|
|
273
|
+
const maximum = path === "shard.json" ? 64 * 1024 : SHARD_PACK_MAX_BYTES, prior = blobCache.get(object);
|
|
274
|
+
if (prior) { if (prior.length > maximum) refuse("shard sync blob exceeds its bound"); return prior; }
|
|
275
|
+
const size = Number(await required(["cat-file", "-s", object], "shard sync blob size unavailable"));
|
|
276
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > maximum) refuse("shard sync blob exceeds its bound");
|
|
277
|
+
const value = await required(["cat-file", "blob", object], "shard sync blob unavailable"), bytes = Buffer.from(value + "\n");
|
|
278
|
+
if (bytes.length !== size) refuse("shard sync requires complete newline-terminated public blobs");
|
|
279
|
+
const identity = createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
280
|
+
if (identity !== object) refuse("shard sync public blob identity mismatch");
|
|
281
|
+
blobCache.set(object, bytes); return bytes;
|
|
282
|
+
};
|
|
283
|
+
type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer> };
|
|
284
|
+
// Keep only requested tips. Per-commit full row maps would make append-only history quadratic in memory.
|
|
285
|
+
const histories = new Map<string, History>(), audited = new Set<string>();
|
|
286
|
+
const treeState = async (commit: string, target: Target): Promise<History> => {
|
|
287
|
+
const entries = (await required(["ls-tree", "-r", "-z", commit], "shard sync tree unavailable")).split("\0").filter(Boolean);
|
|
288
|
+
const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>();
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const match = /^100644 blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
|
|
291
|
+
if (!match || match[2] !== "shard.json" && !PACK.test(match[2]!)) refuse(`${shardSyncLabel(target.realm)}: forbidden historical tree entry`);
|
|
292
|
+
const path = match[2]!, object = match[1]!, bytes = await blob(object, path); files.set(path, bytes);
|
|
293
|
+
if (path === "shard.json") {
|
|
294
|
+
if (bytes.toString() !== shardSyncDescriptor(target.realm)) refuse(`${shardSyncLabel(target.realm)}: historical descriptor mismatch`);
|
|
295
|
+
} else {
|
|
296
|
+
const key = `${shardSyncRef(target.realm)}:${object}`;
|
|
297
|
+
let pack = decoded.get(key);
|
|
298
|
+
if (!pack) { pack = decodeShardPack(path, bytes, target.realm, target.generation); decoded.set(key, pack); }
|
|
299
|
+
else if (!path.endsWith(`/${digest(bytes)}.jsonl`) || pack.some(row => path !== `rows/${digest(row.machine)}/${digest(bytes)}.jsonl`)) refuse(`${shardSyncLabel(target.realm)}: immutable pack path changed`);
|
|
300
|
+
mergeRows(rows, pack, target.realm);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!files.has("shard.json")) refuse(`${shardSyncLabel(target.realm)}: historical descriptor missing`);
|
|
304
|
+
return { rows, files };
|
|
305
|
+
};
|
|
306
|
+
const history = async (tip: string, target: Target): Promise<History> => {
|
|
307
|
+
const key = `${shardSyncRef(target.realm)}:${tip}`, cached = histories.get(key); if (cached) return cached;
|
|
308
|
+
const commits = (await required(["rev-list", "--topo-order", "--reverse", "--parents", tip], "shard sync history unavailable")).split("\n").filter(Boolean);
|
|
309
|
+
let latest: History | undefined;
|
|
310
|
+
// ponytail: complete selected history still costs time and selected pack memory per invocation.
|
|
311
|
+
// Upgrade: a validated immutable commit/blob digest cache, never an arbitrary lifetime refusal cliff.
|
|
312
|
+
for (const line of commits) {
|
|
313
|
+
const [commit, ...parents] = line.split(" ");
|
|
314
|
+
if (!commit || !OID.test(commit) || parents.some(parent => !OID.test(parent))) refuse("shard sync history identity invalid");
|
|
315
|
+
const identity = `${shardSyncRef(target.realm)}:${commit}`;
|
|
316
|
+
if (audited.has(identity)) { if (commit === tip) latest = await treeState(commit, target); continue; }
|
|
317
|
+
const current = await treeState(commit, target);
|
|
318
|
+
for (const parent of parents) {
|
|
319
|
+
if (!audited.has(`${shardSyncRef(target.realm)}:${parent}`)) refuse(`${shardSyncLabel(target.realm)}: historical parent unavailable`);
|
|
320
|
+
const previous = histories.get(`${shardSyncRef(target.realm)}:${parent}`) ?? await treeState(parent, target);
|
|
321
|
+
for (const row of previous.rows.values()) if (canonicalJSON(current.rows.get(row.id)) !== canonicalJSON(row)) refuse(`${shardSyncLabel(target.realm)}: historical original row removed or replaced`);
|
|
322
|
+
}
|
|
323
|
+
audited.add(identity); if (commit === tip) latest = current;
|
|
324
|
+
}
|
|
325
|
+
if (!latest) refuse("shard sync history unavailable");
|
|
326
|
+
histories.set(key, latest); return latest;
|
|
327
|
+
};
|
|
328
|
+
const fetchTargets = async (): Promise<void> => {
|
|
329
|
+
for (const target of targets) {
|
|
330
|
+
const ref = shardSyncRef(target.realm);
|
|
331
|
+
const symbolic = await git(["symbolic-ref", "--quiet", ref]); check();
|
|
332
|
+
if (symbolic.code !== 1) refuse(`${shardSyncLabel(target.realm)}: selected cache ref must be direct`);
|
|
333
|
+
const local = await git(["rev-parse", "--verify", "--quiet", ref]);
|
|
334
|
+
if (local.code === 0) { if (!OID.test(local.out)) refuse("shard sync cached ref invalid"); target.local = local.out; await history(local.out, target); }
|
|
335
|
+
else if (local.code !== 1) refuse("shard sync cached ref unavailable");
|
|
336
|
+
const advertised = await git(["ls-remote", "--symref", "--exit-code", "--refs", "origin", ref]); check();
|
|
337
|
+
if (advertised.code === 2) { if (target.local) refuse(`${shardSyncLabel(target.realm)}: previously synced remote ref disappeared`); target.remote = undefined; continue; }
|
|
338
|
+
const fields = advertised.out.split("\t");
|
|
339
|
+
if (advertised.code !== 0 || fields.length !== 2 || !OID.test(fields[0]!) || fields[1] !== ref) refuse(`${shardSyncLabel(target.realm)}: remote ref lookup unavailable`);
|
|
340
|
+
const incoming = `refs/amem/shard-incoming/${randomBytes(16).toString("hex")}`; transient.push(incoming);
|
|
341
|
+
await required(["fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "origin", `${ref}:${incoming}`], "shard sync fetch failed");
|
|
342
|
+
await independent();
|
|
343
|
+
const tip = await required(["rev-parse", "--verify", incoming], "shard sync incoming ref unavailable");
|
|
344
|
+
if (!OID.test(tip)) refuse("shard sync incoming ref invalid");
|
|
345
|
+
if (target.local) await required(["merge-base", "--is-ancestor", target.local, tip], `${shardSyncLabel(target.realm)}: remote history is not fast-forward`);
|
|
346
|
+
const remote = await history(tip, target); target.remote = tip;
|
|
347
|
+
mergeRows(target.rows, Array.from(remote.rows.values()), target.realm);
|
|
348
|
+
for (const row of remote.rows.values()) target.fetched.add(row.id);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const localTargets = () => {
|
|
352
|
+
check();
|
|
353
|
+
for (const target of targets) {
|
|
354
|
+
const source = sourceRows(target.store, target.realm); target.sources = source.sources; target.pending = source.pending;
|
|
355
|
+
mergeRows(target.rows, source.rows, target.realm);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const writeBlob = async (bytes: Buffer): Promise<string> => {
|
|
359
|
+
const file = join(root, `.blob-${randomBytes(16).toString("hex")}`); temporary.push(file);
|
|
360
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
361
|
+
try { let offset = 0; while (offset < bytes.length) { const n = writeSync(fd, bytes, offset, bytes.length - offset); if (n <= 0) refuse("shard sync blob write unavailable"); offset += n; } fsyncSync(fd); } finally { closeSync(fd); }
|
|
362
|
+
return required(["hash-object", "-w", "--no-filters", "--", file], "shard sync blob creation failed");
|
|
363
|
+
};
|
|
364
|
+
const buildTargets = async () => {
|
|
365
|
+
for (const target of targets) {
|
|
366
|
+
const remote = target.remote ? await history(target.remote, target) : undefined;
|
|
367
|
+
const files = new Map(remote?.files);
|
|
368
|
+
files.set("shard.json", Buffer.from(shardSyncDescriptor(target.realm)));
|
|
369
|
+
// Retain immutable packs and append only new ids; repacking every prefix would duplicate the entire history at each sync.
|
|
370
|
+
const additions = [...target.rows.values()].filter(row => !remote?.rows.has(row.id));
|
|
371
|
+
for (const [path, bytes] of encodeShardPacks(additions)) {
|
|
372
|
+
const old = files.get(path); if (old && !old.equals(bytes)) refuse(`${shardSyncLabel(target.realm)}: immutable pack conflict`); files.set(path, bytes);
|
|
373
|
+
}
|
|
374
|
+
await required(["read-tree", "--empty"], "shard sync private index unavailable");
|
|
375
|
+
for (const [path, bytes] of [...files].sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
376
|
+
const object = await writeBlob(bytes);
|
|
377
|
+
if (!OID.test(object)) refuse("shard sync blob identity invalid");
|
|
378
|
+
await required(["update-index", "--add", "--cacheinfo", `100644,${object},${path}`], "shard sync private tree staging failed");
|
|
379
|
+
}
|
|
380
|
+
const tree = await required(["write-tree"], "shard sync private tree unavailable");
|
|
381
|
+
if (target.remote && await required(["rev-parse", `${target.remote}^{tree}`], "shard sync remote tree unavailable") === tree) { target.commit = target.remote; continue; }
|
|
382
|
+
const who = ["-c", "user.name=agent-mem", "-c", "user.email=agent-mem@sync.invalid"];
|
|
383
|
+
target.commit = await required([...who, "commit-tree", tree, ...(target.remote ? ["-p", target.remote] : []), "-m", "memory: shard append"], "shard sync commit failed");
|
|
384
|
+
if (!OID.test(target.commit)) refuse("shard sync commit identity invalid");
|
|
385
|
+
await history(target.commit, target);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
localTargets(); await fetchTargets(); localTargets(); provePrunes(targets); check();
|
|
389
|
+
// Every selected realm, including its complete remote history/prunes, is preflighted before the first append or publication.
|
|
390
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
391
|
+
if (attempt) { await fetchTargets(); localTargets(); provePrunes(targets); check(); }
|
|
392
|
+
await buildTargets(); check();
|
|
393
|
+
const sent = await git(["push", "--atomic", "--no-follow-tags", "-q", "origin", ...targets.map(target => `${target.commit!}:${shardSyncRef(target.realm)}`)]);
|
|
394
|
+
if (sent.code === 0) pushed = true;
|
|
395
|
+
check();
|
|
396
|
+
if (sent.code === 0) {
|
|
397
|
+
pushed = true;
|
|
398
|
+
// No local append before a successful atomic publication: a raced foreign prune can still refuse the entire retry safely.
|
|
399
|
+
localTargets(); provePrunes(targets); check();
|
|
400
|
+
for (const target of targets) {
|
|
401
|
+
assertCuratedShardPaths(target.store.profile.home, target.realm.scope === "user" ? { user: true } : { project: target.realm.key });
|
|
402
|
+
importRows(target);
|
|
403
|
+
}
|
|
404
|
+
for (const target of targets) await required(["update-ref", "--no-deref", shardSyncRef(target.realm), target.commit!, target.local ?? zero], "shard sync cached ref changed concurrently");
|
|
405
|
+
return { ok: true, pushed, entries: targets.reduce((n, target) => n + [...target.rows.values()].filter(row => row.op === "note").length, 0) };
|
|
406
|
+
}
|
|
407
|
+
if (opts.signal?.aborted) return fail("stopped");
|
|
408
|
+
}
|
|
409
|
+
return fail("shard sync selected-ref push failed");
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (error instanceof ShardSyncFormatError || error instanceof SyncRefusal) return fail(error.message);
|
|
412
|
+
try { testOnlyUnexpectedError?.(error); } catch { /* A test observer cannot change the neutral result. */ }
|
|
413
|
+
return fail("shard sync unavailable");
|
|
414
|
+
} finally {
|
|
415
|
+
for (const ref of transient) { try { await generationSyncGit(root, gitArgs(["update-ref", "--no-deref", "-d", ref]), undefined); } catch { /* private orphaned refs remain non-public */ } }
|
|
416
|
+
for (const file of temporary) { try { rmSync(file, { force: true }); } catch { /* private diagnostic bytes never enter a public tree */ } }
|
|
417
|
+
for (const release of releases.reverse()) { try { release(); } catch { /* fail-open cleanup */ } }
|
|
418
|
+
try { releaseSync?.(); } catch { /* private lock cleanup */ }
|
|
419
|
+
try { releaseTransport?.(); } catch { /* private lock cleanup */ }
|
|
420
|
+
}
|
|
421
|
+
}
|