@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.
@@ -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 interface ShardedSelection { schema: typeof SHARDED_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
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 value = readProfileJSON(join(memoryDir, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
91
- if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
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 selection = readShardedSelection(memoryDir), generation = selection.generation;
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 selector = readSelector(memoryDir);
131
- if (selector.revision !== selection.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
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
  }
@@ -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?: () => void;
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 db = openedDb = new Database(join(this.dir, "index.db"), { create: true });
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
- // #15 T5: the one gate every recall path shares, checked before a shard can be created. The user shard has no lifecycle.
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) initializeShard(home, opts);
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. */
@@ -1,5 +1,7 @@
1
- import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
2
- import { join } from "node:path";
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 = 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 } : {}) });
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 });