@gregiteen/ssss-cli 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +50 -7
- package/package.json +3 -2
- package/scripts/check-commit-durability.mjs +392 -0
- package/scripts/cmd-adapter.mjs +3 -0
- package/skills/ssss/SKILL.md +6 -47
- package/skills/ssss/core/SKILL.md +18 -0
- package/skills/ssss/core/references/core-registry.json +333 -0
- package/skills/ssss/core/references/ssss-spec.md +1959 -0
- package/skills/ssss/core/scripts/maintain.mjs +75 -0
- package/skills/ssss/evals/maintain.test.mjs +11 -0
- package/skills/ssss/references/protocol-reference-legacy.md +54 -0
- package/src/bundle.mjs +4 -1
- package/src/durable-fs.mjs +32 -0
- package/src/engine.mjs +4 -0
- package/src/events.mjs +119 -33
- package/src/intents.mjs +217 -0
- package/src/kernel.mjs +175 -54
- package/src/vfs.mjs +22 -3
- package/types/kernel.d.ts +29 -0
|
@@ -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
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
/**
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
stat = null;
|
|
204
260
|
}
|
|
205
|
-
if (stat
|
|
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
|
-
|
|
213
|
-
const
|
|
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
|
-
|
|
278
|
+
merge(loadIndex(archive));
|
|
219
279
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/intents.mjs
ADDED
|
@@ -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
|
+
}
|