@staix/agent-mem 0.13.0 → 0.14.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/plugin.json +1 -1
- package/README.md +21 -2
- package/package.json +3 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +1 -0
- package/src/memory/catalog.ts +4 -4
- package/src/memory/commands.ts +4 -0
- package/src/memory/doctor.ts +13 -3
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/migrate.ts +1 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +4 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +46 -8
- package/src/memory/sync.ts +141 -5
package/src/memory/shard.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
5
|
+
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
5
6
|
import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -80,16 +81,53 @@ export function captureShardDir(base: string, key: string, machine: string): str
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export const SHARDED_SCHEMA = "agent-mem.shards/v1";
|
|
83
|
-
export
|
|
84
|
+
export const SHARDED_V2_SCHEMA = "agent-mem.shards/v2";
|
|
85
|
+
export const SHARDED_V2_FIELDS = ["schema", "generation", "phase", "selectorRevision", "sourceGeneration", "sources", "machine", "legacyCommit", "deadLeasesRemoved"] as const;
|
|
86
|
+
export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
|
|
84
87
|
|
|
85
88
|
export function userShardDir(base: string): string {
|
|
86
89
|
return join(base, "user");
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
type ShardedJournal = Record<string, unknown> & ShardedSelection;
|
|
93
|
+
function shardJournal(memoryDir: string): ShardedJournal {
|
|
94
|
+
const value = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
95
|
+
if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
|
|
96
|
+
if (value.schema === SHARDED_V2_SCHEMA && Object.keys(value).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key))) throw new Error("unsupported curated shard view");
|
|
97
|
+
return value as ShardedJournal;
|
|
98
|
+
}
|
|
99
|
+
function compatibleShardAdvance(before: ShardedJournal, after: ShardedJournal): boolean {
|
|
100
|
+
if (before.schema !== SHARDED_V2_SCHEMA || after.schema !== SHARDED_V2_SCHEMA) return false;
|
|
101
|
+
const { selectorRevision: _old, ...old } = before, { selectorRevision: _next, ...next } = after;
|
|
102
|
+
return canonicalJSON(old) === canonicalJSON(next);
|
|
103
|
+
}
|
|
104
|
+
function shardSnapshot(memoryDir: string, deadline: number) {
|
|
105
|
+
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
106
|
+
let journal = shardJournal(memoryDir);
|
|
107
|
+
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
108
|
+
let selector: ReturnType<typeof readSelector>;
|
|
109
|
+
try { selector = readSelector(memoryDir, deadline); }
|
|
110
|
+
catch (error) {
|
|
111
|
+
// The writer's spent wait budget or unavailable proof is never restarted here.
|
|
112
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw error;
|
|
113
|
+
const current = shardJournal(memoryDir);
|
|
114
|
+
if (current.selectorRevision === journal.selectorRevision || !compatibleShardAdvance(journal, current)) throw error;
|
|
115
|
+
journal = current; continue;
|
|
116
|
+
}
|
|
117
|
+
const current = shardJournal(memoryDir);
|
|
118
|
+
if (canonicalJSON(current) !== canonicalJSON(journal)) {
|
|
119
|
+
if (!compatibleShardAdvance(journal, current)) throw new Error("curated shard selector changed");
|
|
120
|
+
journal = current; continue;
|
|
121
|
+
}
|
|
122
|
+
if ((journal.schema === SHARDED_V2_SCHEMA) !== (selector.selector?.schema === "agent-mem.profile/v2")) throw new Error("curated shard selector changed");
|
|
123
|
+
if (journal.schema === SHARDED_V2_SCHEMA && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
|
|
124
|
+
return { journal, selector };
|
|
125
|
+
}
|
|
126
|
+
throw new Error("curated shard selector changed");
|
|
127
|
+
}
|
|
89
128
|
export function readShardedSelection(memoryDir: string): ShardedSelection {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
return { schema: SHARDED_SCHEMA, generation: value.generation, phase: "active", selectorRevision: value.selectorRevision };
|
|
129
|
+
const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
130
|
+
return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
/** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
|
|
@@ -107,14 +145,14 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
|
|
|
107
145
|
check(dir, true);
|
|
108
146
|
for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
|
|
109
147
|
for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
|
|
110
|
-
for (const database of ["index.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
148
|
+
for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
/** Open one already-sealed shard without consulting the legacy home or the catalog. */
|
|
114
152
|
export function resolveShardProfile(memoryDir: string, opts: { project?: string; user?: boolean }): BoundProfile {
|
|
115
153
|
if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
|
|
116
154
|
assertCuratedShardPaths(memoryDir, opts);
|
|
117
|
-
const
|
|
155
|
+
const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS, initial = shardSnapshot(memoryDir, deadline), generation = initial.journal.generation;
|
|
118
156
|
const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
|
|
119
157
|
if (opts.project !== undefined && readShardDescriptor(memoryDir, shardKeyHash(opts.project))?.key !== opts.project) throw new Error("curated shard project identity mismatch");
|
|
120
158
|
const expected = generationDescriptor(generation);
|
|
@@ -127,10 +165,13 @@ export function resolveShardProfile(memoryDir: string, opts: { project?: string;
|
|
|
127
165
|
const rootMachine = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown } | undefined;
|
|
128
166
|
const shardMachine = readProfileJSON(join(dir, "machine.json"), 4096) as { id?: unknown } | undefined;
|
|
129
167
|
if (typeof rootMachine?.id !== "string" || !/^[a-f0-9]{16}$/.test(rootMachine.id) || rootMachine.id !== shardMachine?.id) throw new Error("curated shard machine identity mismatch");
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
168
|
+
const final = shardSnapshot(memoryDir, deadline), selector = final.selector;
|
|
169
|
+
if (canonicalJSON(final.journal) !== canonicalJSON(initial.journal) && !compatibleShardAdvance(initial.journal, final.journal)
|
|
170
|
+
|| selector.revision !== final.journal.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
|
|
171
|
+
if (selector.selector?.schema === "agent-mem.profile/v2") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
|
|
132
172
|
const legacyMainHome = join(dir, "legacy-main");
|
|
133
173
|
const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
|
|
134
174
|
return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
|
|
175
|
+
...(selector.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash } : {}),
|
|
135
176
|
legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
136
177
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readPublicPruneView } from "./public-prune-view.ts";
|
|
2
|
+
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
1
3
|
import { cliAttribution } from "./cli-attribution.ts";
|
|
2
4
|
import { Database } from "bun:sqlite";
|
|
3
5
|
import { createHash } from "node:crypto";
|
|
@@ -17,13 +19,13 @@ import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, cap
|
|
|
17
19
|
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
18
20
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
19
21
|
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
20
|
-
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
22
|
+
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow, rebindPublicPruneProfile } from "./generation.ts";
|
|
21
23
|
import { GenerationIndex, coalesceGenerationSource, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
22
24
|
import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
|
|
23
25
|
import { readMigrationSources } from "./migrate.ts";
|
|
24
26
|
import type { PruneSourceRow, PruneStorePort } from "./prune.ts";
|
|
25
27
|
import { isDeepStrictEqual } from "node:util";
|
|
26
|
-
import { registerReaderLease } from "./reader-lease.ts";
|
|
28
|
+
import { registerReaderLease, type ReaderLease } from "./reader-lease.ts";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
|
|
@@ -215,7 +217,7 @@ export class MemoryStore {
|
|
|
215
217
|
/** #13: the derived FTS5 copy of the curated rows, and the memory home base for capture shard resolution (#15 T1). */
|
|
216
218
|
private readonly searchIndex: SearchIndex;
|
|
217
219
|
private readonly memoryBaseDir: string;
|
|
218
|
-
private releaseReaderLease?:
|
|
220
|
+
private releaseReaderLease?: ReaderLease;
|
|
219
221
|
private readonly userOverlay?: MemoryStore;
|
|
220
222
|
/** #27: the other keys' shards of a reconciled read (curated shards); shared-v1 reads them from its own index. */
|
|
221
223
|
private readonly aliasStores: MemoryStore[];
|
|
@@ -248,10 +250,16 @@ export class MemoryStore {
|
|
|
248
250
|
this.screen = opts.screen;
|
|
249
251
|
this.deferPromotions = opts.deferPromotions ?? false;
|
|
250
252
|
// Announce before opening derived handles or reading rows; failed admission leaks no database handles.
|
|
251
|
-
if (!bootstrap) this.releaseReaderLease = registerReaderLease(this.profile.home);
|
|
253
|
+
if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
|
|
252
254
|
let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
|
|
253
255
|
try {
|
|
254
|
-
const
|
|
256
|
+
const indexFile = join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db");
|
|
257
|
+
if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
258
|
+
const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
|
|
259
|
+
if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
|
|
260
|
+
}
|
|
261
|
+
const db = openedDb = new Database(indexFile, { create: true });
|
|
262
|
+
if (this.profile.foreignPruneFormat) chmodSync(indexFile, 0o600);
|
|
255
263
|
db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
|
|
256
264
|
db.run("PRAGMA journal_mode = WAL");
|
|
257
265
|
if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
|
|
@@ -272,7 +280,7 @@ export class MemoryStore {
|
|
|
272
280
|
// One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
|
|
273
281
|
this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
|
|
274
282
|
this.memoryBaseDir = home;
|
|
275
|
-
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
283
|
+
try { chmodSync(join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), 0o600); } catch { /* best effort */ }
|
|
276
284
|
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
277
285
|
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
278
286
|
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
@@ -1003,6 +1011,8 @@ export class MemoryStore {
|
|
|
1003
1011
|
|
|
1004
1012
|
/** Index deferred writes before any note, goal or suggestion read. */
|
|
1005
1013
|
private ensureFresh(): void {
|
|
1014
|
+
this.releaseReaderLease?.verify();
|
|
1015
|
+
if (!this.bootstrapOnly) rebindPublicPruneProfile(this.profile);
|
|
1006
1016
|
this.userOverlay?.ensureFresh();
|
|
1007
1017
|
for (const store of this.aliasStores) store.ensureFresh();
|
|
1008
1018
|
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
@@ -1164,6 +1174,7 @@ export class MemoryStore {
|
|
|
1164
1174
|
};
|
|
1165
1175
|
const roots = new Set<string>(), fixed = new Set<string>();
|
|
1166
1176
|
for (const store of this.readStores()) {
|
|
1177
|
+
if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
|
|
1167
1178
|
roots.add(store.dir);
|
|
1168
1179
|
for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
|
|
1169
1180
|
for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
|
|
@@ -1195,6 +1206,7 @@ export class MemoryStore {
|
|
|
1195
1206
|
}
|
|
1196
1207
|
private prepareRecallRead(stores: readonly MemoryStore[]): void {
|
|
1197
1208
|
for (const store of stores) {
|
|
1209
|
+
if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
|
|
1198
1210
|
const active = readSelector(store.profile.home);
|
|
1199
1211
|
if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
|
|
1200
1212
|
if (store.profile.sharded) {
|
|
@@ -1257,6 +1269,7 @@ export class MemoryStore {
|
|
|
1257
1269
|
if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
|
|
1258
1270
|
}
|
|
1259
1271
|
const frontier = () => {
|
|
1272
|
+
if (!this.bootstrapOnly) for (const store of this.readStores()) rebindPublicPruneProfile(store.profile);
|
|
1260
1273
|
const active = readSelector(this.profile.home);
|
|
1261
1274
|
if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
|
|
1262
1275
|
if (this.profile.sharded) {
|
|
@@ -1553,6 +1566,18 @@ export function buildGenerationIndex(profile: BoundProfile, sources: readonly Le
|
|
|
1553
1566
|
finally { store.close(); }
|
|
1554
1567
|
}
|
|
1555
1568
|
|
|
1569
|
+
/** Trusted activation bootstrap; caller holds profile + realm mutation fences. */
|
|
1570
|
+
export function buildPublicPruneIndex(profile: BoundProfile): {targets:string[];sourceHash:string} {
|
|
1571
|
+
const sources = readShardLegacySnapshot(profile, {}).map(({bytes:_bytes,local:_local,...prefix}) => prefix);
|
|
1572
|
+
const store = new MemoryStore({deferPromotions:true}, {token:MIGRATION_BOOTSTRAP, profile, limits:sources});
|
|
1573
|
+
try {
|
|
1574
|
+
const before = store.derivedTables(); store.rebuild(); const tables = store.derivedTables();
|
|
1575
|
+
if (!isDeepStrictEqual(before, tables)) throw new Error("public prune view rebuild differs from incremental index");
|
|
1576
|
+
return { targets: (tables.generation_targets as {id:string}[]).map(row=>row.id).sort(),
|
|
1577
|
+
sourceHash: hashJSON([tables.generation_rows, tables.generation_prunes, tables.generation_segments, tables.generation_state]) };
|
|
1578
|
+
} finally {store.close();}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1556
1581
|
function initializeShard(home: string, opts: { project?: string; user?: boolean }): void {
|
|
1557
1582
|
const selection = readShardedSelection(home), generation = selection.generation;
|
|
1558
1583
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!), machine = machineId(home);
|
|
@@ -1565,7 +1590,9 @@ function initializeShard(home: string, opts: { project?: string; user?: boolean
|
|
|
1565
1590
|
}
|
|
1566
1591
|
const legacyHome = join(dir, "legacy"), sources = readMigrationSources(legacyHome);
|
|
1567
1592
|
writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
|
|
1593
|
+
const selected = readSelector(home).selector;
|
|
1568
1594
|
const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
|
|
1595
|
+
...(selected?.schema === "agent-mem.profile/v2" ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash} : {}),
|
|
1569
1596
|
legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
1570
1597
|
buildGenerationIndex(profile, sources);
|
|
1571
1598
|
writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
|
|
@@ -1578,11 +1605,21 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1578
1605
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
1579
1606
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1580
1607
|
assertCuratedShardPaths(home, opts);
|
|
1581
|
-
//
|
|
1608
|
+
// Lifecycle refusal must remain before admission creates any lease/fence file.
|
|
1582
1609
|
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
|
1610
|
+
readShardedSelection(home); // validate the floor before any lazy shard initialization
|
|
1611
|
+
const admission = registerReaderLease(home, {scope:"shard"});
|
|
1612
|
+
try {
|
|
1583
1613
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
|
|
1584
1614
|
const missing = !existsSync(join(dir, "generation.json"));
|
|
1585
|
-
if (missing)
|
|
1615
|
+
if (missing) {
|
|
1616
|
+
const selected = readSelector(home).selector;
|
|
1617
|
+
if (selected?.schema === "agent-mem.profile/v2") {
|
|
1618
|
+
const view = readPublicPruneView(home, selected.pruneInputSetHash, selected.generation), key = opts.user ? "user" : `project:${opts.project}`;
|
|
1619
|
+
if (view.realms[key] || ["generation-seal.json", "migration-sources.json", "machine.json"].some(name => existsSync(join(dir, name)))) throw new Error("v2 generation input unavailable; reader cannot repair committed inputs");
|
|
1620
|
+
}
|
|
1621
|
+
initializeShard(home, opts);
|
|
1622
|
+
}
|
|
1586
1623
|
const profile = resolveShardProfile(home, opts);
|
|
1587
1624
|
const { reconcile, ...plain } = opts;
|
|
1588
1625
|
const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
|
|
@@ -1599,6 +1636,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1599
1636
|
}
|
|
1600
1637
|
return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
|
|
1601
1638
|
} catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
|
|
1639
|
+
} finally { admission(); }
|
|
1602
1640
|
}
|
|
1603
1641
|
|
|
1604
1642
|
/** Administrative migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { rebindPublicPruneProfile } from "./generation.ts";
|
|
3
5
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
6
|
import { acquireMutation } from "./mutation.ts";
|
|
5
7
|
import type { MemoryStore } from "./store.ts";
|
|
@@ -12,7 +14,9 @@ import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen }
|
|
|
12
14
|
* machine's history never conflicts. Pull, then push; fail-open: a failure is a result with one reason, never a throw.
|
|
13
15
|
*/
|
|
14
16
|
export type Visibility = "public" | "private" | "unknown";
|
|
15
|
-
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string
|
|
17
|
+
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string;
|
|
18
|
+
pruneApplication?: {transport:"durable"|"unconfirmed";proofComplete:number;projectionActive:number|null;nativeApplied:number|null;held:number;phase:"preflight"|"prepared"|"active"|"complete"};
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n";
|
|
18
22
|
const STEP_MS = 60_000;
|
|
@@ -62,13 +66,19 @@ async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_
|
|
|
62
66
|
|
|
63
67
|
export { git as syncGit };
|
|
64
68
|
|
|
69
|
+
/** Shared hardening for text history queries and bounded binary object reads. */
|
|
70
|
+
function spawnGenerationGit(dir: string, args: string[], group: boolean) {
|
|
71
|
+
// The shell receives only positional arguments; its umask is child-local.
|
|
72
|
+
return Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) });
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
/** Generation-only process isolation; shared-v1 above retains its pre-P9 spawn and environment contract. */
|
|
66
76
|
export async function generationSyncGit(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
67
77
|
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
68
78
|
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
69
79
|
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
70
80
|
const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline
|
|
71
|
-
const p =
|
|
81
|
+
const p = spawnGenerationGit(dir, args, group);
|
|
72
82
|
const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); };
|
|
73
83
|
const unregister = group ? register(p.pid) : () => {};
|
|
74
84
|
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
@@ -89,6 +99,53 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab
|
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
|
|
102
|
+
/** Exact Git object bytes. Bounds apply only here, never to full text history audits. */
|
|
103
|
+
export async function generationSyncGitBytes(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS, maxBytes = 2 * 1024 * 1024): Promise<{ code: number; out: Uint8Array; err: string }> {
|
|
104
|
+
const empty = () => new Uint8Array(0);
|
|
105
|
+
if (signal?.aborted) return { code: -1, out: empty(), err: "stopped" };
|
|
106
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || !Number.isFinite(ms) || ms <= 0) return { code: -1, out: empty(), err: "invalid git output bound or deadline" };
|
|
107
|
+
// A bound or deadline can stop this call even without a caller signal, so always own its group.
|
|
108
|
+
const p = spawnGenerationGit(dir, args, true), unregister = register(p.pid);
|
|
109
|
+
const send = (name: "SIGTERM" | "SIGKILL") => {
|
|
110
|
+
try { process.kill(-p.pid, name); } catch { try { p.kill(name); } catch { /* already exited */ } }
|
|
111
|
+
};
|
|
112
|
+
let hard: ReturnType<typeof setTimeout> | undefined, stopped = false, overflow = false;
|
|
113
|
+
const kill = () => { stopped = true; send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
114
|
+
const timer = setTimeout(kill, ms);
|
|
115
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
116
|
+
// Drain both pipes concurrently, including after overflow. Waiting on exit before draining
|
|
117
|
+
// deadlocks a full pipe; cancelling a reader can leave inherited child pipes unaccounted for.
|
|
118
|
+
const collect = async (stream: ReadableStream<Uint8Array>, limit: number, refuse: boolean): Promise<Buffer> => {
|
|
119
|
+
const reader = stream.getReader(), chunks: Buffer[] = []; let size = 0;
|
|
120
|
+
try {
|
|
121
|
+
for (;;) {
|
|
122
|
+
const part = await reader.read(); if (part.done) break;
|
|
123
|
+
const bytes = part.value, remaining = limit - size;
|
|
124
|
+
if (refuse && bytes.byteLength > remaining) { overflow = true; chunks.length = 0; kill(); }
|
|
125
|
+
if (!refuse || !overflow) {
|
|
126
|
+
const keep = Math.min(remaining, bytes.byteLength);
|
|
127
|
+
if (keep) { chunks.push(Buffer.from(bytes.subarray(0, keep))); size += keep; }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Buffer.concat(chunks);
|
|
131
|
+
} finally { reader.releaseLock(); }
|
|
132
|
+
};
|
|
133
|
+
const operations = [p.exited, collect(p.stdout, maxBytes, true), collect(p.stderr, 65_536, false)] as const;
|
|
134
|
+
try {
|
|
135
|
+
const [code, out, err] = await Promise.all(operations);
|
|
136
|
+
return overflow ? { code: -1, out: empty(), err: "git output exceeds byte bound" }
|
|
137
|
+
: stopped ? { code: -1, out: empty(), err: "stopped" } : { code, out, err: err.toString("utf8").trim() };
|
|
138
|
+
} catch {
|
|
139
|
+
kill(); await Promise.allSettled(operations);
|
|
140
|
+
return { code: -1, out: empty(), err: "git output unavailable" };
|
|
141
|
+
} finally {
|
|
142
|
+
// Reap descendants even if Git exited first while they retained its pipe handles.
|
|
143
|
+
if (stopped) send("SIGKILL");
|
|
144
|
+
unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
92
149
|
/**
|
|
93
150
|
* The `owner/repo` of a remote URL whose host is EXACTLY `github.com` (case-insensitive) over `https://`, `ssh://git@github.com/` or the scp form
|
|
94
151
|
* `[git@]github.com:`; anything else is undefined, so it is `unknown` and never asked. An unanchored match let `https://notgithub.com/acme/notes`,
|
|
@@ -161,18 +218,97 @@ export interface SyncOptions {
|
|
|
161
218
|
visibility?: (url: string) => Promise<Visibility>;
|
|
162
219
|
/** An extra screen over each outbound row's text, on top of the machine's configured policy (the CLI passes the repository-narrowed policy). */
|
|
163
220
|
screen?: RowScreen;
|
|
221
|
+
/** Host-only policy binding stamp, never authorization or a substitute for screening. */
|
|
222
|
+
policyVersion?: () => string;
|
|
164
223
|
/** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
|
|
165
224
|
signal?: AbortSignal;
|
|
166
225
|
}
|
|
167
226
|
|
|
227
|
+
/** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
|
|
228
|
+
export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
|
|
229
|
+
/** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
|
|
230
|
+
export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
|
|
231
|
+
function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
|
|
232
|
+
const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
|
|
233
|
+
if (!store.profile.sharded) return unavailable();
|
|
234
|
+
const uid = process.getuid?.(); if (uid === undefined) return unavailable();
|
|
235
|
+
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
236
|
+
const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
|
|
237
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
|
|
238
|
+
|| stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
|
|
239
|
+
};
|
|
240
|
+
const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
|
|
241
|
+
const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
|
|
242
|
+
const directory = (dir: string): void => {
|
|
243
|
+
if (directories.has(dir)) return;
|
|
244
|
+
const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
|
|
245
|
+
if (dir !== home) directory(dirname(dir));
|
|
246
|
+
const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
|
|
247
|
+
emit(["directory", dir, String(before.dev), String(before.ino)]);
|
|
248
|
+
checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
|
|
249
|
+
};
|
|
250
|
+
// Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
|
|
251
|
+
const source = (file: string, privateMode = true): void => {
|
|
252
|
+
directory(dirname(file));
|
|
253
|
+
let before: BigIntStats;
|
|
254
|
+
try { before = lstatSync(file, { bigint: true }); }
|
|
255
|
+
catch (error) {
|
|
256
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
257
|
+
emit(["source", file, "absent"]);
|
|
258
|
+
checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
|
|
259
|
+
}
|
|
260
|
+
owned(before, false, privateMode);
|
|
261
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
262
|
+
try {
|
|
263
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
|
|
264
|
+
const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
|
|
265
|
+
for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
|
|
266
|
+
const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
|
|
267
|
+
if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
|
|
268
|
+
emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
|
|
269
|
+
} finally { closeSync(fd); }
|
|
270
|
+
checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
|
|
271
|
+
};
|
|
272
|
+
try {
|
|
273
|
+
directory(home);
|
|
274
|
+
for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
|
|
275
|
+
const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
276
|
+
for (const target of targets) {
|
|
277
|
+
directory(target.dir);
|
|
278
|
+
emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
|
|
279
|
+
for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
|
|
280
|
+
}
|
|
281
|
+
for (const check of checks) check();
|
|
282
|
+
return digest.digest("hex");
|
|
283
|
+
} catch { return unavailable(); }
|
|
284
|
+
}
|
|
285
|
+
|
|
168
286
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
287
|
+
if (store.profile.sharded) {
|
|
288
|
+
try {
|
|
289
|
+
// Refresh only through the existing proved monotone profile rebind before pinning this attempt.
|
|
290
|
+
for (const target of store.openedSyncTargets()) rebindPublicPruneProfile(target.profile);
|
|
291
|
+
const binding = shardSyncBinding(store), policyVersion = opts.policyVersion?.();
|
|
292
|
+
const assertBinding = () => {
|
|
293
|
+
if (opts.policyVersion?.() !== policyVersion || shardSyncBinding(store) !== binding) throw new Error("binding changed");
|
|
294
|
+
};
|
|
295
|
+
assertBinding();
|
|
296
|
+
const screen = outboundScreen(store.profile.home, opts.screen);
|
|
297
|
+
const pass: RowScreen = row => { assertBinding(); const accepted = screen(row); assertBinding(); if (typeof accepted !== "boolean") throw new Error("invalid screen result"); return accepted; };
|
|
298
|
+
for (const target of store.openedSyncTargets()) {
|
|
299
|
+
const screened = screenOutboundRows(target, pass); assertBinding();
|
|
300
|
+
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
301
|
+
}
|
|
302
|
+
const transport = await import("./shard-sync.ts"); assertBinding();
|
|
303
|
+
return transport.syncShardedMemory(store, opts);
|
|
304
|
+
} catch { return { ok: false, pushed: false, entries: 0, error: "sharded sync binding or policy unavailable or changed" }; }
|
|
305
|
+
}
|
|
169
306
|
// F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
|
|
170
307
|
const pass = outboundScreen(store.profile.home, opts.screen);
|
|
171
308
|
for (const target of store.openedSyncTargets()) {
|
|
172
309
|
const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
|
|
173
310
|
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
174
311
|
}
|
|
175
|
-
if (store.profile.sharded) return (await import("./shard-sync.ts")).syncShardedMemory(store, opts);
|
|
176
312
|
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
177
313
|
const dir = store.dir;
|
|
178
314
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|