@gregiteen/ssss-cli 0.12.0 → 0.13.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.
@@ -0,0 +1,75 @@
1
+ /** Portable owner helper: preserve local wrappers, deploy managed SSSS core, verify every eligible consumer. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ const args = process.argv.slice(2);
9
+ const value = flag => { const i=args.indexOf(flag); return i<0 ? null : args[i+1]; };
10
+ const source = path.resolve(value('--source') || path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'));
11
+ const apply = args.includes('--apply');
12
+ const executable = execFileSync('which',['total-recall'],{encoding:'utf8'}).trim();
13
+ const packageRoot = path.resolve(path.dirname(fs.realpathSync(executable)), '..');
14
+ const api = await import(pathToFileURL(path.join(packageRoot,'src/core/skills-registry.mjs')));
15
+ const brain = path.resolve(value('--brain') || path.join(os.homedir(),'.agent/skills/total-recall'));
16
+ const sharedRoot = path.resolve(value('--shared-root') || os.homedir());
17
+ const registry = api.loadRegistry(brain);
18
+ const expectedCore = api.hashSkillLayer(source,'core');
19
+ if (!expectedCore || !fs.existsSync(path.join(source,'SKILL.md'))) throw new Error('Invalid source-owned portable SSSS package');
20
+ const known = api.loadKnownRepoRoots(brain,{includeCwd:false});
21
+ const targets = new Set([sharedRoot, ...known]);
22
+ const rows = registry.installs.filter(row=>row.skill_id==='ssss');
23
+ const obsolete = rows.filter(row=>row.repo && !fs.existsSync(row.repo)).map(row=>({path:row.path,repo:row.repo,status:'obsolete_repository',required:false}));
24
+ const stamp = new Date().toISOString().replace(/[:.]/g,'-');
25
+ const backup = path.join(brain,'skill-package-history','ssss',stamp);
26
+ const results=[];
27
+ if (apply) api.registerSkill(brain,source,{source:source,source_type:'portable-owner-package',authoritative_scope:true});
28
+ for (const root of targets) {
29
+ if (!fs.existsSync(root)) { results.push({repo:root,status:'missing_repository',required:true}); continue; }
30
+ let destination=path.join(root,'.agent/skills/ssss');
31
+ let alias=null;
32
+ try {
33
+ if(fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) {
34
+ alias=destination; destination=fs.realpathSync(destination);
35
+ if (api.readSkillMeta(destination).id !== 'ssss') throw new Error('Shared alias does not identify an SSSS skill');
36
+ }
37
+ if (!apply) { results.push({repo:root,path:destination,status:'would_deploy',required:true}); continue; }
38
+ const priorWrapper=path.join(destination,'SKILL.md');
39
+ const routed=fs.existsSync(priorWrapper) && /(?:core\/SKILL\.md|total-recall:managed-core-route:v1)/.test(fs.readFileSync(priorWrapper,'utf8'));
40
+ if(api.hashSkillLayer(destination,'core')===expectedCore && routed) {
41
+ results.push({repo:root,path:destination,status:'verified',required:true,expected_core:expectedCore,observed_core:expectedCore,wrapper_preserved:true,changed:false,...(alias?{alias}: {})});
42
+ continue;
43
+ }
44
+ // Snapshot the package only, never a vault/database or unrelated skill.
45
+ if(fs.existsSync(destination)) {
46
+ const id=Buffer.from(root).toString('base64url');
47
+ fs.cpSync(destination,path.join(backup,id),{recursive:true,dereference:false});
48
+ }
49
+ if (alias) {
50
+ api.replaceSkillDir(path.join(source,'core'),path.join(destination,'core'),{requiredFile:null,preserveExisting:false});
51
+ const mapped=api.loadRegistry(brain);
52
+ mapped.installs=mapped.installs.filter(row=>!(row.skill_id==='ssss'&&row.path===alias));
53
+ mapped.installs.push({skill_id:'ssss',path:alias,repo:root,layered:true,version:api.readSkillMeta(source).version,core_hash:expectedCore,repo_hash:api.hashSkillLayer(destination,'repo'),registry_hash:mapped.skills.ssss.content_hash,installed_at:new Date().toISOString()});
54
+ api.saveRegistry(brain,mapped);
55
+ } else api.deploySkill(brain,'ssss',{repo:root,routeCore:true});
56
+ const wrapper=path.join(destination,'SKILL.md');
57
+ const text=fs.readFileSync(wrapper,'utf8');
58
+ const marker='<!-- total-recall:managed-core-route:v1 -->';
59
+ if(!text.includes(marker) && !text.includes('(core/SKILL.md)')) fs.writeFileSync(wrapper,text.trimEnd()+`\n\n${marker}\nRead the [maintained portable core](core/SKILL.md) for current protocol contracts; preserve repository-specific instructions above.\n`);
60
+ const observed=api.hashSkillLayer(destination,'core');
61
+ results.push({repo:root,path:destination,status:observed===expectedCore?'verified':'hash_mismatch',required:true,expected_core:expectedCore,observed_core:observed,wrapper_preserved:true,changed:true,...(alias?{alias}: {})});
62
+ } catch(error) { results.push({repo:root,path:destination,status:'error',required:true,error:error.message}); }
63
+ }
64
+ if(apply) {
65
+ const updated=api.loadRegistry(brain);
66
+ for(const row of updated.installs.filter(row=>row.skill_id==='ssss')) {
67
+ row.obsolete=Boolean(row.repo && !fs.existsSync(row.repo));
68
+ if(row.obsolete) row.obsolete_reason='Recorded repository no longer exists; retained for history, never recreated';
69
+ else delete row.obsolete_reason;
70
+ }
71
+ api.saveRegistry(brain,updated);
72
+ }
73
+ const report={checked_at:new Date().toISOString(),apply,source,version:api.readSkillMeta(source).version,expected_core:expectedCore,backup:apply?backup:null,results,obsolete,updated:results.filter(row=>row.changed).length,failed:results.filter(row=>row.required&&!['verified','would_deploy'].includes(row.status)).length};
74
+ console.log(JSON.stringify(report,null,2));
75
+ if(report.failed)process.exitCode=1;
@@ -0,0 +1,11 @@
1
+ import {fileURLToPath} from 'node:url'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import assert from 'node:assert/strict';
2
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ssss-helper-')); const source=path.join(root,'source'),brain=path.join(root,'brain'),repo=path.join(root,'repo'),helper=fileURLToPath(new URL('../core/scripts/maintain.mjs', import.meta.url));
3
+ try {
4
+ fs.mkdirSync(path.join(source,'core'),{recursive:true});fs.writeFileSync(path.join(source,'SKILL.md'),'---\nname: ssss\ndescription: portable\nversion: 0.12.1\n---\n# Portable\n');fs.writeFileSync(path.join(source,'core/SKILL.md'),'# Current protocol\n');
5
+ const wrapper=path.join(repo,'.agent/skills/ssss/SKILL.md');fs.mkdirSync(path.dirname(wrapper),{recursive:true});fs.writeFileSync(wrapper,'---\nname: ssss\n---\n# Keep authored local instructions\n');
6
+ const run=(apply)=>JSON.parse(execFileSync(process.execPath,[helper,'--source',source,'--brain',brain,'--shared-root',repo,...(apply?['--apply']:[])],{encoding:'utf8'}));
7
+ assert.equal(run(false).apply,false);assert(!fs.existsSync(path.join(path.dirname(wrapper),'core')));
8
+ const first=run(true);assert.equal(first.failed,0);assert.equal(first.updated,1);assert(fs.readFileSync(wrapper,'utf8').includes('Keep authored local instructions'));
9
+ const second=run(true);assert.equal(second.updated,0);assert.equal(second.failed,0);assert(!fs.existsSync(second.backup));
10
+ console.log('PASS portable helper dry-run, preserve authored wrapper, deploy managed core, independent hash verification and idempotent no-op');
11
+ } finally {fs.rmSync(root,{recursive:true,force:true});}
@@ -0,0 +1,54 @@
1
+ ---
2
+ type: skill
3
+ title: SSSS Protocol Reference
4
+ name: ssss
5
+ description: "Use this skill when reading, writing, parsing, validating, or extending SSSS -- the Structured Semantic Syntax System: the SSSS Core Contract API, VFS-native primitives, operation/patch/event envelopes, leases, and projections. MANDATORY: You MUST read the full SKILL.md file before executing."
6
+ timestamp: 2026-07-15T00:00:00Z
7
+ ---
8
+
9
+ # SSSS — Structured Semantic Syntax System Reference
10
+
11
+ You are operating in a repository that implements, extends, or relies on the **Structured Semantic Syntax System (SSSS)**. SSSS is a database-free, Markdown-first schema and mutation contract for AI-agent state.
12
+
13
+ This skill provides the normative rules for interacting with an SSSS-compliant vault or host engine (like Total Recall).
14
+
15
+ ## ⚠️ The VFS-First Mandate (Absolute Rule)
16
+
17
+ **NEVER use raw database inserts, updates, or deletes to mutate application state in an SSSS environment!**
18
+
19
+ All persistent state (memories, agents, workflows, tasks) lives in the Virtual File System (VFS) as Markdown files with YAML frontmatter. The VFS is the absolute source of truth.
20
+ - If you are interacting via CLI: Use `npx total-recall remember` or `npx total-recall recall`.
21
+ - If you are building host code: You must route mutations through the SSSS **Operation Contract** engine (which handles idempotency, leasing, and semantic indexing), or use the provided API wrappers. Do not bypass the engine to write files directly.
22
+
23
+ ## SSSS Core Concepts
24
+
25
+ ### 1. Primitives (The Data Model)
26
+ Data in SSSS is defined by standard primitives (e.g. `workflow`, `assistant`, `skill`, `memory`). Each primitive has a defined schema (in `registry/core.json` or extension registries) and belongs to a specific Portability Class:
27
+ - `structural`: Logic and system prompts (e.g. rules, workflows). Included in export templates.
28
+ - `tenant_private`: User data (e.g. chats, tasks). NEVER included in templates.
29
+ - `resource_bound`: Requires external setup (e.g. Twilio numbers). Exported only as a requirement.
30
+
31
+ ### 2. The Operation Contract (Envelopes)
32
+ When an SSSS engine processes a write, it receives an **Envelope**:
33
+ - `operation`: Creates or completely replaces a document.
34
+ - `patch`: Merges partial data into a document (e.g. updating task status).
35
+ - `event`: Appends an immutable log entry to a document.
36
+ - `delete`: Tombstones a replace-type document.
37
+
38
+ ### 3. Idempotency and Leases
39
+ - **Idempotency**: All mutations must provide an `idempotency_key`. The engine ensures duplicate submissions do not result in duplicate state.
40
+ - **Leases**: Background tasks and workflows use the Lease store to acquire temporary locks on documents to prevent race conditions during execution.
41
+
42
+ ### 4. Bundles (.ucw)
43
+ An SSSS vault can be packaged into a `.ucw` (Universal Containerized Workspace) bundle. A bundle is a portable snapshot containing a manifest, deterministic file paths, and content hashes for cryptographic integrity.
44
+
45
+ Bundle import validates the complete plan before committing. Hosts pass planned
46
+ document lookup through the kernel context's `resolveReference` option so valid
47
+ forward references resolve in an empty destination. Missing referenced documents
48
+ must reject the whole import; never weaken constraints to bypass ordering.
49
+
50
+ ## Validation
51
+
52
+ If you are writing tests or schemas in a host repository, ensure that:
53
+ 1. `MemoryNodeSchema` validations provide all required SSSS v2 properties (`status`, `created`, `updated`, `last_accessed`, `source`, `decay`).
54
+ 2. Schema changes maintain strict compatibility with the Open Knowledge Format (OKF) standard if applicable.
package/src/bundle.mjs CHANGED
@@ -471,9 +471,12 @@ export function provisionBundle(bundle, opts = {}) {
471
471
  }
472
472
 
473
473
  // Build the envelope plan. Deterministic idempotency keys make import a no-op on re-run.
474
+ // The key covers the content: committed keys are durable (spec §6.4), so a key
475
+ // derived from the path alone would make every changed re-import a conflict.
474
476
  const plan = bundle.files.map((f) => {
475
477
  const targetPath = remap(f.path);
476
- const key = 'prov-' + crypto.createHash('sha256').update(workspaceId + ':' + targetPath).digest('hex').slice(0, 24);
478
+ const contentDigest = crypto.createHash('sha256').update(f.content).digest('hex');
479
+ const key = 'prov-' + crypto.createHash('sha256').update(workspaceId + ':' + targetPath + ':' + contentDigest).digest('hex').slice(0, 24);
477
480
  return { type: 'operation', idempotency_key: key, path: targetPath, workspace_id: workspaceId, content: f.content, intent: 'provision', dry_run: dryRun, actor: { role: 'system' } };
478
481
  });
479
482
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Flush helpers for the reference file adapters (spec §6.3 commit point).
3
+ *
4
+ * A rename is atomic but not durable: after an OS crash the new name can
5
+ * survive while its bytes do not, unless the file is flushed before the
6
+ * rename and the directory after it. Platforms that cannot open a directory
7
+ * for flushing (Windows) skip the directory step.
8
+ */
9
+ import fs from 'node:fs';
10
+
11
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
12
+ const CREATE_EXCLUSIVE = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW;
13
+ const UNSUPPORTED_DIRECTORY_SYNC = new Set(['EISDIR', 'EINVAL', 'EPERM', 'EACCES', 'ENOTSUP', 'EBADF']);
14
+
15
+ /** Create `file` exclusively, write `bytes`, and flush them before returning. */
16
+ export function writeFileSyncDurable(file, bytes, mode = 0o600) {
17
+ const fd = fs.openSync(file, CREATE_EXCLUSIVE, mode);
18
+ try {
19
+ fs.writeFileSync(fd, bytes);
20
+ fs.fsyncSync(fd);
21
+ } finally { fs.closeSync(fd); }
22
+ }
23
+
24
+ /** Flush a directory's entries (a rename, create or unlink inside it). */
25
+ export function fsyncDirectory(dir) {
26
+ let fd;
27
+ try { fd = fs.openSync(dir, fs.constants.O_RDONLY); }
28
+ catch (error) { if (UNSUPPORTED_DIRECTORY_SYNC.has(error.code)) return; throw error; }
29
+ try { fs.fsyncSync(fd); }
30
+ catch (error) { if (!UNSUPPORTED_DIRECTORY_SYNC.has(error.code)) throw error; }
31
+ finally { fs.closeSync(fd); }
32
+ }
package/src/engine.mjs CHANGED
@@ -139,6 +139,10 @@ export function createEngine(options = {}) {
139
139
  }
140
140
  return response;
141
141
  },
142
+ /** Resolve commit intents a crashed writer left in this vault (spec §6.3). */
143
+ async recover(vaultRoot, callOptions = {}) {
144
+ return bound(vaultRoot, callOptions.eventLogDir).recover();
145
+ },
142
146
  resolveContainedPath,
143
147
  _types: registrySet.types,
144
148
  _registry: registrySet,
package/src/events.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
- import { StringDecoder } from 'node:string_decoder';
6
5
  import { withFileLock } from './file-lock.mjs';
6
+ import { fsyncDirectory } from './durable-fs.mjs';
7
7
 
8
8
  // UUIDv5 namespace for event ids derived from idempotency keys.
9
9
  const IDEMPOTENT_EVENT_NAMESPACE = Buffer.from('6f1c2d9e4b7a4e0c9f35a1d8c3b2e7f4', 'hex');
@@ -49,16 +49,26 @@ export function createCanonicalEvent(input, options = {}) {
49
49
  };
50
50
  }
51
51
 
52
+ const keyId = (workspaceId, idempotencyKey) => JSON.stringify([workspaceId, idempotencyKey]);
53
+
52
54
  export class MemoryEventStore {
53
55
  #events = [];
54
56
  #ids = new Set();
57
+ #keys = new Map();
55
58
  async append(event) {
56
59
  if (this.#ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
57
60
  const frozen = structuredClone(event);
58
61
  this.#events.push(frozen);
59
62
  this.#ids.add(frozen.event_id);
63
+ const id = keyId(frozen.workspace_id, frozen.idempotency_key);
64
+ if (!this.#keys.has(id)) this.#keys.set(id, frozen);
60
65
  return structuredClone(frozen);
61
66
  }
67
+ /** The first event committed under (workspace, key), or null (spec §6.4). */
68
+ async findByIdempotencyKey(workspaceId, idempotencyKey) {
69
+ const event = this.#keys.get(keyId(workspaceId, idempotencyKey));
70
+ return event ? structuredClone(event) : null;
71
+ }
62
72
  async *replay(options = {}) {
63
73
  for (let index = options.cursor || 0; index < this.#events.length; index++) {
64
74
  const event = this.#events[index];
@@ -84,34 +94,77 @@ function sameStat(a, b) {
84
94
  return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
85
95
  }
86
96
 
87
- /** Decode one line at a time, never materializing the complete log as a string. */
88
- function* logLines(fd, size) {
97
+ /**
98
+ * Split a log into lines on raw bytes, one line at a time, never materializing
99
+ * the complete log. Each record carries the byte offset of its line, and
100
+ * `complete: false` for a final line with no newline yet (an append in flight).
101
+ */
102
+ function* logRecords(fd, size) {
89
103
  const buffer = Buffer.alloc(64 * 1024);
90
- const decoder = new StringDecoder('utf8');
91
- let offset = 0, pending = '';
92
- while (offset < size) {
93
- const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset);
104
+ let position = 0, start = 0, pending = [];
105
+ while (position < size) {
106
+ const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - position), position);
94
107
  if (read === 0) break;
95
- offset += read;
96
- const text = pending + decoder.write(buffer.subarray(0, read));
97
- let start = 0, newline;
98
- while ((newline = text.indexOf('\n', start)) !== -1) {
99
- yield text.slice(start, newline);
100
- start = newline + 1;
108
+ let from = 0, newline;
109
+ while ((newline = buffer.indexOf(0x0a, from)) !== -1 && newline < read) {
110
+ pending.push(buffer.subarray(from, newline));
111
+ yield { offset: start, text: Buffer.concat(pending).toString('utf8'), complete: true };
112
+ pending = [];
113
+ start = position + newline + 1;
114
+ from = newline + 1;
101
115
  }
102
- pending = text.slice(start);
116
+ // Copy: the read buffer is reused by the next iteration.
117
+ if (from < read) pending.push(Buffer.from(buffer.subarray(from, read)));
118
+ position += read;
103
119
  }
104
- pending += decoder.end();
105
- if (pending) yield pending;
120
+ if (pending.length) yield { offset: start, text: Buffer.concat(pending).toString('utf8'), complete: false };
121
+ }
122
+
123
+ function* logLines(fd, size) {
124
+ for (const record of logRecords(fd, size)) yield record.text;
106
125
  }
107
126
 
108
- /** Read every event_id, bounded to the bytes covered by the returned stat. */
109
- function loadIndex(file) {
127
+ /**
128
+ * Read every event_id and the first offset of every idempotency key, bounded
129
+ * to the bytes covered by the returned stat. A reader outside the append lock
130
+ * passes `partialTail` so another writer's in-flight line is skipped instead
131
+ * of failing to parse; the index then records that it is partial.
132
+ */
133
+ function loadIndex(file, { partialTail = false } = {}) {
110
134
  const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
111
135
  try {
112
- const stat = fs.fstatSync(fd), ids = new Set();
113
- for (const line of logLines(fd, stat.size)) if (line.trim()) ids.add(JSON.parse(line).event_id);
114
- return { ids, stat };
136
+ const stat = fs.fstatSync(fd), ids = new Set(), keys = new Map();
137
+ let partial = false;
138
+ for (const { offset, text, complete } of logRecords(fd, stat.size)) {
139
+ if (!text.trim()) continue;
140
+ if (!complete && partialTail) { partial = true; continue; }
141
+ const event = JSON.parse(text);
142
+ ids.add(event.event_id);
143
+ if (typeof event.idempotency_key === 'string' && !keys.has(event.idempotency_key)) {
144
+ keys.set(event.idempotency_key, { file, offset });
145
+ }
146
+ }
147
+ return { ids, keys, stat, partial };
148
+ } finally { fs.closeSync(fd); }
149
+ }
150
+
151
+ /** Parse the single log line that starts at `offset`, or null if it is gone. */
152
+ function readEventAt(file, offset) {
153
+ let fd;
154
+ try { fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW); }
155
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
156
+ try {
157
+ const size = fs.fstatSync(fd).size;
158
+ const buffer = Buffer.alloc(16 * 1024), chunks = [];
159
+ for (let position = offset; position < size;) {
160
+ const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - position), position);
161
+ if (read === 0) break;
162
+ const newline = buffer.indexOf(0x0a);
163
+ if (newline !== -1 && newline < read) { chunks.push(Buffer.from(buffer.subarray(0, newline))); break; }
164
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
165
+ position += read;
166
+ }
167
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
115
168
  } finally { fs.closeSync(fd); }
116
169
  }
117
170
 
@@ -194,35 +247,60 @@ export class JsonlEventStore {
194
247
  return 0;
195
248
  }
196
249
  }
197
- #index(file) {
250
+ // `partialTail` is for readers outside the append lock (see loadIndex). An
251
+ // index built that way is cached, but the append path never trusts it.
252
+ #index(file, { partialTail = false } = {}) {
253
+ const workspaceId = path.basename(file, '.jsonl');
198
254
  let stat;
199
255
  try { stat = fs.lstatSync(file); }
200
256
  catch (error) {
201
257
  if (error.code !== 'ENOENT') throw error;
202
258
  this.#indexes.delete(file);
203
- return { ids: new Set(), stat: null };
259
+ stat = null;
204
260
  }
205
- if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
261
+ if (stat?.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
206
262
  const cached = this.#indexes.get(file);
207
- if (cached && sameStat(cached.stat, stat)) return cached;
263
+ if (cached && cached.stat && stat && sameStat(cached.stat, stat) && (partialTail || !cached.partial)) return cached;
208
264
  // Duplicate detection spans the live log AND every archive segment:
209
265
  // rotating a log must never let an already-recorded event_id be appended
210
266
  // again. Archives are immutable, so their ids are collected once per
211
267
  // rebuild; only the live file's stat decides when a rebuild is needed.
212
- const ids = new Set();
213
- const workspaceId = path.basename(file, '.jsonl');
268
+ // Keys keep their first occurrence, oldest segment first.
269
+ const ids = new Set(), keys = new Map();
270
+ const merge = (part) => {
271
+ for (const id of part.ids) ids.add(id);
272
+ for (const [key, entry] of part.keys) if (!keys.has(key)) keys.set(key, entry);
273
+ };
214
274
  for (const archive of this.#archives(workspaceId)) {
215
275
  let archiveStat;
216
276
  try { archiveStat = fs.lstatSync(archive); } catch { continue; }
217
277
  if (archiveStat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
218
- for (const id of loadIndex(archive).ids) ids.add(id);
278
+ merge(loadIndex(archive));
219
279
  }
220
- const live = loadIndex(file);
221
- for (const id of live.ids) ids.add(id);
222
- const index = { ids, stat };
280
+ if (!stat) return { ids, keys, stat: null, partial: false };
281
+ const live = loadIndex(file, { partialTail });
282
+ merge(live);
283
+ const index = { ids, keys, stat, partial: live.partial };
223
284
  this.#indexes.set(file, index);
224
285
  return index;
225
286
  }
287
+ /**
288
+ * The first event committed under (workspace, key), or null (spec §6.4).
289
+ * The index holds only offsets; the event itself is read on a hit, which
290
+ * only a retry causes. A segment rotated between the index read and the
291
+ * line read is detected by the key check and retried once on a fresh index.
292
+ */
293
+ async findByIdempotencyKey(workspaceId, idempotencyKey) {
294
+ const file = this.#file(workspaceId);
295
+ for (let attempt = 0; attempt < 2; attempt++) {
296
+ const entry = this.#index(file, { partialTail: true }).keys.get(idempotencyKey);
297
+ if (!entry) return null;
298
+ const event = readEventAt(entry.file, entry.offset);
299
+ if (event?.idempotency_key === idempotencyKey && event.workspace_id === workspaceId) return event;
300
+ this.#indexes.delete(file);
301
+ }
302
+ return null;
303
+ }
226
304
  async append(event) {
227
305
  const file = this.#file(event.workspace_id);
228
306
  return withFileLock(`${file}.lock`, () => this.#appendLocked(file, event), this.lockOptions);
@@ -240,15 +318,23 @@ export class JsonlEventStore {
240
318
  let after;
241
319
  try {
242
320
  fs.writeFileSync(fd, line);
321
+ // The append is the commit point (spec §6.3): it is on disk before the
322
+ // kernel reports success, and so is a log this append created.
323
+ fs.fsyncSync(fd);
243
324
  after = fs.fstatSync(fd);
244
325
  } finally { fs.closeSync(fd); }
326
+ if (!before) fsyncDirectory(this.root);
245
327
  // Cache the index only when the log grew by exactly this line on the same
246
328
  // file. After a rotation the live file is brand new, so the previous stat
247
329
  // is meaningless and the next append rebuilds from the archives instead.
248
- const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
330
+ const offset = before?.size || 0;
331
+ const expectedSize = offset + Buffer.byteLength(line);
249
332
  if (!rotated && after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
250
333
  index.ids.add(event.event_id);
251
- this.#indexes.set(file, { ids: index.ids, stat: after });
334
+ if (typeof event.idempotency_key === 'string' && !index.keys.has(event.idempotency_key)) {
335
+ index.keys.set(event.idempotency_key, { file, offset });
336
+ }
337
+ this.#indexes.set(file, { ids: index.ids, keys: index.keys, stat: after, partial: false });
252
338
  }
253
339
  return structuredClone(event);
254
340
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Commit intent journals (spec §6.3): a rollback journal for the window
3
+ * between the VFS commit (stage 9) and the event append (stage 10).
4
+ *
5
+ * Before stage 9 the kernel records an intent holding the document's prior
6
+ * content and holds the path's commit lock until stage 10 ends. An intent
7
+ * still present when its lock is free belongs to a writer that died; the
8
+ * kernel resolves it from the event log and the vault before the next commit
9
+ * on that path. A live holder's intent is never touched.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { withFileLock } from './file-lock.mjs';
15
+ import { fsyncDirectory, writeFileSyncDurable } from './durable-fs.mjs';
16
+
17
+ /**
18
+ * Commit locks wait like other file locks, but count as stale only after five
19
+ * minutes, far beyond the longest in-flight commit (two 40 s lock waits), so
20
+ * recovery can never roll back a commit that is still running. A holder that
21
+ * has exited on this host is detected at once.
22
+ */
23
+ export const INTENT_LOCK_DEFAULTS = Object.freeze({ timeoutMs: 40_000, staleMs: 5 * 60_000 });
24
+
25
+ /** Outcomes a resolver may return. `failed` keeps the intent for a later attempt. */
26
+ export const INTENT_OUTCOMES = Object.freeze(['committed', 'rolled_back', 'discarded', 'conflict', 'failed']);
27
+
28
+ /**
29
+ * In-process journal: serializes commits per path. Memory state cannot outlive
30
+ * a crash, so the only leftovers are intents a commit kept after its own
31
+ * rollback failed; those are resolved before the next commit on the path.
32
+ */
33
+ export class MemoryIntentJournal {
34
+ #tails = new Map();
35
+ #intents = new Map();
36
+ #held = new Set();
37
+
38
+ async run(vfsPath, intent, commit, { resolve }) {
39
+ const previous = this.#tails.get(vfsPath) || Promise.resolve();
40
+ let release;
41
+ const tail = previous.then(() => new Promise((done) => { release = done; }));
42
+ this.#tails.set(vfsPath, tail);
43
+ await previous;
44
+ this.#held.add(vfsPath);
45
+ try {
46
+ const leftover = this.#intents.get(vfsPath);
47
+ if (leftover) {
48
+ const settled = await resolve(structuredClone(leftover));
49
+ if (settled.outcome === 'failed') throw new Error(`Recovery of an earlier commit on '${vfsPath}' failed: ${settled.reason}`);
50
+ this.#intents.delete(vfsPath);
51
+ }
52
+ this.#intents.set(vfsPath, structuredClone(intent));
53
+ const result = await commit();
54
+ if (!result?.keepIntent) this.#intents.delete(vfsPath);
55
+ return result;
56
+ } finally {
57
+ this.#held.delete(vfsPath);
58
+ release();
59
+ if (this.#tails.get(vfsPath) === tail) this.#tails.delete(vfsPath);
60
+ }
61
+ }
62
+
63
+ async recover(resolve) {
64
+ const results = [];
65
+ for (const [vfsPath, intent] of [...this.#intents]) {
66
+ if (this.#held.has(vfsPath)) { results.push({ path: vfsPath, operation_id: intent.operation_id, outcome: 'in_flight' }); continue; }
67
+ const settled = await resolve(structuredClone(intent));
68
+ if (settled.outcome !== 'failed') this.#intents.delete(vfsPath);
69
+ results.push({ path: vfsPath, operation_id: intent.operation_id, ...settled });
70
+ }
71
+ return results;
72
+ }
73
+ }
74
+
75
+ const INTENT_FILE = /^[a-f0-9]{64}\.json$/;
76
+
77
+ /**
78
+ * File-backed journal, one intent and one lock per path under `root`. Intents
79
+ * are written to a flushed temp file and renamed, so a reader sees a complete
80
+ * intent or none. Unresolvable intents move to `root/unresolved/`.
81
+ */
82
+ export class FileIntentJournal {
83
+ constructor(root, options = {}) {
84
+ if (!root) throw new Error('FileIntentJournal requires a root directory.');
85
+ this.root = path.resolve(root);
86
+ this.lockOptions = { ...INTENT_LOCK_DEFAULTS, ...options.lock };
87
+ }
88
+
89
+ #checkRoot({ create }) {
90
+ let stat = null;
91
+ try { stat = fs.lstatSync(this.root); }
92
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
93
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new Error('Intent journal root must be a real directory.');
94
+ if (!stat && create) fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
95
+ return !!stat || create;
96
+ }
97
+
98
+ #files(name) {
99
+ return { intent: path.join(this.root, `${name}.json`), lock: path.join(this.root, `${name}.lock`) };
100
+ }
101
+
102
+ #name(vfsPath) { return crypto.createHash('sha256').update(vfsPath).digest('hex'); }
103
+
104
+ #read(file) {
105
+ let stat;
106
+ try { stat = fs.lstatSync(file); }
107
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
108
+ if (!stat.isFile()) return { unreadable: true };
109
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { unreadable: true }; }
110
+ }
111
+
112
+ #write(file, intent) {
113
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
114
+ try {
115
+ writeFileSyncDurable(temp, `${JSON.stringify(intent)}\n`);
116
+ fs.renameSync(temp, file);
117
+ fsyncDirectory(this.root);
118
+ } finally { fs.rmSync(temp, { force: true }); }
119
+ }
120
+
121
+ #quarantine(file, intent) {
122
+ const dir = path.join(this.root, 'unresolved');
123
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
124
+ const target = path.join(dir, `${path.basename(file, '.json')}-${intent?.operation_id || crypto.randomUUID()}.json`);
125
+ fs.renameSync(file, target);
126
+ fsyncDirectory(dir);
127
+ return path.relative(this.root, target).split(path.sep).join('/');
128
+ }
129
+
130
+ // Caller holds the path's lock. Temp files under the same name belong to a
131
+ // writer that held this lock and died, so they are removed here.
132
+ async #settle(name, resolve) {
133
+ const { intent: file } = this.#files(name);
134
+ for (const entry of fs.readdirSync(this.root)) {
135
+ if (entry.startsWith(`${name}.json.`) && entry.endsWith('.tmp')) fs.rmSync(path.join(this.root, entry), { force: true });
136
+ }
137
+ const leftover = this.#read(file);
138
+ if (!leftover) return null;
139
+ if (leftover.unreadable) {
140
+ return { path: null, operation_id: null, outcome: 'conflict', reason: 'Intent record is unreadable.', quarantined: this.#quarantine(file, null) };
141
+ }
142
+ const settled = await resolve(leftover);
143
+ const result = { path: leftover.path, operation_id: leftover.operation_id, ...settled };
144
+ if (settled.outcome === 'conflict') result.quarantined = this.#quarantine(file, leftover);
145
+ else if (settled.outcome !== 'failed') fs.rmSync(file, { force: true });
146
+ return result;
147
+ }
148
+
149
+ async run(vfsPath, intent, commit, { resolve }) {
150
+ this.#checkRoot({ create: true });
151
+ const name = this.#name(vfsPath);
152
+ const { intent: file, lock } = this.#files(name);
153
+ return withFileLock(lock, async () => {
154
+ const settled = await this.#settle(name, resolve);
155
+ if (settled?.outcome === 'failed') throw new Error(`Recovery of an earlier commit on '${vfsPath}' failed: ${settled.reason}`);
156
+ this.#write(file, intent);
157
+ // A throw leaves the intent in place: the commit's state is unknown, and
158
+ // the next recovery decides it from the log and the vault.
159
+ const result = await commit();
160
+ if (!result?.keepIntent) fs.rmSync(file, { force: true });
161
+ return result;
162
+ }, this.lockOptions);
163
+ }
164
+
165
+ /**
166
+ * Resolve every intent whose writer is gone. Intents whose lock is held by
167
+ * a live writer are reported as `in_flight` and left alone.
168
+ */
169
+ async recover(resolve) {
170
+ if (!this.#checkRoot({ create: false })) return [];
171
+ const results = [];
172
+ for (const entry of fs.readdirSync(this.root).sort()) {
173
+ if (!INTENT_FILE.test(entry)) continue;
174
+ const name = entry.slice(0, 64);
175
+ const { intent: file, lock } = this.#files(name);
176
+ try {
177
+ const settled = await withFileLock(lock, () => this.#settle(name, resolve), { ...this.lockOptions, timeoutMs: 0 });
178
+ if (settled) results.push(settled);
179
+ } catch (error) {
180
+ if (error.code !== 'SSSS_LOCK_TIMEOUT') throw error;
181
+ const intent = this.#read(file);
182
+ results.push({ path: intent?.path || null, operation_id: intent?.operation_id || null, outcome: 'in_flight' });
183
+ }
184
+ }
185
+ return results;
186
+ }
187
+ }
188
+
189
+ /** Shared contract for intent journals (spec §6.7). */
190
+ export async function runIntentJournalContract(journal) {
191
+ const checks = [];
192
+ const record = (name, passed) => checks.push({ name, passed: !!passed });
193
+ const target = `contract/${crypto.randomUUID()}.md`;
194
+ const intent = (id) => ({ version: 1, operation_id: id, path: target });
195
+ const seen = [];
196
+ const resolve = async (leftover) => { seen.push(leftover.operation_id); return { outcome: 'rolled_back' }; };
197
+
198
+ const result = await journal.run(target, intent('one'), async () => ({ ok: true }), { resolve });
199
+ record('run returns the commit result', result?.ok === true);
200
+ record('a completed commit leaves nothing to recover', (await journal.recover(resolve)).length === 0 && seen.length === 0);
201
+
202
+ await journal.run(target, intent('kept'), async () => ({ keepIntent: true }), { resolve });
203
+ await journal.run(target, intent('next'), async () => ({ ok: true }), { resolve });
204
+ record('a kept intent is resolved before the next commit on its path', seen.join() === 'kept');
205
+
206
+ let order = '';
207
+ const slow = journal.run(target, intent('slow'), async () => { order += 'a'; await new Promise((done) => setTimeout(done, 20)); order += 'b'; return {}; }, { resolve });
208
+ const fast = journal.run(target, intent('fast'), async () => { order += 'c'; return {}; }, { resolve });
209
+ await Promise.all([slow, fast]);
210
+ record('commits on one path are serialized', order === 'abc');
211
+
212
+ await journal.run(target, intent('stuck'), async () => ({ keepIntent: true }), { resolve });
213
+ const recovered = await journal.recover(resolve);
214
+ record('recover resolves a kept intent and reports it', recovered.length === 1 && recovered[0].outcome === 'rolled_back' && recovered[0].operation_id === 'stuck');
215
+ record('a resolved intent is not resolved again', (await journal.recover(resolve)).length === 0);
216
+ return { passed: checks.every((check) => check.passed), checks };
217
+ }