@staix/agent-mem 0.20.3 → 0.22.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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +30 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +36 -3
  5. package/src/commands-table.ts +3 -0
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +31 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/commands.ts +3 -2
  11. package/src/memory/generation-index.ts +95 -18
  12. package/src/memory/generation-sync.ts +3 -0
  13. package/src/memory/generation.ts +33 -15
  14. package/src/memory/platform.ts +38 -3
  15. package/src/memory/policy.ts +28 -15
  16. package/src/memory/prune-publisher-lineage.ts +50 -12
  17. package/src/memory/public-prune-activation.ts +4 -4
  18. package/src/memory/public-prune-advance.ts +11 -5
  19. package/src/memory/public-prune-inputs.ts +17 -16
  20. package/src/memory/shard-sync-applications.ts +153 -0
  21. package/src/memory/shard-sync-format.ts +15 -0
  22. package/src/memory/shard-sync-graph.ts +6 -4
  23. package/src/memory/shard-sync-history.ts +238 -0
  24. package/src/memory/shard-sync.ts +66 -151
  25. package/src/memory/shard.ts +32 -6
  26. package/src/memory/store.ts +14 -1
  27. package/src/memory/sync-binding.ts +63 -0
  28. package/src/memory/sync-cadence.ts +2 -1
  29. package/src/memory/sync-placement-abandon.ts +142 -0
  30. package/src/memory/sync-placement-activation-locks.ts +28 -0
  31. package/src/memory/sync-placement-activation-path.ts +2 -0
  32. package/src/memory/sync-placement-activation-proof.ts +68 -0
  33. package/src/memory/sync-placement-activation-storage.ts +167 -0
  34. package/src/memory/sync-placement-activation.ts +185 -0
  35. package/src/memory/sync-placement-active.ts +75 -0
  36. package/src/memory/sync-placement-cache.ts +32 -0
  37. package/src/memory/sync-placement-capture.ts +373 -0
  38. package/src/memory/sync-placement-dependencies.ts +255 -0
  39. package/src/memory/sync-placement-effective-writers.ts +84 -0
  40. package/src/memory/sync-placement-format.ts +182 -0
  41. package/src/memory/sync-placement-git.ts +95 -0
  42. package/src/memory/sync-placement-guard.ts +104 -0
  43. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  44. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  45. package/src/memory/sync-placement-inspection.ts +240 -0
  46. package/src/memory/sync-placement-journal-format.ts +114 -0
  47. package/src/memory/sync-placement-journal-storage.ts +557 -0
  48. package/src/memory/sync-placement-journal.ts +160 -0
  49. package/src/memory/sync-placement-materialize.ts +149 -0
  50. package/src/memory/sync-placement-plan.ts +171 -0
  51. package/src/memory/sync-placement-prerequisites.ts +50 -0
  52. package/src/memory/sync-placement-projection.ts +79 -0
  53. package/src/memory/sync-placement-publication-plan.ts +189 -0
  54. package/src/memory/sync-placement-publication-storage.ts +87 -0
  55. package/src/memory/sync-placement-transport.ts +500 -0
  56. package/src/memory/sync-placement-writer-history.ts +278 -0
  57. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  58. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  59. package/src/memory/sync-placement-writer-roots.ts +167 -0
  60. package/src/memory/sync-placement-writers.ts +135 -0
  61. package/src/memory/sync-policy-inspection.ts +126 -0
  62. package/src/memory/sync-screen.ts +128 -33
  63. package/src/memory/sync.ts +19 -64
  64. package/src/setup.ts +101 -14
  65. package/src/sync-placement-writer-context.ts +136 -0
  66. package/src/ui/inbox-cache.ts +219 -0
  67. package/src/ui/inbox-worker-version.ts +14 -0
  68. package/src/ui/inbox-worker.ts +32 -0
  69. package/src/ui/inbox.ts +281 -0
  70. package/src/ui/panel.js +7 -3
  71. package/src/ui/plugin.ts +26 -53
@@ -1,3 +1,6 @@
1
+ import { readActivePlacementBinding } from "./sync-placement-active.ts";
2
+ import { validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
3
+ import { assertPlacementRuntime, SyncPlacementRuntimeError } from "./sync-placement-guard.ts";
1
4
  import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
2
5
  import { indexFileName } from "./index-file.ts";
3
6
  import { createHash, randomBytes } from "node:crypto";
@@ -84,31 +87,46 @@ export function captureShardDir(base: string, key: string, machine: string): str
84
87
  export const SHARDED_SCHEMA = "agent-mem.shards/v1";
85
88
  export const SHARDED_V2_SCHEMA = "agent-mem.shards/v2";
86
89
  export const SHARDED_V2_FIELDS = ["schema", "generation", "phase", "selectorRevision", "sourceGeneration", "sources", "machine", "legacyCommit", "deadLeasesRemoved"] as const;
87
- export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
90
+ export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA | "agent-mem.shards/v3"; generation: string; phase: "active"; selectorRevision: string }
88
91
 
89
92
  export function userShardDir(base: string): string {
90
93
  return join(base, "user");
91
94
  }
92
95
 
93
96
  type ShardedJournal = Record<string, unknown> & ShardedSelection;
97
+ /** Keep the existing active reader shape and the migration's captured-v1/v2 state distinct from v3. */
98
+ export function legacyShardMarkerShape(value: unknown): boolean {
99
+ if (value === undefined) return true;
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
101
+ const row = value as Record<string, unknown>;
102
+ return (row.schema === SHARDED_SCHEMA || row.schema === SHARDED_V2_SCHEMA) && validGeneration(row.generation)
103
+ && typeof row.phase === "string" && ["captured", "active"].includes(row.phase) && typeof row.selectorRevision === "string"
104
+ && (row.schema !== SHARDED_V2_SCHEMA || !Object.keys(row).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key)));
105
+ }
94
106
  function shardJournal(memoryDir: string): ShardedJournal {
95
107
  const value = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
108
+ if (value?.schema === "agent-mem.shards/v3") {
109
+ const profile = validatePlacementProfile(readProfileJSON(join(memoryDir, "profile.json")));
110
+ return validatePlacementShards(value, profile) as unknown as ShardedJournal;
111
+ }
96
112
  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");
97
113
  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");
98
114
  return value as ShardedJournal;
99
115
  }
100
116
  function compatibleShardAdvance(before: ShardedJournal, after: ShardedJournal): boolean {
101
- if (before.schema !== SHARDED_V2_SCHEMA || after.schema !== SHARDED_V2_SCHEMA) return false;
117
+ if (![SHARDED_V2_SCHEMA, "agent-mem.shards/v3"].includes(before.schema) || after.schema !== before.schema) return false;
102
118
  const { selectorRevision: _old, ...old } = before, { selectorRevision: _next, ...next } = after;
103
119
  return canonicalJSON(old) === canonicalJSON(next);
104
120
  }
105
121
  function shardSnapshot(memoryDir: string, deadline: number) {
122
+ assertPlacementRuntime(memoryDir);
106
123
  assertNoPublicPruneIntent(memoryDir, deadline);
107
124
  let journal = shardJournal(memoryDir);
108
125
  for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
109
126
  let selector: ReturnType<typeof readSelector>;
110
127
  try { selector = readSelector(memoryDir, deadline); }
111
128
  catch (error) {
129
+ if (error instanceof SyncPlacementRuntimeError) throw error;
112
130
  // The writer's spent wait budget or unavailable proof is never restarted here.
113
131
  if (error instanceof Error && error.message === "public prune view unavailable") throw error;
114
132
  const current = shardJournal(memoryDir);
@@ -120,8 +138,8 @@ function shardSnapshot(memoryDir: string, deadline: number) {
120
138
  if (!compatibleShardAdvance(journal, current)) throw new Error("curated shard selector changed");
121
139
  journal = current; continue;
122
140
  }
123
- if ((journal.schema === SHARDED_V2_SCHEMA) !== (selector.selector?.schema === "agent-mem.profile/v2")) throw new Error("curated shard selector changed");
124
- if (journal.schema === SHARDED_V2_SCHEMA && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
141
+ if ((journal.schema === SHARDED_V2_SCHEMA || journal.schema === "agent-mem.shards/v3") !== (selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3")) throw new Error("curated shard selector changed");
142
+ if ((journal.schema === SHARDED_V2_SCHEMA || journal.schema === "agent-mem.shards/v3") && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
125
143
  return { journal, selector };
126
144
  }
127
145
  throw new Error("curated shard selector changed");
@@ -135,6 +153,14 @@ export function readShardedSelection(memoryDir: string): ShardedSelection {
135
153
  * ponytail: same-user concurrent parent replacement needs descriptor-relative opens; fixed preflight does not claim that fence.
136
154
  */
137
155
  export function assertCuratedShardPaths(memoryDir: string, opts: { project?: string; user?: boolean }): void {
156
+ assertPlacementRuntime(memoryDir);
157
+ const selector = readProfileJSON(join(memoryDir, "profile.json")) as {schema?:unknown} | undefined;
158
+ if (selector?.schema === "agent-mem.profile/v3") {
159
+ const active = readActivePlacementBinding(memoryDir, selector, readProfileJSON(join(memoryDir, "shards.json")));
160
+ const keyHash = opts.user ? undefined : createHash("sha256").update(opts.project!).digest("hex");
161
+ if (!active || !active.activation.realms.some(realm => opts.user ? realm.scope === "user" : realm.scope === "project" && realm.keyHash === keyHash))
162
+ throw new Error("sync placement realm is outside the activated inventory; review its activation before opening it");
163
+ }
138
164
  const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
139
165
  const check = (path: string, directory: boolean) => {
140
166
  try {
@@ -169,10 +195,10 @@ export function resolveShardProfile(memoryDir: string, opts: { project?: string;
169
195
  const final = shardSnapshot(memoryDir, deadline), selector = final.selector;
170
196
  if (canonicalJSON(final.journal) !== canonicalJSON(initial.journal) && !compatibleShardAdvance(initial.journal, final.journal)
171
197
  || selector.revision !== final.journal.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
172
- if (selector.selector?.schema === "agent-mem.profile/v2") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
198
+ if (selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
173
199
  const legacyMainHome = join(dir, "legacy-main");
174
200
  const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
175
201
  return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
176
- ...(selector.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash } : {}),
202
+ ...((selector.selector?.schema === "agent-mem.profile/v2" || selector.selector?.schema === "agent-mem.profile/v3") ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash, ...(selector.selector.schema === "agent-mem.profile/v3" ? { publicationFormat: selector.selector.publicationFormat, placementHead: selector.selector.placementHead } : {}) } : {}),
177
203
  legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
178
204
  }
@@ -1,3 +1,4 @@
1
+ import { assertPlacementRuntime, placementSelectorRefusal, SyncPlacementRuntimeError } from "./sync-placement-guard.ts";
1
2
  import { assertFormatFloor } from "./formats.ts";
2
3
  import { readPublicPruneView } from "./public-prune-view.ts";
3
4
  import { INDEX_VERSION, indexFileName } from "./index-file.ts";
@@ -234,7 +235,10 @@ export class MemoryStore {
234
235
  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");
235
236
  const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
236
237
  assertFormatFloor(home, "curated");
238
+ const placementRefusal = placementSelectorRefusal(home);
239
+ if (placementRefusal) throw new SyncPlacementRuntimeError(placementRefusal);
237
240
  if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
241
+ if (bootstrap) assertPlacementRuntime(home);
238
242
  this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
239
243
  if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
240
244
  || (!this.profile.sharded && this.profile.dir !== generationDir(this.profile.home, this.profile.generation))
@@ -1561,6 +1565,14 @@ export class MemoryStore {
1561
1565
  });
1562
1566
  }
1563
1567
 
1568
+ /** Canonical generation outbound sources only; no refresh, rebind or derived-state write.
1569
+ * Call the returned bounded recheck after visiting all opened targets, before declaring completeness.
1570
+ */
1571
+ inspectOutboundRows(visit: (row: Record<string, unknown>) => void, limits: { maxBytes: number; maxRows: number }): () => void {
1572
+ if (!this.generationIndex) throw Object.assign(new Error("outbound generation input unavailable"), { code: "input-unavailable" });
1573
+ return this.generationIndex.inspectOutboundRows(visit, limits);
1574
+ }
1575
+
1564
1576
  /** Person-local diagnostic receipt; malformed legacy bodies are never included. */
1565
1577
  bridgeStatus(): { skippedRows: number } | undefined {
1566
1578
  return this.generationIndex ? { skippedRows: this.generationIndex.skippedRows() } : undefined;
@@ -1622,7 +1634,7 @@ function initializeShard(home: string, opts: { project?: string; user?: boolean
1622
1634
  writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
1623
1635
  const selected = readSelector(home).selector;
1624
1636
  const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
1625
- ...(selected?.schema === "agent-mem.profile/v2" ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash} : {}),
1637
+ ...((selected?.schema === "agent-mem.profile/v2" || selected?.schema === "agent-mem.profile/v3") ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash, ...(selected.schema === "agent-mem.profile/v3" ? {publicationFormat:selected.publicationFormat,placementHead:selected.placementHead} : {})} : {}),
1626
1638
  legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
1627
1639
  buildGenerationIndex(profile, sources);
1628
1640
  writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
@@ -1645,6 +1657,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1645
1657
  const missing = !existsSync(join(dir, "generation.json"));
1646
1658
  if (missing) {
1647
1659
  const selected = readSelector(home).selector;
1660
+ if (selected?.schema === "agent-mem.profile/v3") throw new Error("sync placement realm was not activated; review a complete realm inventory before opening a new curated shard");
1648
1661
  if (selected?.schema === "agent-mem.profile/v2") {
1649
1662
  const view = readPublicPruneView(home, selected.pruneInputSetHash, selected.generation), key = opts.user ? "user" : `project:${opts.project}`;
1650
1663
  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");
@@ -0,0 +1,63 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import type { MemoryStore } from "./store.ts";
5
+
6
+ /** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
7
+ export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
8
+ /** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
9
+ export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
10
+ function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
11
+ const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
12
+ if (!store.profile.sharded) return unavailable();
13
+ const uid = process.getuid?.(); if (uid === undefined) return unavailable();
14
+ 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;
15
+ const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
16
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
17
+ || stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
18
+ };
19
+ const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
20
+ const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
21
+ const directory = (dir: string): void => {
22
+ if (directories.has(dir)) return;
23
+ const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
24
+ if (dir !== home) directory(dirname(dir));
25
+ const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
26
+ emit(["directory", dir, String(before.dev), String(before.ino)]);
27
+ checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
28
+ };
29
+ // Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
30
+ const source = (file: string, privateMode = true): void => {
31
+ directory(dirname(file));
32
+ let before: BigIntStats;
33
+ try { before = lstatSync(file, { bigint: true }); }
34
+ catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
36
+ emit(["source", file, "absent"]);
37
+ checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
38
+ }
39
+ owned(before, false, privateMode);
40
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
41
+ try {
42
+ const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
43
+ const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
44
+ for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
45
+ const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
46
+ if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
47
+ emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
48
+ } finally { closeSync(fd); }
49
+ checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
50
+ };
51
+ try {
52
+ directory(home);
53
+ for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
54
+ const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
55
+ for (const target of targets) {
56
+ directory(target.dir);
57
+ emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
58
+ for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
59
+ }
60
+ for (const check of checks) check();
61
+ return digest.digest("hex");
62
+ } catch { return unavailable(); }
63
+ }
@@ -88,8 +88,9 @@ export function endLikeSignal(name: string): void {
88
88
  */
89
89
  function syncRepository(home: string): string | undefined {
90
90
  try {
91
- if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
92
91
  const { selector } = readSelector(home);
92
+ if (selector?.schema === "agent-mem.profile/v3") return join(home,"shard-sync-placement","objects.git","config");
93
+ if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
93
94
  if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
94
95
  return join(home, ".git", "config");
95
96
  } catch { return undefined; }
@@ -0,0 +1,142 @@
1
+ /** Person coordinator's precommit-only metadata cleanup. No rollback, source rewrite, renewal or package measurement. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, opendirSync, readSync, rmdirSync, unlinkSync, type BigIntStats } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { types } from "node:util";
6
+ import { canonicalJSON } from "./generation.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
9
+ import { placementEpochHash, SyncPlacementJournalError, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
10
+ import { validatePlacementActivationIntent, readPlacementActivationIntent, clearPlacementActivationIntent, PLACEMENT_ACTIVATION_INTENT_FILE, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
11
+ import { planSyncPlacementWriterRoots, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
12
+ import { syncPlacementWriterId } from "./sync-placement-writers.ts";
13
+ import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
14
+
15
+ export interface PlacementAbandonOptions { mutation: MutationHandle; assertCurrent(): void }
16
+ export class PlacementActivationAbandonError extends Error {
17
+ readonly code: "forward" | "metadata" | "bound";
18
+ constructor(code: "forward" | "metadata" | "bound" = "metadata") {
19
+ super(code === "forward"
20
+ ? "activation markers or active fence changed; use forward --resume with a compatible recovery release"
21
+ : code === "bound" ? "activation cleanup metadata exceeds the supported bound; inspect it with a compatible recovery release"
22
+ : "activation cleanup metadata is unsafe, changed or unknown; inspect it with a compatible recovery release before deleting anything");
23
+ this.name = "PlacementActivationAbandonError"; this.code = code;
24
+ }
25
+ }
26
+ function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
27
+ const bytes = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
28
+ const hash = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
29
+ const identity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode].map(String).join(":");
30
+ const stamp = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String).join(":");
31
+ function owned(s: BigIntStats, directory = false, links: readonly bigint[] = [1n], marker = false): void {
32
+ if (s.isSymbolicLink() || s.uid !== BigInt(process.getuid?.() ?? -1)
33
+ || (directory ? !s.isDirectory() || (s.mode & 0o077n) !== 0n : !s.isFile() || !links.includes(s.nlink) || (marker ? (s.mode & 0o022n) !== 0n : (s.mode & 0o777n) !== 0o600n))) fail();
34
+ }
35
+ interface Member { stat: BigIntStats; expected: Buffer }
36
+ interface State { files: Map<string, Member>; dirs: Map<string, BigIntStats> }
37
+ function readMember(file: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n], marker = false): Member | undefined {
38
+ const before = lstatSync(file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
39
+ owned(before, false, links, marker); if (before.size > BigInt(expected.length)) fail("bound");
40
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
41
+ try {
42
+ const opened = fstatSync(fd, { bigint: true }); if (stamp(opened) !== stamp(before)) fail("changed");
43
+ const data = Buffer.alloc(Number(opened.size) + 1); let at = 0;
44
+ while (at < data.length) { const n = readSync(fd, data, at, data.length - at, at); if (!n) break; at += n; }
45
+ if (at !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(file, { bigint: true })) !== stamp(opened)) fail("changed");
46
+ if ((!prefix && at !== expected.length) || !data.subarray(0, at).equals(expected.subarray(0, at))) fail("conflict");
47
+ return { stat: opened, expected };
48
+ } finally { closeSync(fd); }
49
+ }
50
+ function directory(path: string): BigIntStats | undefined {
51
+ const value = lstatSync(path, { bigint: true, throwIfNoEntry: false }); if (value) owned(value, true); return value;
52
+ }
53
+ function names(path: string, maximum: number): string[] {
54
+ const result: string[] = [], iterator = opendirSync(path); let size = 2;
55
+ try { for (;;) { const entry = iterator.readSync(); if (!entry) break; if (result.length >= maximum) fail("bound");
56
+ size += Buffer.byteLength(JSON.stringify(entry.name)) + 1; if (size > SYNC_SCREEN_MAX_BYTES) fail("bound"); result.push(entry.name);
57
+ } } finally { iterator.closeSync(); } return result.sort();
58
+ }
59
+ /** Called only after person approval and under activation's existing live profile/readers/sync/mutation fences. */
60
+ export function abandonPlacementActivation(homeInput: string, input: PlacementActivationIntent, options: PlacementAbandonOptions): void {
61
+ try {
62
+ const intent = validatePlacementActivationIntent(input), initialHome = lstatSync(homeInput, { bigint: true }); owned(initialHome, true);
63
+ const home = realPath(homeInput); if (home !== intent.home || identity(directory(home)!) !== identity(initialHome)) fail("changed");
64
+ const epochHash = placementEpochHash(intent.epoch), storage = join(home, "sync-placement"), epochs = join(storage, "epochs"), epochDir = join(epochs, epochHash);
65
+ const intentFile = join(home, PLACEMENT_ACTIVATION_INTENT_FILE), intentBytes = bytes(intent), intentTemp = intentFile + ".pending-" + hash(intentBytes);
66
+ const roots = intent.roots.map(root => {
67
+ const writerId = syncPlacementWriterId(root), writer = intent.plan.writers.find(value => value.writerId === writerId); if (!writer) fail("conflict");
68
+ return { root, writerId, version: writer.version, pin: writer.pin };
69
+ });
70
+ const locatorBytes = bytes(planSyncPlacementWriterRoots({ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, roots },
71
+ { epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, writers: intent.plan.writers }));
72
+ const locator = join(home, SYNC_PLACEMENT_WRITER_ROOTS_FILE), locatorTemp = locator + ".pending-" + hash(locatorBytes);
73
+ const expectedEpoch = new Map([["epoch.json", bytes(intent.epoch)], ["activation.json", bytes(intent.plan)]]);
74
+ let clearing = false;
75
+ const proof = () => {
76
+ assertMutationHandle(options.mutation, home); options.assertCurrent(); assertMutationHandle(options.mutation, home);
77
+ if (identity(directory(home)!) !== identity(initialHome)) fail("changed");
78
+ try {
79
+ if (!readMember(join(home, "profile.json"), Buffer.from(intent.previousProfile), false, [1n], true)
80
+ || !readMember(join(home, "shards.json"), Buffer.from(intent.previousShards), false, [1n], true)) throw new PlacementActivationAbandonError("forward");
81
+ } catch { throw new PlacementActivationAbandonError("forward"); }
82
+ if (directory(storage) && lstatSync(join(storage, "active.json"), { throwIfNoEntry: false })) throw new PlacementActivationAbandonError("forward");
83
+ const actual = readPlacementActivationIntent(home);
84
+ if (actual ? canonicalJSON(actual) !== canonicalJSON(intent) : !clearing) fail("changed");
85
+ };
86
+ const scan = (): State => {
87
+ const files = new Map<string, Member>(), dirs = new Map<string, BigIntStats>([[home, directory(home)!]]);
88
+ for (const path of [storage, epochs, epochDir]) { const stat = directory(path); if (stat) dirs.set(path, stat); }
89
+ if (dirs.has(storage)) for (const name of names(storage, 2)) if (name !== "epochs") fail("corrupt");
90
+ const knownTemps = new Set([intentTemp, locatorTemp]);
91
+ for (const name of names(home, SYNC_SCREEN_MAX_ROWS)) if (name.startsWith(PLACEMENT_ACTIVATION_INTENT_FILE + ".pending-") || name.startsWith(SYNC_PLACEMENT_WRITER_ROOTS_FILE + ".pending-"))
92
+ if (!knownTemps.has(join(home, name))) fail("corrupt");
93
+ const add = (path: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n]) => { const value = readMember(path, expected, prefix, links); if (value) files.set(path, value); return value; };
94
+ const final = add(locator, locatorBytes, false, [1n, 2n]), temp = add(locatorTemp, locatorBytes, true, [1n, 2n]);
95
+ if (final?.stat.nlink === 2n || temp?.stat.nlink === 2n) {
96
+ if (!final || !temp || final.stat.size !== BigInt(locatorBytes.length) || temp.stat.size !== BigInt(locatorBytes.length) || stamp(final.stat) !== stamp(temp.stat)) fail("corrupt");
97
+ }
98
+ if (dirs.has(epochDir)) for (const name of names(epochDir, 3)) { const expected = expectedEpoch.get(name); if (!expected) fail("corrupt"); add(join(epochDir, name), expected, true); }
99
+ const current = add(intentFile, intentBytes, false, [1n, 2n]), companion = add(intentTemp, intentBytes, false, [1n, 2n]);
100
+ if (current?.stat.nlink === 2n || companion) {
101
+ if (!current || !companion || current.stat.nlink !== 2n || companion.stat.nlink !== 2n || stamp(current.stat) !== stamp(companion.stat)) fail("corrupt");
102
+ }
103
+ if (!current && !clearing) fail("changed"); return { files, dirs };
104
+ };
105
+ proof(); let state = scan();
106
+ const check = () => {
107
+ proof(); const next = scan();
108
+ const expectedFiles = [...state.files.keys()].filter(path => !clearing || path !== intentFile).sort(), foundFiles = [...next.files.keys()].filter(path => !clearing || path !== intentFile).sort();
109
+ if (canonicalJSON(expectedFiles) !== canonicalJSON(foundFiles) || canonicalJSON([...state.dirs.keys()].sort()) !== canonicalJSON([...next.dirs.keys()].sort())) fail("changed");
110
+ for (const [path, before] of state.files) { const after = next.files.get(path); if (clearing && path === intentFile && !after) continue; if (!after || stamp(before.stat) !== stamp(after.stat)) fail("changed"); }
111
+ for (const [path, before] of state.dirs) if (identity(before) !== identity(next.dirs.get(path)!)) fail("changed");
112
+ };
113
+ const sync = (path: string) => {
114
+ check(); const expected = state.dirs.get(path); if (!expected) fail("changed"); const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
115
+ try { if (identity(fstatSync(fd, { bigint: true })) !== identity(expected)) fail("changed"); fsyncSync(fd); } finally { closeSync(fd); } check();
116
+ };
117
+ const remove = (path: string) => {
118
+ if (!state.files.has(path)) return; check(); const before = state.files.get(path)!; unlinkSync(path); state.files.delete(path);
119
+ for (const [peer, item] of state.files) if (item.stat.dev === before.stat.dev && item.stat.ino === before.stat.ino) {
120
+ const now = lstatSync(peer, { bigint: true }); owned(now);
121
+ if (before.stat.nlink !== 2n || now.nlink !== 1n || now.dev !== item.stat.dev || now.ino !== item.stat.ino || now.uid !== item.stat.uid || now.mode !== item.stat.mode || now.size !== item.stat.size || now.mtimeNs !== item.stat.mtimeNs) fail("changed");
122
+ item.stat = now;
123
+ }
124
+ sync(dirname(path));
125
+ };
126
+ remove(locatorTemp); remove(locator);
127
+ for (const name of ["activation.json", "epoch.json"]) remove(join(epochDir, name));
128
+ if (state.dirs.has(epochDir)) { check(); if (names(epochDir, 1).length) fail("changed"); rmdirSync(epochDir); state.dirs.delete(epochDir); sync(epochs); }
129
+ remove(intentTemp);
130
+ // A prior owner may have unlinked a member or removed this epoch before its directory fsync.
131
+ // Reassert every retained parent before the intent can acknowledge that cleanup as complete.
132
+ for (const path of [epochs, storage, home]) if (state.dirs.has(path)) sync(path);
133
+ check(); clearing = true;
134
+ clearPlacementActivationIntent(home, intent, { mutation: options.mutation, assertCurrent: check }); state.files.delete(intentFile); check();
135
+ } catch (error) {
136
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementActivationAbandonError) throw error;
137
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
138
+ const value = Object.getOwnPropertyDescriptor(error, "code"); if (value && "value" in value && ["invalid", "corrupt", "bound", "conflict", "pending", "changed", "authorization", "policy", "local", "busy"].includes(value.value)) throw new PlacementActivationAbandonError(value.value === "bound" ? "bound" : "metadata");
139
+ }
140
+ throw new PlacementActivationAbandonError();
141
+ }
142
+ }
@@ -0,0 +1,28 @@
1
+ /** Shared person-migration fences. No serialized value grants ownership. */
2
+ import { lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { withProfileLock } from "./generation.ts";
5
+ import { acquireMutation, type MutationHandle } from "./mutation.ts";
6
+ import { generationSyncLock } from "./generation-sync.ts";
7
+ import { assertNoLiveReaders } from "./reader-lease.ts";
8
+ import { createPlacementActivationOwner } from "./sync-placement-activation-proof.ts";
9
+ export class PlacementInventoryFenceError extends Error{
10
+ constructor(readonly causeClass:"readers"|"busy"){super(`Sync placement migration is ${causeClass}; close cooperating readers/writers, then run amem migrate --sync-placement --dry-run before retrying.`);this.name="PlacementInventoryFenceError";}
11
+ }
12
+ function fail(cause:"readers"|"busy"):never{throw new PlacementInventoryFenceError(cause);}
13
+ export async function withPlacementActivationFences<T>(home:string,directories:readonly string[],work:(mutation:MutationHandle,owner:ReturnType<typeof createPlacementActivationOwner>)=>Promise<T>):Promise<T> {
14
+ return withProfileLock(home,async()=>{
15
+ const releases:(()=>void)[]=[];
16
+ try {
17
+ const transports=["shard-sync","shard-sync-placement"].map(name=>join(home,name)).filter(dir=>lstatSync(dir,{throwIfNoEntry:false})!==undefined);
18
+ const dirs=[...new Set([home,...directories,...transports])].sort();let mutation:MutationHandle|undefined;
19
+ for(const dir of dirs){
20
+ try{assertNoLiveReaders(dir,{includeShards:true});}catch{fail("readers");}
21
+ const handle=acquireMutation(dir);if(!handle)fail("busy");releases.push(handle);if(dir===home)mutation=handle;
22
+ }
23
+ if(!mutation)fail("busy");
24
+ for(const dir of [home,...transports]){const release=generationSyncLock(dir);if(!release)fail("busy");releases.push(release);}
25
+ const owner=createPlacementActivationOwner(home,mutation);return await work(mutation,owner);
26
+ }finally{for(const release of releases.reverse())release();}
27
+ });
28
+ }
@@ -0,0 +1,2 @@
1
+ /** Pending coordinator metadata, outside the closed placement-journal namespace. */
2
+ export const PLACEMENT_ACTIVATION_INTENT = "sync-placement-activation.json";
@@ -0,0 +1,68 @@
1
+ /** Internal activation-owner proof. No serialized object or boolean grants lock bypass authority. */
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJSON, hashJSON } from "./generation.ts";
5
+ import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
6
+ import { processSignature, realPath } from "./platform.ts";
7
+ import type { PlacementActivationMarkerSnapshot } from "./sync-placement-capture.ts";
8
+ import { readPlacementActivationIntent, validatePlacementActivationIntent, placementActivationFinalMarkers, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
9
+
10
+ declare const ownerBrand: unique symbol, captureBrand: unique symbol;
11
+ export interface PlacementActivationOwner { readonly [ownerBrand]: true }
12
+ export interface PlacementActivationCaptureProof { readonly [captureBrand]: true }
13
+ interface Owner { home: string; mutation: MutationHandle; root: BigIntStats; lock: string; lockStamp: string }
14
+ const owners = new WeakMap<PlacementActivationOwner, Owner>();
15
+ const captures = new WeakMap<PlacementActivationCaptureProof, { owner: PlacementActivationOwner; intent: PlacementActivationIntent }>();
16
+ const stamp = (s: BigIntStats) => [s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
17
+ function fail(): never { throw Error("Sync placement activation ownership or retained evidence changed; close cooperating writers and run amem migrate --sync-placement --resume after person inspection."); }
18
+ function lockBytes(home: string): { text: string; stamp: string } {
19
+ const file = join(home,"profile.lock"), before = lstatSync(file,{bigint:true});
20
+ if (!before.isFile() || before.isSymbolicLink() || before.uid !== BigInt(process.getuid?.() ?? -1) || (before.mode & 0o077n) !== 0n || before.nlink !== 1n || before.size < 1n || before.size > 4096n) fail();
21
+ const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
22
+ try {
23
+ const opened = fstatSync(fd,{bigint:true}); if (stamp(opened) !== stamp(before)) fail();
24
+ const out = Buffer.alloc(Number(opened.size)); let at = 0;
25
+ while (at < out.length) { const n = readSync(fd,out,at,out.length-at,at); if (!n) fail(); at += n; }
26
+ if (stamp(fstatSync(fd,{bigint:true})) !== stamp(opened) || stamp(lstatSync(file,{bigint:true})) !== stamp(opened)) fail();
27
+ return { text: new TextDecoder("utf-8",{fatal:true}).decode(out), stamp: stamp(opened) };
28
+ } finally { closeSync(fd); }
29
+ }
30
+ export function createPlacementActivationOwner(homeInput: string, mutation: MutationHandle): PlacementActivationOwner {
31
+ try {
32
+ const home = realPath(homeInput); assertMutationHandle(mutation,home);
33
+ const root = lstatSync(home,{bigint:true}), lock = lockBytes(home), value = JSON.parse(lock.text) as Record<string,unknown>;
34
+ if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== BigInt(process.getuid?.() ?? -1) || (root.mode & 0o077n) !== 0n
35
+ || Object.keys(value).sort().join() !== "pid,signature,token" || value.pid !== process.pid || value.signature !== processSignature(process.pid)
36
+ || typeof value.signature !== "string" || !value.signature || typeof value.token !== "string" || !/^[a-f0-9]{32}$/.test(value.token)) fail();
37
+ const token = Object.freeze({}) as PlacementActivationOwner;
38
+ owners.set(token,{home,mutation,root,lock:lock.text,lockStamp:lock.stamp}); return token;
39
+ } catch { return fail(); }
40
+ }
41
+ export function assertPlacementActivationOwner(token: PlacementActivationOwner, home?: string): void {
42
+ try {
43
+ const owner = owners.get(token); if (!owner) fail(); assertMutationHandle(owner.mutation,owner.home);
44
+ if(home!==undefined&&realPath(home)!==owner.home)fail();
45
+ const root = lstatSync(owner.home,{bigint:true}), lock = lockBytes(owner.home);
46
+ if (!root.isDirectory() || root.isSymbolicLink() || root.dev !== owner.root.dev || root.ino !== owner.root.ino || root.uid !== owner.root.uid || (root.mode & 0o077n) !== 0n
47
+ || lock.text !== owner.lock || lock.stamp !== owner.lockStamp) fail();
48
+ assertMutationHandle(owner.mutation,owner.home);
49
+ } catch { fail(); }
50
+ }
51
+ export function createPlacementActivationCaptureProof(owner: PlacementActivationOwner, input: PlacementActivationIntent): PlacementActivationCaptureProof {
52
+ assertPlacementActivationOwner(owner);
53
+ const state = owners.get(owner)!;
54
+ const intent = validatePlacementActivationIntent(input), actual = readPlacementActivationIntent(state.home);
55
+ if (!intent || !actual || canonicalJSON(intent) !== canonicalJSON(actual)
56
+ || actual.home !== state.home || actual.epoch.homeHash !== hashJSON([state.home,String(state.root.dev),String(state.root.ino),String(state.root.uid)])) fail();
57
+ const token = Object.freeze({}) as PlacementActivationCaptureProof; captures.set(token,{owner,intent:actual}); return token;
58
+ }
59
+ export function activationSourceMarkers(token: PlacementActivationCaptureProof, home: string, markers: { profileText: string; shardsText: string; profileStamp: readonly string[]; shardsStamp: readonly string[] }): PlacementActivationMarkerSnapshot {
60
+ const scope = captures.get(token); if (!scope) fail(); assertPlacementActivationOwner(scope.owner);
61
+ const state = owners.get(scope.owner)!, actual = readPlacementActivationIntent(home), intent = scope.intent;
62
+ if (home !== state.home || !actual || canonicalJSON(actual) !== canonicalJSON(intent)) fail();
63
+ const { profileText, shardsText } = markers;
64
+ const final = placementActivationFinalMarkers(intent), oldProfile = profileText === intent.previousProfile, oldShards = shardsText === intent.previousShards;
65
+ if ((!oldProfile && profileText !== final.profile) || (!oldShards && shardsText !== final.shards) || !oldProfile && shardsText !== final.shards) fail();
66
+ if (oldProfile && canonicalJSON(markers.profileStamp) !== canonicalJSON(intent.profileStamp) || oldShards && canonicalJSON(markers.shardsStamp) !== canonicalJSON(intent.shardsStamp)) fail();
67
+ return { profileText:intent.previousProfile,shardsText:intent.previousShards,profileStamp:[...intent.profileStamp],shardsStamp:[...intent.shardsStamp] };
68
+ }