@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
@@ -0,0 +1,278 @@
1
+ /** Private append-only writer inventory history. It never measures installs or grants activation authority. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, openSync, opendirSync, readSync, unlinkSync, writeSync, type BigIntStats } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { types } from "node:util";
6
+ import { canonicalJSON, hashJSON } from "./generation.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
9
+ import {
10
+ WRITER_REVISION_MAX_BYTES, WRITER_COMMIT_MAX_BYTES, WRITER_RENEWAL_INTENT_MAX_BYTES, WRITER_REVISION_LIMIT,
11
+ WRITER_COMMIT_LOG, WRITER_RENEWAL_INTENT_FILE, writerRevisionFile, writerInventoryGenesis,
12
+ validateWriterRevision, validateWriterCommit, validateWriterRenewalIntent, createWriterCommit,
13
+ SharedWriterRevisionError, type WriterRevision, type WriterCommit, type WriterRenewalIntent,
14
+ } from "./sync-placement-writer-revision-format.ts";
15
+
16
+ export interface WriterHistoryBinding { epochHash: string; activationHash: string; homeHash: string; generation: string; initialLocatorHash?: string }
17
+ export type WriterHistoryPhase = "intent-started" | "intent-written" | "intent-fsync" | "intent-published" | "revision-written" | "revision-fsync" | "commit-started" | "commit-written" | "commit-fsync" | "intent-cleared";
18
+ export interface WriterHistoryOptions { mutation: MutationHandle; assertCurrent(): void; phase?(phase: WriterHistoryPhase): void }
19
+ export interface WriterHistoryRead {
20
+ current?: WriterRevision; commits: WriterCommit[]; anchors: { sequence: number; headHash: string }[];
21
+ sequence: number; headHash: string; logBytes: number; logHash: string; pending?: WriterRenewalIntent; appendStarted: boolean; assertCurrent(): void;
22
+ }
23
+ const encode = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
24
+ const sha = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
25
+ const stamp = (s: BigIntStats) => [s.dev,s.ino,s.uid,s.mode,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":");
26
+ const identity = (s: BigIntStats) => [s.dev,s.ino,s.uid,s.mode].map(String).join(":");
27
+ function fail(code: "invalid" | "bound" | "changed" | "pending" | "staging" = "invalid"): never { throw new SharedWriterRevisionError(code); }
28
+ function safe<T>(work: () => T): T {
29
+ try { return work(); } catch (error) {
30
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SharedWriterRevisionError) {
31
+ const code=Object.getOwnPropertyDescriptor(error,"code");
32
+ if(code&&"value"in code&&["invalid","bound","changed","pending","staging","cap"].includes(code.value))throw new SharedWriterRevisionError(code.value);
33
+ }
34
+ return fail();
35
+ }
36
+ }
37
+ function owned(s: BigIntStats, dir = false, links: readonly bigint[] = [1n]): void {
38
+ if (s.isSymbolicLink() || s.uid !== BigInt(process.getuid?.() ?? -1)
39
+ || (dir ? !s.isDirectory() || (s.mode & 0o077n) !== 0n : !s.isFile() || !links.includes(s.nlink) || (s.mode & 0o777n) !== 0o600n)) fail();
40
+ }
41
+ function checkedDir(file: string): BigIntStats { const s = lstatSync(file,{bigint:true}); owned(s,true); return s; }
42
+ interface FileRead { bytes: Buffer; stat: BigIntStats }
43
+ function read(file: string, max: number, checks: (() => void)[] = [], links: readonly bigint[] = [1n]): FileRead | undefined {
44
+ const before = lstatSync(file,{bigint:true,throwIfNoEntry:false});
45
+ if (!before) { checks.push(() => { if (lstatSync(file,{throwIfNoEntry:false})) fail("changed"); }); return undefined; }
46
+ owned(before,false,links); if (before.size > BigInt(max)) fail("bound");
47
+ const fd = openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
48
+ try {
49
+ const opened = fstatSync(fd,{bigint:true}); owned(opened,false,links); if (stamp(opened)!==stamp(before)) fail("changed");
50
+ const buffer = Buffer.alloc(Number(opened.size)+1); let at=0;
51
+ while (at<buffer.length) { const n=readSync(fd,buffer,at,buffer.length-at,at); if (!n) break; at+=n; }
52
+ if (at!==Number(opened.size) || stamp(fstatSync(fd,{bigint:true}))!==stamp(opened) || stamp(lstatSync(file,{bigint:true}))!==stamp(opened)) fail("changed");
53
+ checks.push(() => { const now=lstatSync(file,{bigint:true}); owned(now,false,links); if(stamp(now)!==stamp(opened))fail("changed"); });
54
+ return {bytes:buffer.subarray(0,at),stat:opened};
55
+ } finally { closeSync(fd); }
56
+ }
57
+ function decode<T>(bytes: Buffer, validate: (value: unknown) => T): T {
58
+ const text = new TextDecoder("utf-8",{fatal:true}).decode(bytes), result=validate(JSON.parse(text));
59
+ if (!bytes.equals(encode(result))) fail(); return result;
60
+ }
61
+ function binding(value: WriterHistoryBinding): WriterHistoryBinding {
62
+ if (!value || typeof value!=="object" || types.isProxy(value) || Object.getOwnPropertySymbols(value).length) fail();
63
+ const descriptors=Object.getOwnPropertyDescriptors(value), keys=["epochHash","activationHash","homeHash","generation"];
64
+ if(Object.keys(descriptors).some(key=>![...keys,"initialLocatorHash"].includes(key)))fail();
65
+ const result: Record<string,string>={};
66
+ for(const key of keys){const d=descriptors[key];if(!d||!("value"in d)||!d.enumerable||typeof d.value!=="string"||d.value.length!==(key==="generation"?32:64)||!/^[a-f0-9]+$/.test(d.value))fail();result[key]=d.value;}
67
+ const locator=descriptors.initialLocatorHash;if(locator){if(!("value"in locator)||!locator.enumerable||typeof locator.value!=="string"||locator.value.length!==64||!/^[a-f0-9]+$/.test(locator.value))fail();result.initialLocatorHash=locator.value;}
68
+ return result as unknown as WriterHistoryBinding;
69
+ }
70
+ interface Paths { home: string; epoch: string; intent: string; log: string; dirs: [string,BigIntStats][] }
71
+ function paths(homeInput: string, input: WriterHistoryBinding): Paths {
72
+ const home=realPath(homeInput), original=checkedDir(homeInput), root=checkedDir(home);
73
+ if(identity(original)!==identity(root)||hashJSON([home,String(root.dev),String(root.ino),String(root.uid)])!==input.homeHash)fail("changed");
74
+ const dirs:[string,BigIntStats][]=[[home,root]];
75
+ let current=home;for(const part of ["sync-placement","epochs",input.epochHash]){current=join(current,part);dirs.push([current,checkedDir(current)]);}
76
+ return {home,epoch:current,intent:join(home,WRITER_RENEWAL_INTENT_FILE),log:join(current,WRITER_COMMIT_LOG),dirs};
77
+ }
78
+ function manifest(p: Paths): string[] {
79
+ const iterator=opendirSync(p.epoch), names:string[]=[]; let count=0;
80
+ try {for(;;){const entry=iterator.readSync();if(!entry)break;if(++count>255)fail("bound");
81
+ if(entry.name.startsWith("writer-")){if(entry.name!==WRITER_COMMIT_LOG&&!/^writer-revision-\d{5}\.json$/.test(entry.name))fail();names.push(entry.name);}
82
+ }} finally {iterator.closeSync();} return names.sort();
83
+ }
84
+ function boundRevision(revision: WriterRevision,b:WriterHistoryBinding):void {
85
+ if(revision.epochHash!==b.epochHash||revision.activationHash!==b.activationHash||revision.homeHash!==b.homeHash||revision.generation!==b.generation||b.initialLocatorHash!==undefined&&revision.initialLocatorHash!==b.initialLocatorHash)fail();
86
+ }
87
+ function intentBinding(intent:WriterRenewalIntent):WriterHistoryBinding {const r=intent.revision;return {epochHash:r.epochHash,activationHash:r.activationHash,homeHash:r.homeHash,generation:r.generation};}
88
+ /** Exact basename reserved alongside the final outside intent during atomic publication. */
89
+ export function writerRenewalIntentTempName(value:WriterRenewalIntent):string {return WRITER_RENEWAL_INTENT_FILE+".pending-"+sha(encode(validateWriterRenewalIntent(value)));}
90
+ export function isWriterRenewalIntentTempName(name:string):boolean {const match=new RegExp("^"+WRITER_RENEWAL_INTENT_FILE.replaceAll(".","\\.")+"\\.pending-[a-f0-9]{64}$").exec(name);return !!match&&match[0]===name;}
91
+ function intentTemps(p:Paths):string[]{
92
+ const iterator=opendirSync(p.home),out:string[]=[];let count=0,bytes=0;
93
+ try{for(;;){const item=iterator.readSync();if(!item)break;if(++count>20000||(bytes+=Buffer.byteLength(item.name))>8*1024*1024)fail("bound");
94
+ if(item.name.startsWith(WRITER_RENEWAL_INTENT_FILE+".pending-")){if(!isWriterRenewalIntentTempName(item.name))fail();if(out.length>=256)fail("bound");out.push(item.name);}
95
+ }}finally{iterator.closeSync();}return out.sort();
96
+ }
97
+ function readIntent(p:Paths,checks:(()=>void)[],expected?:WriterRenewalIntent,ignoreUnpublishedStaging=false):{file?:FileRead;intent?:WriterRenewalIntent;temp?:string}{
98
+ const names=intentTemps(p);if(!ignoreUnpublishedStaging)checks.push(()=>{if(canonicalJSON(intentTemps(p))!==canonicalJSON(names))fail("changed");});
99
+ const final=read(p.intent,WRITER_RENEWAL_INTENT_MAX_BYTES,checks,[1n,2n]);
100
+ if(!final&&names.length){if(ignoreUnpublishedStaging)return{};fail("staging");}
101
+ if(names.length>1)fail();const temp=names[0]?join(p.home,names[0]):undefined;
102
+ const staging=temp?read(temp,WRITER_RENEWAL_INTENT_MAX_BYTES,checks,[1n,2n]):undefined;
103
+ if(!final&&!staging)return{};
104
+ const file=final??staging!;
105
+ let intent:WriterRenewalIntent;
106
+ if(expected){const full=encode(expected);if(file.bytes.length>full.length||!file.bytes.equals(full.subarray(0,file.bytes.length)))fail();intent=expected;}
107
+ else {try{intent=decode(file.bytes,validateWriterRenewalIntent);}catch{fail("pending");}}
108
+ const full=encode(intent),known=join(p.home,writerRenewalIntentTempName(intent));if(temp&&temp!==known)fail();
109
+ if(final){if(!final.bytes.equals(full))fail();if(staging){if(final.stat.nlink!==2n||staging.stat.nlink!==2n||stamp(final.stat)!==stamp(staging.stat)||!staging.bytes.equals(full))fail();}else if(final.stat.nlink!==1n)fail();}
110
+ else if(staging!.stat.nlink!==1n)fail();
111
+ return{file,intent,temp};
112
+ }
113
+ function publishIntent(p:Paths,intent:WriterRenewalIntent,opts:WriterHistoryOptions):void{
114
+ const before=readIntent(p,[],intent),full=encode(intent),temp=join(p.home,writerRenewalIntentTempName(intent));
115
+ if(!before.file||!lstatSync(p.intent,{throwIfNoEntry:false})){
116
+ complete(p,temp,full,WRITER_RENEWAL_INTENT_MAX_BYTES,opts,"intent-written","intent-fsync",{at:0,phase:"intent-started"});proof(p,opts);
117
+ const staged=readFileExpected(temp,full,WRITER_RENEWAL_INTENT_MAX_BYTES);proof(p,opts);
118
+ if(stamp(lstatSync(temp,{bigint:true}))!==stamp(staged.stat))fail("changed");linkSync(temp,p.intent);syncDir(p,p.home,opts);
119
+ }
120
+ const current=readIntent(p,[],intent);if(!current.file||!current.file.bytes.equals(full))fail();
121
+ if(current.temp){proof(p,opts);const pair=readIntent(p,[],intent);if(pair.temp!==temp||!pair.file||stamp(pair.file.stat)!==stamp(current.file.stat))fail("changed");unlinkSync(temp);syncDir(p,p.home,opts);}
122
+ const final=readFileExpected(p.intent,full,WRITER_RENEWAL_INTENT_MAX_BYTES),fd=openSync(p.intent,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
123
+ try{if(stamp(fstatSync(fd,{bigint:true}))!==stamp(final.stat))fail("changed");proof(p,opts);fsyncSync(fd);if(stamp(lstatSync(p.intent,{bigint:true}))!==stamp(final.stat))fail("changed");}finally{closeSync(fd);}syncDir(p,p.home,opts);
124
+ if(stamp(lstatSync(p.intent,{bigint:true}))!==stamp(final.stat))fail("changed");
125
+ }
126
+ interface Snapshot { result:WriterHistoryRead; paths:Paths; log:Buffer; intentBytes?:Buffer }
127
+ function snapshot(home:string,b:WriterHistoryBinding,allowPending:boolean,expected?:WriterRenewalIntent,ignoreUnpublishedStaging=false):Snapshot {
128
+ const p=paths(home,b), checks:(()=>void)[]=[], names=manifest(p);
129
+ for(const [dir,stat]of p.dirs)checks.push(()=>{if(identity(checkedDir(dir))!==identity(stat))fail("changed");});
130
+ checks.push(()=>{if(canonicalJSON(manifest(p))!==canonicalJSON(names))fail("changed");});
131
+ const intentRead=readIntent(p,checks,expected,ignoreUnpublishedStaging),intentFile=intentRead.file;let pending=intentRead.intent;
132
+ if(pending){if(!allowPending)fail("pending");if(pending.home!==p.home)fail();boundRevision(pending.revision,b);}
133
+ else if(expected)fail("pending");
134
+ const logFile=read(p.log,WRITER_COMMIT_MAX_BYTES,checks), log=logFile?.bytes??Buffer.alloc(0);
135
+ const commits:WriterCommit[]=[],anchors:{sequence:number;headHash:string}[]=[];let current:WriterRevision|undefined;
136
+ let head=writerInventoryGenesis(b.epochHash,b.activationHash),previous=0,completeBytes=log.length;
137
+ const lastLF=log.lastIndexOf(10);if(lastLF!==log.length-1){if(!pending)fail("pending");completeBytes=lastLF+1;}
138
+ let at=0;
139
+ while(at<completeBytes){const end=log.indexOf(10,at);if(end<at)fail();const commit=decode(log.subarray(at,end+1),validateWriterCommit);at=end+1;
140
+ if(commits.length>=WRITER_REVISION_LIMIT)fail("bound");if(commit.epochHash!==b.epochHash||commit.sequence!==commits.length+1||commit.previousHash!==head)fail();
141
+ const file=join(p.epoch,writerRevisionFile(commit.sequence)),raw=read(file,WRITER_REVISION_MAX_BYTES,checks);if(!raw)fail();
142
+ const revision=decode(raw.bytes,validateWriterRevision);boundRevision(revision,b);
143
+ if(revision.sequence!==commit.sequence||revision.previousHash!==head||canonicalJSON(createWriterCommit(revision))!==canonicalJSON(commit))fail();
144
+ if(current&&revision.initialLocatorHash!==current.initialLocatorHash)fail();
145
+ if(revision.journal.sequence<previous||revision.journal.sequence===previous&&anchors.length&&revision.journal.headHash!==anchors.at(-1)!.headHash)fail();
146
+ anchors.push({...revision.journal});previous=revision.journal.sequence;commits.push(commit);current=revision;head=commit.seal;
147
+ }
148
+ const expectedNames=new Set(commits.map(commit=>writerRevisionFile(commit.sequence)));if(logFile)expectedNames.add(WRITER_COMMIT_LOG);
149
+ if(pending){
150
+ const start=pending.previousLogBytes, next=pending.revision.sequence;
151
+ if(start>log.length||sha(log.subarray(0,start))!==pending.previousLogHash||start>0&&log[start-1]!==10)fail();
152
+ const beforeCount=commits.filter(commit=>commit.sequence<next).length;
153
+ if(beforeCount!==next-1||commits.length<next-1||commits.length>next)fail();
154
+ const beforeHead=next===1?writerInventoryGenesis(b.epochHash,b.activationHash):commits[next-2]!.seal;
155
+ if(pending.revision.previousHash!==beforeHead||start!==committedPrefixLength(log,next-1))fail();
156
+ const tail=log.subarray(start),line=encode(pending.commit);if(tail.length>line.length||!tail.equals(line.subarray(0,tail.length)))fail();
157
+ const candidateFile=join(p.epoch,writerRevisionFile(next)),candidate=read(candidateFile,WRITER_REVISION_MAX_BYTES,checks),full=encode(pending.revision);
158
+ if(candidate){if(candidate.bytes.length>full.length||!candidate.bytes.equals(full.subarray(0,candidate.bytes.length)))fail();expectedNames.add(writerRevisionFile(next));}
159
+ if(tail.length&&(!candidate||!candidate.bytes.equals(full)))fail();
160
+ if(current&&current.initialLocatorHash!==pending.revision.initialLocatorHash)fail();
161
+ if(next>1){const anchor=anchors[next-2]!;if(pending.revision.journal.sequence<anchor.sequence||pending.revision.journal.sequence===anchor.sequence&&pending.revision.journal.headHash!==anchor.headHash)fail();}
162
+ }else if(completeBytes!==log.length||logFile&&commits.length===0)fail("pending");
163
+ if(canonicalJSON([...expectedNames].sort())!==canonicalJSON(names))fail();
164
+ const assertCurrent=()=>safe(()=>{for(const check of checks)check();});assertCurrent();
165
+ return{paths:p,log,intentBytes:intentFile?.bytes,result:{current,commits,anchors,sequence:commits.length,headHash:head,logBytes:log.length,logHash:sha(log),pending,appendStarted:!!pending&&log.length>pending.previousLogBytes,assertCurrent}};
166
+ }
167
+ function committedPrefixLength(log:Buffer,count:number):number {let at=0;for(let n=0;n<count;n++){const lf=log.indexOf(10,at);if(lf<0)fail();at=lf+1;}return at;}
168
+ export function readWriterHistory(home:string,input:WriterHistoryBinding,options:{allowPending?:boolean}={}):WriterHistoryRead {
169
+ return safe(()=>snapshot(home,binding(input),options.allowPending===true).result);
170
+ }
171
+ function proof(p:Paths,opts:WriterHistoryOptions):void {
172
+ assertMutationHandle(opts.mutation,p.home);opts.assertCurrent();assertMutationHandle(opts.mutation,p.home);
173
+ for(const [dir,stat]of p.dirs)if(identity(checkedDir(dir))!==identity(stat))fail("changed");
174
+ }
175
+ function syncDir(p:Paths,dir:string,opts:WriterHistoryOptions):void {
176
+ proof(p,opts);const stat=checkedDir(dir),fd=openSync(dir,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW);
177
+ try{if(identity(fstatSync(fd,{bigint:true}))!==identity(stat))fail("changed");proof(p,opts);fsyncSync(fd);}finally{closeSync(fd);}proof(p,opts);
178
+ }
179
+ function complete(p:Paths,file:string,full:Buffer,max:number,opts:WriterHistoryOptions,written:WriterHistoryPhase,synced:WriterHistoryPhase,start?:{at:number;phase:WriterHistoryPhase}):void {
180
+ if(full.length>max)fail("bound");proof(p,opts);const prior=read(file,max);if(prior&&(prior.bytes.length>full.length||!prior.bytes.equals(full.subarray(0,prior.bytes.length))))fail();
181
+ let installed:BigIntStats;
182
+ const fd=openSync(file,constants.O_WRONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK|(prior?0:constants.O_CREAT|constants.O_EXCL),0o600);
183
+ try{
184
+ const opened=fstatSync(fd,{bigint:true});owned(opened);if(prior?stamp(opened)!==stamp(prior.stat):opened.size!==0n)fail("changed");
185
+ let observed=opened;proof(p,opts);let at=prior?.bytes.length??0;
186
+ while(at<full.length){proof(p,opts);if(stamp(fstatSync(fd,{bigint:true}))!==stamp(observed)||stamp(lstatSync(file,{bigint:true}))!==stamp(observed))fail("changed");const firstByte=start!==undefined&&at===start.at;const n=writeSync(fd,full,at,firstByte?1:full.length-at,at);if(n<=0)fail();at+=n;observed=fstatSync(fd,{bigint:true});if(firstByte)opts.phase?.(start!.phase);}
187
+ opts.phase?.(written);proof(p,opts);const after=fstatSync(fd,{bigint:true});if(stamp(after)!==stamp(observed)||after.size!==BigInt(full.length)||stamp(lstatSync(file,{bigint:true}))!==stamp(after))fail("changed");fsyncSync(fd);installed=fstatSync(fd,{bigint:true});opts.phase?.(synced);
188
+ if(stamp(lstatSync(file,{bigint:true}))!==stamp(installed))fail("changed");
189
+ }finally{closeSync(fd);}syncDir(p,dirname(file),opts);proof(p,opts);if(stamp(lstatSync(file,{bigint:true}))!==stamp(installed!))fail("changed");
190
+ }
191
+ function checkedIntent(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):{intent:WriterRenewalIntent;snapshot:Snapshot}{
192
+ const intent=validateWriterRenewalIntent(value),b=intentBinding(intent),p=paths(home,b);proof(p,opts);
193
+ const read=snapshot(home,b,true,intent);if(!read.intentBytes)fail("pending");return{intent,snapshot:read};
194
+ }
195
+ export function writeWriterRenewalIntent(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):void {
196
+ safe(()=>{
197
+ const intent=validateWriterRenewalIntent(value),b=intentBinding(intent),p=paths(home,b);if(intent.home!==p.home)fail();
198
+ if(intent.previousLogBytes+encode(intent.commit).length>WRITER_COMMIT_MAX_BYTES)fail("bound");proof(p,opts);
199
+ const prior=readIntent(p,[],intent).file;
200
+ const old=prior?snapshot(home,b,true,intent):snapshot(home,b,false);
201
+ if(!prior&&(intent.revision.sequence!==old.result.sequence+1||intent.revision.previousHash!==old.result.headHash||intent.previousLogBytes!==old.result.logBytes||intent.previousLogHash!==old.result.logHash))fail("changed");
202
+ old.result.assertCurrent();publishIntent(p,intent,opts);snapshot(home,b,true).result.assertCurrent();opts.phase?.("intent-published");
203
+ });
204
+ }
205
+ export function writeWriterRevision(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):void {
206
+ safe(()=>{const {intent,snapshot:read}=checkedIntent(home,value,opts);read.result.assertCurrent();
207
+ publishIntent(read.paths,intent,opts);
208
+ complete(read.paths,join(read.paths.epoch,writerRevisionFile(intent.revision.sequence)),encode(intent.revision),WRITER_REVISION_MAX_BYTES,opts,"revision-written","revision-fsync");
209
+ snapshot(home,intentBinding(intent),true).result.assertCurrent();});
210
+ }
211
+ export function appendWriterCommit(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):void {
212
+ safe(()=>{const {intent,snapshot:read}=checkedIntent(home,value,opts);read.result.assertCurrent();
213
+ publishIntent(read.paths,intent,opts);
214
+ complete(read.paths,join(read.paths.epoch,writerRevisionFile(intent.revision.sequence)),encode(intent.revision),WRITER_REVISION_MAX_BYTES,opts,"revision-written","revision-fsync");
215
+ const prefix=read.log.subarray(0,intent.previousLogBytes),full=Buffer.concat([prefix,encode(intent.commit)]);
216
+ complete(read.paths,read.paths.log,full,WRITER_COMMIT_MAX_BYTES,opts,"commit-written","commit-fsync",{at:intent.previousLogBytes,phase:"commit-started"});snapshot(home,intentBinding(intent),true).result.assertCurrent();});
217
+ }
218
+ export function clearWriterRenewalIntent(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):void {
219
+ safe(()=>{const intent=validateWriterRenewalIntent(value),p=paths(home,intentBinding(intent));proof(p,opts);
220
+ const prior=readIntent(p,[],intent).file;
221
+ if(prior){const read=snapshot(home,intentBinding(intent),true,intent);if(!prior.bytes.equals(encode(intent))||read.result.headHash!==intent.commit.seal||read.log.length!==intent.previousLogBytes+encode(intent.commit).length)fail("pending");
222
+ appendWriterCommit(home,intent,opts);proof(p,opts);const now=readFileExpected(p.intent,encode(intent),WRITER_RENEWAL_INTENT_MAX_BYTES);proof(p,opts);if(stamp(lstatSync(p.intent,{bigint:true}))!==stamp(now.stat))fail("changed");unlinkSync(p.intent);opts.phase?.("intent-cleared");}
223
+ else {const read=snapshot(home,intentBinding(intent),false);if(read.result.headHash!==intent.commit.seal)fail("changed");}
224
+ syncDir(p,p.epoch,opts);syncDir(p,p.home,opts);proof(p,opts);snapshot(home,intentBinding(intent),false).result.assertCurrent();});
225
+ }
226
+ function readFileExpected(file:string,full:Buffer,max:number):FileRead {const value=read(file,max);if(!value||!value.bytes.equals(full))fail();return value;}
227
+ export function abandonWriterRenewal(home:string,value:WriterRenewalIntent,opts:WriterHistoryOptions):void {
228
+ safe(()=>{const intent=validateWriterRenewalIntent(value),p=paths(home,intentBinding(intent));proof(p,opts);
229
+ if(!readIntent(p,[],intent).file){
230
+ const cleared=snapshot(home,intentBinding(intent),false);
231
+ if(cleared.result.sequence!==intent.revision.sequence-1||cleared.result.headHash!==intent.revision.previousHash||cleared.result.logBytes!==intent.previousLogBytes||cleared.result.logHash!==intent.previousLogHash)fail("changed");
232
+ cleared.result.assertCurrent();syncDir(p,p.epoch,opts);syncDir(p,p.home,opts);proof(p,opts);return;
233
+ }
234
+ const before=snapshot(home,intentBinding(intent),true,intent);
235
+ if(before.result.appendStarted)fail("pending");
236
+ const check=()=>{proof(p,opts);const current=snapshot(home,intentBinding(intent),true,intent);if(current.result.appendStarted||current.log.length!==intent.previousLogBytes||sha(current.log)!==intent.previousLogHash)fail("pending");};
237
+ check();const candidate=join(p.epoch,writerRevisionFile(intent.revision.sequence)),prior=read(candidate,WRITER_REVISION_MAX_BYTES);
238
+ if(prior){check();if(stamp(lstatSync(candidate,{bigint:true}))!==stamp(prior.stat))fail("changed");unlinkSync(candidate);syncDir(p,p.epoch,opts);}
239
+ const log=read(p.log,WRITER_COMMIT_MAX_BYTES);if(intent.revision.sequence===1&&log){if(log.bytes.length)fail("pending");check();if(stamp(lstatSync(p.log,{bigint:true}))!==stamp(log.stat))fail("changed");unlinkSync(p.log);syncDir(p,p.epoch,opts);}
240
+ // Reassert parent durability even when the prior owner removed a member before its fsync.
241
+ syncDir(p,p.epoch,opts);check();const intentRead=readIntent(p,[],intent);if(!intentRead.file)fail("changed");
242
+ if(intentRead.temp){check();unlinkSync(intentRead.temp);syncDir(p,p.home,opts);}
243
+ const pending=read(p.intent,WRITER_RENEWAL_INTENT_MAX_BYTES);
244
+ if(pending){proof(p,opts);if(!pending.bytes.equals(encode(intent))||stamp(lstatSync(p.intent,{bigint:true}))!==stamp(pending.stat))fail("changed");unlinkSync(p.intent);opts.phase?.("intent-cleared");}
245
+ syncDir(p,p.home,opts);proof(p,opts);snapshot(home,intentBinding(intent),false).result.assertCurrent();
246
+ });
247
+ }
248
+
249
+
250
+ /** Person coordinator only: discard unpublished atomic-intent staging, never a registered or committed renewal.
251
+ * This purpose-specific reader exception is deliberately unavailable to ordinary history/resolver callers. */
252
+ export function abandonUnpublishedWriterStaging(home:string,input:WriterHistoryBinding,opts:WriterHistoryOptions&{assertCommittedHistory?(history:WriterHistoryRead):void}):{removed:number}{
253
+ return safe(()=>{
254
+ const b=binding(input),p=paths(home,b);proof(p,opts);
255
+ if(lstatSync(p.intent,{throwIfNoEntry:false}))fail("pending");
256
+ const before=snapshot(home,b,false,undefined,true);before.result.assertCurrent();
257
+ const guarded:WriterHistoryOptions={...opts,assertCurrent(){opts.assertCurrent();opts.assertCommittedHistory?.(before.result);}};
258
+ proof(p,guarded);
259
+ const members=new Map<string,{stat:BigIntStats;hash:string}>();
260
+ for(const name of intentTemps(p)){
261
+ proof(p,guarded);const file=join(p.home,name),value=read(file,WRITER_RENEWAL_INTENT_MAX_BYTES);if(!value)fail("changed");
262
+ members.set(name,{stat:value.stat,hash:sha(value.bytes)});
263
+ }
264
+ const check=()=>{
265
+ proof(p,guarded);before.result.assertCurrent();
266
+ if(lstatSync(p.intent,{throwIfNoEntry:false})||canonicalJSON(intentTemps(p))!==canonicalJSON([...members.keys()].sort()))fail("changed");
267
+ };
268
+ check();let removed=0;
269
+ for(const [name,expected]of members){
270
+ check();const file=join(p.home,name),value=read(file,WRITER_RENEWAL_INTENT_MAX_BYTES);
271
+ if(!value||stamp(value.stat)!==stamp(expected.stat)||sha(value.bytes)!==expected.hash)fail("changed");
272
+ check();if(stamp(lstatSync(file,{bigint:true}))!==stamp(expected.stat))fail("changed");
273
+ unlinkSync(file);members.delete(name);removed++;syncDir(p,p.home,guarded);check();
274
+ }
275
+ // A previous owner may have unlinked the last stage before the directory fsync/acknowledgement.
276
+ syncDir(p,p.epoch,guarded);syncDir(p,p.home,guarded);check();return{removed};
277
+ });
278
+ }
@@ -0,0 +1,179 @@
1
+ /** Person-only same-epoch writer renewal. No source/journal recovery, publication or floor writer. */
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { lstatSync, opendirSync } from "node:fs";
4
+ import { join, basename } from "node:path";
5
+ import { types } from "node:util";
6
+ import { canonicalJSON, readProfileJSON, hashJSON, legacyProfileMarkerShape } from "./generation.ts";
7
+ import { validatePlacementProfile } from "./sync-placement-format.ts";
8
+ import { realPath } from "./platform.ts";
9
+ import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
10
+ import { readActivePlacementBinding, type ActivePlacementBinding } from "./sync-placement-active.ts";
11
+ import { readInitialSyncPlacementWriterLocator } from "./sync-placement-writer-roots.ts";
12
+ import { readEffectiveSyncPlacementWriters } from "./sync-placement-effective-writers.ts";
13
+ import { readPlacementJournal, readPlacementWriterJournalAnchors, writerRenewalCapacity } from "./sync-placement-journal-storage.ts";
14
+ import { readWriterHistory, writeWriterRenewalIntent, writeWriterRevision, appendWriterCommit, clearWriterRenewalIntent, abandonWriterRenewal, abandonUnpublishedWriterStaging, type WriterHistoryPhase, type WriterHistoryRead } from "./sync-placement-writer-history.ts";
15
+ import { createWriterRevision, createWriterCommit, createWriterRenewalIntent, writerRevisionWriters, WRITER_REVISION_SCHEMA, WRITER_RENEWAL_INTENT_SCHEMA, WRITER_REVISION_LIMIT, WRITER_COMMIT_LOG, SharedWriterRevisionError, type WriterRenewalIntent } from "./sync-placement-writer-revision-format.ts";
16
+ import { requireWriterRenewalHomeFloor } from "./sync-placement-prerequisites.ts";
17
+ import { withPlacementActivationFences } from "./sync-placement-activation-locks.ts";
18
+ import { assertPlacementActivationOwner, type PlacementActivationOwner } from "./sync-placement-activation-proof.ts";
19
+ import { previewSyncPlacementSources, previewOwnedSyncPlacementSources } from "./sync-placement-capture.ts";
20
+ import { captureInspectionPendingPrunes } from "./sync-placement-inspection-pending.ts";
21
+ import { validatePrunePlan } from "./prune.ts";
22
+ import { initialPlacementWriterContext, previewActivePlacementWriters } from "../sync-placement-writer-context.ts";
23
+ import type { PlacementActivationOptions } from "./sync-placement-activation.ts";
24
+
25
+ export class WriterRenewalError extends Error{
26
+ constructor(readonly causeClass:"pending"|"candidate"|"changed"|"capacity"|"authorization",details=""){
27
+ super(causeClass==="pending"
28
+ ? "Writer renewal refuses an outstanding append, publication, reservation or source-recovery intent; if a precommit renewal is retained, run amem migrate --sync-placement --abandon first. Then restore the inventoried install (for example dot ai rollback amem or the host's previous pin), run amem sync to finish the exact attempt, then preview and apply renewal."
29
+ :causeClass==="candidate"
30
+ ? "Writer renewal requires an explicit complete --writer-root inventory of 0.22.0-or-later installs; upgrade the CLI/host pins, refresh managed setup receipts and run amem migrate --sync-placement --dry-run before applying."
31
+ :causeClass==="capacity"
32
+ ? `Writer renewal metadata capacity is exhausted ${details}; retain this epoch and request a separately reviewed archival/checkpoint protocol before another renewal.`
33
+ :causeClass==="authorization"
34
+ ? "Writer renewal requires explicit person authorization of this exact inventory; have the person preview it with amem migrate --sync-placement --dry-run and then apply."
35
+ : "Writer renewal frozen inventory or binding changed; restore it and run amem migrate --sync-placement --resume, or use --abandon before commit append starts and review a new dry-run.");
36
+ this.name="WriterRenewalError";
37
+ }
38
+ }
39
+ function active(home:string):ActivePlacementBinding{
40
+ const markers=readPlacementRuntimeMarkers(home),value=readActivePlacementBinding(home,markers.profile,markers.shards);if(!value)throw new WriterRenewalError("changed");return value;
41
+ }
42
+ const binding=(a:ActivePlacementBinding)=>({epochHash:a.profile.placementHead,activationHash:a.activation.seal,homeHash:a.epoch.homeHash,generation:a.epoch.generation});
43
+ function list(dir:string,maximum:number):string[]{
44
+ const out:string[]=[],reader=opendirSync(dir);try{for(;;){const e=reader.readSync();if(!e)break;if(out.length>=maximum)throw new SharedWriterRevisionError("bound");out.push(e.name);}}finally{reader.closeSync();}return out.sort();
45
+ }
46
+ function directories(home:string,a:ActivePlacementBinding):string[]{
47
+ const out=[home];for(const r of a.activation.realms)out.push(r.scope==="user"?join(home,"user"):join(home,"projects",r.keyHash.slice(0,16)));
48
+ const checked=(dir:string)=>{const s=lstatSync(dir);if(!s.isDirectory()||s.isSymbolicLink()||s.uid!==process.getuid?.()||(s.mode&0o022)!==0)throw new WriterRenewalError("changed");};
49
+ const projects=join(home,"projects");if(lstatSync(projects,{throwIfNoEntry:false})){
50
+ checked(projects);for(const name of list(projects,256))if(/^[a-f0-9]{16}$/.test(name)){
51
+ const dir=join(projects,name);checked(dir);if(lstatSync(join(dir,"generation.json"),{throwIfNoEntry:false}))out.push(dir);
52
+ }
53
+ }
54
+ for(const dir of out)checked(dir);
55
+ return[...new Set(out)].sort();
56
+ }
57
+ function pendingSources(home:string,owner?:PlacementActivationOwner):()=>void{
58
+ try{
59
+ const source=owner?previewOwnedSyncPlacementSources(home,owner):previewSyncPlacementSources(home),checks=source.directories.map(captureInspectionPendingPrunes);
60
+ for(const dir of source.directories){
61
+ const history=join(dir,"prune-plans","selector-history");if(!lstatSync(history,{throwIfNoEntry:false}))continue;
62
+ const seen=new Set<string>();
63
+ for(const name of list(history,128)){
64
+ const m=/^([a-f0-9]{64})\.json(\.pending)?$/.exec(name);if(!m||m[2])throw new WriterRenewalError("pending");seen.add(m[1]!);
65
+ if(seen.size>64)throw new SharedWriterRevisionError("bound");
66
+ const stat=lstatSync(join(history,name));if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.uid!==process.getuid?.()||(stat.mode&0o077)!==0||stat.size>262144)throw new WriterRenewalError("pending");
67
+ const plan=validatePrunePlan(readProfileJSON(join(dir,"prune-plans",m[1]+".json")));
68
+ if(plan.seal!==m[1]||plan.rows.length!==1)throw new WriterRenewalError("pending");
69
+ const prior=readProfileJSON(join(history,name));if(!prior||typeof prior!=="object"||hashJSON(prior)!==plan.selectorRevision)throw new WriterRenewalError("pending");
70
+ if((prior as {schema?:unknown}).schema==="agent-mem.profile/v3")validatePlacementProfile(prior);else if(!legacyProfileMarkerShape(prior))throw new WriterRenewalError("pending");
71
+ const row=plan.rows[0]!.payload;
72
+ if(![...source.specialRows.values()].some(item=>item.row.id===row.id&&hashJSON(item.row)===hashJSON(row)
73
+ &&(dir===home||basename(dir)==="user"&&item.key.realm.scope==="user"||item.key.realm.scope==="project"&&item.key.realm.keyHash.slice(0,16)===basename(dir))))throw new WriterRenewalError("pending");
74
+ }
75
+ }
76
+ return()=>{source.assertSourcesUnchanged();for(const check of checks)check();};
77
+ }catch(error){if(error instanceof SharedWriterRevisionError&&error.code==="bound")throw error;throw new WriterRenewalError("pending");}
78
+ }
79
+ function journalClear(home:string,epochHash:string):void{
80
+ try{const j=readPlacementJournal(home,epochHash);if(j.pending||j.publicationReservation)throw Error();}catch{throw new WriterRenewalError("pending");}
81
+ }
82
+ function supported(version:string):boolean{const p=version.split(".").map(Number);return p[0]!>0||p[0]===0&&p[1]!>=22;}
83
+ function person(options:PlacementActivationOptions,intent:WriterRenewalIntent):void{
84
+ let okay=false;try{okay=options.authorize(Object.freeze({seal:intent.revision.seal,generation:intent.revision.generation,homeHash:intent.revision.homeHash}))===true;}catch{}
85
+ if(!okay)throw new WriterRenewalError("authorization");
86
+ }
87
+ export async function renewSyncPlacementWriters(options:PlacementActivationOptions):Promise<Record<string,unknown>|undefined>{
88
+ const home=realPath(options.memoryDir),initial=active(home),b=binding(initial);
89
+ let history:WriterHistoryRead;
90
+ try{history=readWriterHistory(home,b,{allowPending:true});}catch(error){
91
+ if(options.mode!=="abandon"||!error||typeof error!=="object"||types.isProxy(error)||!(error instanceof SharedWriterRevisionError)||Object.getOwnPropertyDescriptor(error,"code")?.value!=="staging")throw error;
92
+ requireWriterRenewalHomeFloor(home);
93
+ return withPlacementActivationFences(home,directories(home,initial),async(mutation,owner)=>{
94
+ const locator=readInitialSyncPlacementWriterLocator(home),seal=hashJSON({action:"abandon-unpublished-writer-staging",...b,initialLocatorHash:locator.seal});
95
+ let authorized=false;try{authorized=options.authorize(Object.freeze({seal,generation:b.generation,homeHash:b.homeHash}))===true;}catch{}
96
+ if(!authorized)throw new WriterRenewalError("authorization");
97
+ const assertCurrent=()=>{assertPlacementActivationOwner(owner);requireWriterRenewalHomeFloor(home);if(canonicalJSON(binding(active(home)))!==canonicalJSON(b)||readInitialSyncPlacementWriterLocator(home).seal!==locator.seal)throw new WriterRenewalError("changed");};
98
+ const assertCommittedHistory=(history:WriterHistoryRead)=>{
99
+ assertCurrent();const journal=readPlacementWriterJournalAnchors(home,b.epochHash);
100
+ for(const anchor of history.anchors)if(anchor.sequence>journal.sequence||(anchor.sequence===0?b.epochHash:journal.records[anchor.sequence-1]?.seal)!==anchor.headHash)throw new WriterRenewalError("changed");
101
+ };
102
+ const cleared=abandonUnpublishedWriterStaging(home,{...b,initialLocatorHash:locator.seal},{mutation,assertCurrent,assertCommittedHistory});
103
+ return{mode:options.mode,active:true,abandoned:true,unpublishedStagingRemoved:cleared.removed,nextStep:"amem migrate --sync-placement --dry-run"};
104
+ });
105
+ }
106
+ if(!history.pending&&(options.mode==="resume"||options.mode==="abandon")&&history.sequence===0)return undefined;
107
+ if(options.mode==="dry-run"){
108
+ if(history.pending)return{mode:options.mode,active:true,renewalSupported:true,pending:true,commitStarted:history.appendStarted,abandonable:!history.appendStarted,sequence:history.sequence,remainingRevisions:WRITER_REVISION_LIMIT-history.sequence};
109
+ const expected=readEffectiveSyncPlacementWriters(home);let floorReady=true;try{requireWriterRenewalHomeFloor(home);}catch{floorReady=false;}
110
+ let recoveryReady=true,objectSlotsRemaining:number|undefined;
111
+ try{journalClear(home,b.epochHash);pendingSources(home);objectSlotsRemaining=256-readPlacementJournal(home,b.epochHash).objects;}catch{recoveryReady=false;}
112
+ const writers=previewActivePlacementWriters(home,options.ownRoot,options.writerRoots),remaining=WRITER_REVISION_LIMIT-history.sequence;
113
+ return{mode:options.mode,active:true,renewalSupported:true,forwardOnly:true,sequence:history.sequence,revisionHash:history.headHash,remainingRevisions:WRITER_REVISION_LIMIT-history.sequence,
114
+ quiescence:"deferred-to-apply",floorReady,requiredHomeFloor:"0.22.0",recoveryReady,...(recoveryReady?{}:{nextStep:new WriterRenewalError("pending").message}),
115
+ ...(objectSlotsRemaining===undefined?{}:{objectSlotsRemaining}),...(remaining<=4?{capacityNotice:`${remaining} renewals remain before the 64-revision ceiling; the shared object budget may refuse earlier. Archival/checkpointing requires a separate reviewed protocol.`}:{}),
116
+ writers,candidateMinimumVersion:"0.22.0",candidatesSupported:writers.complete&&writers.roots.every(r=>supported(r.version)),expectedWriterCount:expected.writers.length};
117
+ }
118
+ requireWriterRenewalHomeFloor(home);
119
+ if(history.pending&&options.mode==="apply")throw new SharedWriterRevisionError("pending");
120
+ if(!history.pending&&options.mode==="abandon")throw new WriterRenewalError("changed");
121
+ if(!history.pending&&options.mode==="apply"&&!options.writerRoots.length)throw new WriterRenewalError("candidate");
122
+ // No new intent is written before every pre-existing recovery obligation has been rejected.
123
+ // An unlocked refusal stays entirely passive; crashed owners must first be reclaimed by the real fences below.
124
+ const passivePreflight=options.mode==="apply"||options.mode==="resume"&&history.pending&&!history.appendStarted&&!lstatSync(join(home,"profile.lock"),{throwIfNoEntry:false});
125
+ let sourceCheck=passivePreflight?(journalClear(home,b.epochHash),pendingSources(home)):undefined;
126
+ return withPlacementActivationFences(home,directories(home,initial),async(mutation,owner)=>{
127
+ if(options.mode==="resume"&&history.pending&&!history.appendStarted){journalClear(home,b.epochHash);sourceCheck=pendingSources(home,owner);}
128
+ let intent=history.pending;
129
+ const cleanupOnly=!intent&&options.mode==="resume"&&!!history.current;
130
+ if(cleanupOnly){
131
+ const prefix=Buffer.from(history.commits.slice(0,-1).map(row=>canonicalJSON(row)+"\n").join(""));
132
+ intent=createWriterRenewalIntent({schema:WRITER_RENEWAL_INTENT_SCHEMA,home,revision:history.current!,commit:history.commits.at(-1)!,previousLogBytes:prefix.length,previousLogHash:createHash("sha256").update(prefix).digest("hex")});
133
+ }
134
+ const locator=readInitialSyncPlacementWriterLocator(home);
135
+ const metadata=()=>{
136
+ assertPlacementActivationOwner(owner);requireWriterRenewalHomeFloor(home);
137
+ const now=active(home);if(canonicalJSON(binding(now))!==canonicalJSON(b)||readInitialSyncPlacementWriterLocator(home).seal!==locator.seal)throw new WriterRenewalError("changed");
138
+ if(intent){const journal=readPlacementWriterJournalAnchors(home,b.epochHash),anchor=intent.revision.journal;
139
+ if(anchor.sequence>journal.sequence||(anchor.sequence===0?b.epochHash:journal.records[anchor.sequence-1]?.seal)!==anchor.headHash)throw new WriterRenewalError("changed");}
140
+ };
141
+ metadata();
142
+ if(cleanupOnly){
143
+ person(options,intent!);clearWriterRenewalIntent(home,intent!,{mutation,assertCurrent:metadata});
144
+ return{mode:options.mode,active:true,renewed:false,alreadyCommitted:true,sequence:history.sequence,remainingRevisions:WRITER_REVISION_LIMIT-history.sequence};
145
+ }
146
+ if(!intent){
147
+ journalClear(home,b.epochHash);sourceCheck?.();
148
+ const current=readWriterHistory(home,{...b,initialLocatorHash:locator.seal});
149
+ if(current.sequence>=WRITER_REVISION_LIMIT)throw new SharedWriterRevisionError("cap");
150
+ const candidate=initialPlacementWriterContext(home,options.writerRoots,options.ownRoot);if(candidate.measurement.roots.some(r=>!supported(r.version)))throw new WriterRenewalError("candidate");
151
+ candidate.attest();const previous=current.current?writerRevisionWriters(current.current):initial.activation.writers;
152
+ if(canonicalJSON(previous)===canonicalJSON(candidate.measurement.writers))return{mode:options.mode,active:true,renewed:false,sequence:current.sequence,remainingRevisions:WRITER_REVISION_LIMIT-current.sequence};
153
+ const journal=readPlacementJournal(home,b.epochHash),revision=createWriterRevision({schema:WRITER_REVISION_SCHEMA,...b,initialLocatorHash:locator.seal,sequence:current.sequence+1,previousHash:current.headHash,
154
+ journal:{sequence:journal.sequence,headHash:journal.headHash},roots:candidate.measurement.roots,requestId:randomBytes(32).toString("hex")});
155
+ intent=createWriterRenewalIntent({schema:WRITER_RENEWAL_INTENT_SCHEMA,home,revision,commit:createWriterCommit(revision),previousLogBytes:current.logBytes,previousLogHash:current.logHash});
156
+ }
157
+ const fixed=intent;person(options,fixed);
158
+ const log=join(home,"sync-placement","epochs",b.epochHash,WRITER_COMMIT_LOG);
159
+ const started=()=>{const s=lstatSync(log,{throwIfNoEntry:false});return!!s&&s.isFile()&&!s.isSymbolicLink()&&s.size>fixed.previousLogBytes;};
160
+ const check=()=>{
161
+ metadata();
162
+ try{writerRenewalCapacity(home,b.epochHash,fixed.revision.sequence);}catch{throw new WriterRenewalError("capacity",`(remaining ${WRITER_REVISION_LIMIT-history.sequence} revision slots before the independent 64-revision ceiling)`);}
163
+ if(!started()&&options.mode!=="abandon"){
164
+ journalClear(home,b.epochHash);sourceCheck?.();
165
+ const candidate=initialPlacementWriterContext(home,fixed.revision.roots.map(r=>r.root),options.ownRoot);candidate.attest();
166
+ if(canonicalJSON(candidate.measurement.roots)!==canonicalJSON(fixed.revision.roots))throw new WriterRenewalError("changed");
167
+ const j=readPlacementJournal(home,b.epochHash);if(j.sequence!==fixed.revision.journal.sequence||j.headHash!==fixed.revision.journal.headHash)throw new WriterRenewalError("changed");
168
+ }
169
+ };
170
+ const storage={mutation,assertCurrent:check,phase:(phase:WriterHistoryPhase)=>options.renewalCheckpoint?.(phase)};
171
+ if(options.mode==="abandon"){
172
+ abandonWriterRenewal(home,fixed,{...storage,assertCurrent:metadata});
173
+ return{mode:options.mode,active:true,abandoned:true,sequence:history.sequence,remainingRevisions:WRITER_REVISION_LIMIT-history.sequence};
174
+ }
175
+ check();writeWriterRenewalIntent(home,fixed,storage);writeWriterRevision(home,fixed,storage);appendWriterCommit(home,fixed,storage);
176
+ clearWriterRenewalIntent(home,fixed,{...storage,assertCurrent:metadata});
177
+ return{mode:options.mode,active:true,renewed:true,epochHash:b.epochHash,sequence:fixed.revision.sequence,revisionHash:fixed.commit.seal,remainingRevisions:WRITER_REVISION_LIMIT-fixed.revision.sequence};
178
+ });
179
+ }
@@ -0,0 +1,122 @@
1
+ /** Pure same-epoch writer metadata codecs. Valid data and seals are neither person authority nor fresh install proof. */
2
+ import { createHash } from "node:crypto";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { types } from "node:util";
5
+ import { canonicalJSON, hashJSON } from "./generation.ts";
6
+ import { syncPlacementWriterId, type MeasuredSyncPlacementWriterRoot } from "./sync-placement-writers.ts";
7
+ import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
8
+ import type { SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
9
+
10
+ export const WRITER_REVISION_MAX_BYTES=512*1024;
11
+ export const WRITER_COMMIT_MAX_BYTES=64*1024;
12
+ export const WRITER_RENEWAL_INTENT_MAX_BYTES=1024*1024;
13
+ export const WRITER_REVISION_LIMIT=64;
14
+ export const WRITER_COMMIT_LOG="writer-revisions.jsonl";
15
+ export const WRITER_RENEWAL_INTENT_FILE="sync-placement-writer-renewal.json";
16
+ export const WRITER_REVISION_SCHEMA="agent-mem.sync-placement-writer-revision/v1";
17
+ export const WRITER_COMMIT_SCHEMA="agent-mem.sync-placement-writer-commit/v1";
18
+ export const WRITER_RENEWAL_INTENT_SCHEMA="agent-mem.sync-placement-writer-renewal/v1";
19
+
20
+ export type SharedWriterRevisionErrorCode="invalid"|"bound"|"changed"|"pending"|"staging"|"cap";
21
+ const messages:Record<SharedWriterRevisionErrorCode,string>={
22
+ invalid:"Writer renewal metadata is invalid or unsupported; have the person inspect the retained same-epoch inventory and rerun amem migrate --sync-placement --dry-run.",
23
+ bound:"Writer renewal metadata exceeds its supported bound; retain the existing inventory and request a separately reviewed archival/checkpoint before retrying.",
24
+ changed:"Writer renewal bindings changed; retain the existing inventory, inspect the same-epoch evidence and review a fresh dry-run before applying.",
25
+ pending:"Writer renewal is pending; preserve its exact intent and have the person run amem migrate --sync-placement --resume.",
26
+ staging:"Writer renewal has orphan staging without a registered final intent; have the person inspect it and run amem migrate --sync-placement --abandon, then amem migrate --sync-placement --dry-run before another apply.",
27
+ cap:"Writer revision capacity is exhausted (remaining 0); retain this epoch and request a separately reviewed archival/checkpoint before another renewal.",
28
+ };
29
+ // ponytail: at most64 immutable writer revisions; only a separately reviewed archival/checkpoint can extend this history.
30
+ export class SharedWriterRevisionError extends Error {
31
+ readonly remaining?:number;
32
+ constructor(readonly code:SharedWriterRevisionErrorCode){
33
+ const safe=typeof code==="string"&&Object.hasOwn(messages,code)?code:"invalid";
34
+ super(messages[safe]);this.name="SharedWriterRevisionError";this.code=safe;if(safe==="cap")this.remaining=0;
35
+ }
36
+ }
37
+ function fail(code:SharedWriterRevisionErrorCode="invalid"):never {throw new SharedWriterRevisionError(code);}
38
+ function safe<T>(run:()=>T):T {try{return run();}catch(error){
39
+ if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){
40
+ const code=Object.getOwnPropertyDescriptor(error,"code");if(code&&"value"in code&&typeof code.value==="string"&&Object.hasOwn(messages,code.value))fail(code.value as SharedWriterRevisionErrorCode);
41
+ }return fail();
42
+ }}
43
+ function object(input:unknown,keys:readonly string[]):Record<string,unknown>{
44
+ if(!input||typeof input!=="object"||types.isProxy(input)||Array.isArray(input)||![Object.prototype,null].includes(Object.getPrototypeOf(input))||Object.getOwnPropertySymbols(input).length)fail();
45
+ if(Object.getOwnPropertyNames(input).length!==keys.length)fail();const out:Record<string,unknown>=Object.create(null);
46
+ for(const key of keys){const field=Object.getOwnPropertyDescriptor(input,key);if(!field||!("value"in field)||!field.enumerable)fail();out[key]=field.value;}return out;
47
+ }
48
+ function array(input:unknown,maximum:number):unknown[]{
49
+ if(!input||typeof input!=="object"||types.isProxy(input)||!Array.isArray(input)||Object.getPrototypeOf(input)!==Array.prototype||Object.getOwnPropertySymbols(input).length)fail();
50
+ const length=Object.getOwnPropertyDescriptor(input,"length")?.value;
51
+ if(!Number.isSafeInteger(length)||length<1||length>maximum)fail("bound");
52
+ if(Object.getOwnPropertyNames(input).length!==length+1)fail();const out:unknown[]=[];
53
+ for(let i=0;i<length;i++){const field=Object.getOwnPropertyDescriptor(input,String(i));if(!field||!("value"in field)||!field.enumerable)fail();out.push(field.value);}return out;
54
+ }
55
+ function hex(input:unknown,width=64):string {if(typeof input!=="string"||input.length!==width||!/^[a-f0-9]+$/.test(input))fail();return input;}
56
+ function integer(input:unknown,minimum:number,maximum:number):number {if(typeof input!=="number"||!Number.isSafeInteger(input)||input<minimum||input>maximum)fail();return input;}
57
+ function sequence(input:unknown):number {if(typeof input==="number"&&Number.isSafeInteger(input)&&input>WRITER_REVISION_LIMIT)fail("cap");return integer(input,1,WRITER_REVISION_LIMIT);}
58
+ function path(input:unknown):string {if(typeof input!=="string"||!isAbsolute(input)||resolve(input)!==input||input.includes("\0"))fail();if(Buffer.byteLength(input)>4096)fail("bound");return input;}
59
+ function version(input:unknown):string {
60
+ if(typeof input!=="string"||input.length>64)fail();const match=/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(input);
61
+ if(!match||match[0]!==input)fail();const parts=match.slice(1).map(Number);
62
+ if(!parts.every(Number.isSafeInteger)||parts[0]===0&&parts[1]!<22)fail();return input;
63
+ }
64
+ function bounded(value:unknown,maximum:number):void {if(Buffer.byteLength(canonicalJSON(value)+"\n")>maximum)fail("bound");}
65
+ function roots(input:unknown):MeasuredSyncPlacementWriterRoot[]{
66
+ const out:MeasuredSyncPlacementWriterRoot[]=[],ids=new Set<string>();let previous="";
67
+ for(const entry of array(input,256)){
68
+ const value=object(entry,["root","writerId","version","pin"]),root=path(value.root),writerId=hex(value.writerId);
69
+ if(root<=previous||ids.has(writerId)||writerId!==syncPlacementWriterId(root))fail();
70
+ out.push({root,writerId,version:version(value.version),pin:hex(value.pin)});ids.add(writerId);previous=root;
71
+ }return out;
72
+ }
73
+ export function writerRevisionFile(value:number):string {return `writer-revision-${String(sequence(value)).padStart(5,"0")}.json`;}
74
+ export function writerHistoryFileLimit(name:string):number|undefined {
75
+ if(name===WRITER_COMMIT_LOG)return WRITER_COMMIT_MAX_BYTES;
76
+ if(typeof name!=="string")return;const match=/^writer-revision-([0-9]{5})\.json$/.exec(name);
77
+ if(!match||match[0]!==name)return;const n=Number(match[1]);return n>=1&&n<=WRITER_REVISION_LIMIT?WRITER_REVISION_MAX_BYTES:undefined;
78
+ }
79
+ export function writerInventoryGenesis(epochHash:string,activationHash:string):string {
80
+ return hashJSON(["agent-mem.sync-placement-writer-genesis/v1",hex(epochHash),hex(activationHash)]);
81
+ }
82
+ export interface WriterRevisionBody {
83
+ schema:typeof WRITER_REVISION_SCHEMA;epochHash:string;generation:string;homeHash:string;activationHash:string;initialLocatorHash:string;
84
+ sequence:number;previousHash:string;journal:{sequence:number;headHash:string};roots:MeasuredSyncPlacementWriterRoot[];requestId:string;
85
+ }
86
+ export interface WriterRevision extends WriterRevisionBody {seal:string}
87
+ export interface WriterCommitBody {schema:typeof WRITER_COMMIT_SCHEMA;epochHash:string;sequence:number;previousHash:string;revisionHash:string}
88
+ export interface WriterCommit extends WriterCommitBody {seal:string}
89
+ export interface WriterRenewalIntentBody {schema:typeof WRITER_RENEWAL_INTENT_SCHEMA;home:string;revision:WriterRevision;commit:WriterCommit;previousLogBytes:number;previousLogHash:string}
90
+ export interface WriterRenewalIntent extends WriterRenewalIntentBody {seal:string}
91
+ function revision(input:unknown,sealed:boolean):WriterRevision {
92
+ const v=object(input,["schema","epochHash","generation","homeHash","activationHash","initialLocatorHash","sequence","previousHash","journal","roots","requestId",...(sealed?["seal"]:[])]);
93
+ if(v.schema!==WRITER_REVISION_SCHEMA)fail();const journal=object(v.journal,["sequence","headHash"]);
94
+ const body:WriterRevisionBody={schema:WRITER_REVISION_SCHEMA,epochHash:hex(v.epochHash),generation:hex(v.generation,32),homeHash:hex(v.homeHash),activationHash:hex(v.activationHash),
95
+ initialLocatorHash:hex(v.initialLocatorHash),sequence:sequence(v.sequence),previousHash:hex(v.previousHash),journal:{sequence:integer(journal.sequence,0,20000),headHash:hex(journal.headHash)},roots:roots(v.roots),requestId:hex(v.requestId)};
96
+ if(body.sequence===1&&body.previousHash!==writerInventoryGenesis(body.epochHash,body.activationHash))fail();
97
+ const value={...body,seal:hashJSON(body)};if(sealed&&hex(v.seal)!==value.seal)fail();bounded(value,WRITER_REVISION_MAX_BYTES);return value;
98
+ }
99
+ export function createWriterRevision(body:WriterRevisionBody):WriterRevision {return safe(()=>revision(body,false));}
100
+ export function validateWriterRevision(value:unknown):WriterRevision {return safe(()=>revision(value,true));}
101
+ export function writerRevisionWriters(input:WriterRevision):SyncPlacementWriterAttestation[]{
102
+ return validateWriterRevision(input).roots.map<SyncPlacementWriterAttestation>(({writerId,version,pin})=>({writerId,version,pin,publicationFormat:PLACEMENT_FORMAT,compatible:true as const})).sort((a,b)=>a.writerId.localeCompare(b.writerId));
103
+ }
104
+ function commit(input:unknown,sealed:boolean):WriterCommit {
105
+ const v=object(input,["schema","epochHash","sequence","previousHash","revisionHash",...(sealed?["seal"]:[])]);if(v.schema!==WRITER_COMMIT_SCHEMA)fail();
106
+ const body:WriterCommitBody={schema:WRITER_COMMIT_SCHEMA,epochHash:hex(v.epochHash),sequence:sequence(v.sequence),previousHash:hex(v.previousHash),revisionHash:hex(v.revisionHash)};
107
+ const value={...body,seal:hashJSON(body)};if(sealed&&hex(v.seal)!==value.seal)fail();bounded(value,WRITER_COMMIT_MAX_BYTES);return value;
108
+ }
109
+ export function createWriterCommit(input:WriterRevision):WriterCommit {
110
+ const rev=validateWriterRevision(input);return safe(()=>commit({schema:WRITER_COMMIT_SCHEMA,epochHash:rev.epochHash,sequence:rev.sequence,previousHash:rev.previousHash,revisionHash:rev.seal},false));
111
+ }
112
+ export function validateWriterCommit(value:unknown):WriterCommit {return safe(()=>commit(value,true));}
113
+ function intent(input:unknown,sealed:boolean):WriterRenewalIntent {
114
+ const v=object(input,["schema","home","revision","commit","previousLogBytes","previousLogHash",...(sealed?["seal"]:[])]);if(v.schema!==WRITER_RENEWAL_INTENT_SCHEMA)fail();
115
+ const rev=validateWriterRevision(v.revision),ack=validateWriterCommit(v.commit),previousLogBytes=integer(v.previousLogBytes,0,WRITER_COMMIT_MAX_BYTES),previousLogHash=hex(v.previousLogHash);
116
+ if(ack.epochHash!==rev.epochHash||ack.sequence!==rev.sequence||ack.previousHash!==rev.previousHash||ack.revisionHash!==rev.seal)fail();
117
+ if(rev.sequence===1&&(previousLogBytes!==0||previousLogHash!==createHash("sha256").update("").digest("hex")))fail();
118
+ const body:WriterRenewalIntentBody={schema:WRITER_RENEWAL_INTENT_SCHEMA,home:path(v.home),revision:rev,commit:ack,previousLogBytes,previousLogHash};
119
+ const value={...body,seal:hashJSON(body)};if(sealed&&hex(v.seal)!==value.seal)fail();bounded(value,WRITER_RENEWAL_INTENT_MAX_BYTES);return value;
120
+ }
121
+ export function createWriterRenewalIntent(body:WriterRenewalIntentBody):WriterRenewalIntent {return safe(()=>intent(body,false));}
122
+ export function validateWriterRenewalIntent(value:unknown):WriterRenewalIntent {return safe(()=>intent(value,true));}