@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.
- package/CHANGELOG.md +60 -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
package/src/kernel.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './
|
|
|
9
9
|
import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
|
|
10
10
|
import { createCanonicalEvent, idempotentEventId } from './events.mjs';
|
|
11
11
|
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
12
|
+
import { MemoryIntentJournal } from './intents.mjs';
|
|
12
13
|
|
|
13
14
|
export { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
15
|
+
export { FileIntentJournal, MemoryIntentJournal } from './intents.mjs';
|
|
14
16
|
|
|
15
17
|
const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
|
|
16
18
|
|
|
@@ -25,6 +27,8 @@ export const ERROR_CODES = Object.freeze([
|
|
|
25
27
|
|
|
26
28
|
function isVfsError(error, name, code) { return error?.name === name || error?.code === code; }
|
|
27
29
|
|
|
30
|
+
function digestBytes(bytes) { return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; }
|
|
31
|
+
|
|
28
32
|
function stable(value) {
|
|
29
33
|
if (Array.isArray(value)) return value.map(stable);
|
|
30
34
|
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
@@ -59,6 +63,20 @@ function failure(code, envelope, operationId, errors, fieldErrors = []) {
|
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The success response of a committed envelope, rebuilt from its event. The
|
|
68
|
+
* request hash covers `type` and `path`, so those come from the envelope.
|
|
69
|
+
* Post-commit warnings are not in the log and are therefore absent.
|
|
70
|
+
*/
|
|
71
|
+
function responseFromEvent(envelope, event) {
|
|
72
|
+
return {
|
|
73
|
+
success: true, type: envelope.type, operation_id: event.operation_id, path: envelope.path,
|
|
74
|
+
committed_at: event.timestamp, dry_run: false,
|
|
75
|
+
event_id: event.event_id,
|
|
76
|
+
validation: { valid: true, type: event.primitive_id, errors: [], warnings: [] },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
function actionFor(envelope, exists, appendOnly) {
|
|
63
81
|
if (envelope.type === 'operation') return exists ? 'replace' : 'create';
|
|
64
82
|
if (envelope.type === 'patch') return appendOnly && envelope.patches?.__body__ !== undefined ? 'append' : 'patch';
|
|
@@ -81,6 +99,60 @@ export function createKernel(options = {}) {
|
|
|
81
99
|
if (!eventStore) throw new Error('createKernel requires an event store.');
|
|
82
100
|
const validator = options.validator || createValidator(options);
|
|
83
101
|
const registrySet = validator.registry;
|
|
102
|
+
// A VFS whose state outlives the process supplies its own journal, so hosts
|
|
103
|
+
// get crash recovery without wiring one (spec §6.7).
|
|
104
|
+
const intentJournal = options.intentJournal || vfs.intentJournal?.() || new MemoryIntentJournal();
|
|
105
|
+
|
|
106
|
+
async function eventCommitted(intent) {
|
|
107
|
+
if (typeof eventStore.findByIdempotencyKey === 'function') {
|
|
108
|
+
const first = await eventStore.findByIdempotencyKey(intent.workspace_id, intent.idempotency_key);
|
|
109
|
+
if (!first) return false;
|
|
110
|
+
if (first.event_id === intent.event_id) return true;
|
|
111
|
+
}
|
|
112
|
+
// A store without key lookup, or a key first used by another request:
|
|
113
|
+
// scan. This runs only after a crash.
|
|
114
|
+
for await (const { event } of eventStore.replay({ workspaceId: intent.workspace_id })) {
|
|
115
|
+
if (event.event_id === intent.event_id) return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decide a commit intent whose writer is gone (spec §6.3). The event is the
|
|
122
|
+
* commit point: if it is in the log the commit stands; if not, the vault is
|
|
123
|
+
* returned to the prior content. Digests are the kernel's own, over raw
|
|
124
|
+
* bytes, so the decision does not depend on a VFS's version scheme.
|
|
125
|
+
*/
|
|
126
|
+
async function resolveIntent(intent) {
|
|
127
|
+
try {
|
|
128
|
+
if (await eventCommitted(intent)) return { outcome: 'committed' };
|
|
129
|
+
const now = await vfs.read(intent.path);
|
|
130
|
+
const nowDigest = now ? digestBytes(now.bytes) : null;
|
|
131
|
+
const beforeDigest = intent.before?.exists ? intent.before.digest : null;
|
|
132
|
+
if (nowDigest === beforeDigest) return { outcome: 'discarded' };
|
|
133
|
+
if (nowDigest === (intent.after_digest ?? null)) {
|
|
134
|
+
if (intent.before?.exists) {
|
|
135
|
+
await vfs.writeAtomic(intent.path, Buffer.from(intent.before.bytes, 'base64'), now ? { version: now.version } : { ifAbsent: true });
|
|
136
|
+
} else {
|
|
137
|
+
await vfs.remove(intent.path, { version: now.version });
|
|
138
|
+
}
|
|
139
|
+
return { outcome: 'rolled_back' };
|
|
140
|
+
}
|
|
141
|
+
return { outcome: 'conflict', reason: 'The document changed outside the kernel after an uncommitted write.' };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { outcome: 'failed', reason: error.message };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve every commit intent whose writer is gone; report the rest. */
|
|
148
|
+
async function recover() {
|
|
149
|
+
return intentJournal.recover(resolveIntent);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Once per kernel, before its first operation, so state a crashed writer
|
|
153
|
+
// left behind is rolled back even on paths nobody writes again.
|
|
154
|
+
let recovery = null;
|
|
155
|
+
const recoverOnce = () => (recovery ||= recover().catch((error) => { recovery = null; throw error; }));
|
|
84
156
|
|
|
85
157
|
// Registry `references` are enforced on every write (spec §9). A host may
|
|
86
158
|
// supply its own resolver; otherwise targets are read through the VFS.
|
|
@@ -132,15 +204,28 @@ export function createKernel(options = {}) {
|
|
|
132
204
|
return failure('unauthorized', envelope, operationId, ['Access denied: Verified principal is required.'], [{ field: 'principal', issue: 'Verified principal is required.' }]);
|
|
133
205
|
}
|
|
134
206
|
|
|
207
|
+
try { await recoverOnce(); }
|
|
208
|
+
catch (error) { return failure('internal_error', envelope, operationId, [`Commit recovery failed: ${error.message}`]); }
|
|
209
|
+
|
|
135
210
|
const requestHash = canonicalRequestHash(envelope, principal);
|
|
136
|
-
|
|
211
|
+
const keyConflict = () => failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
212
|
+
// A recorded result for this key: replay it, or refuse a different request.
|
|
213
|
+
// The idempotency store is a cache of full responses; the event log is the
|
|
214
|
+
// durable record of every committed key (spec §6.4), so a cache miss after
|
|
215
|
+
// retention, a lost record, or a new process still finds the commit.
|
|
137
216
|
const settled = async () => {
|
|
138
217
|
const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
|
|
139
|
-
if (
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
218
|
+
if (stored) return stored.request_hash === requestHash ? { ...stored.response, replay: true } : keyConflict();
|
|
219
|
+
if (typeof eventStore.findByIdempotencyKey !== 'function') return null;
|
|
220
|
+
let committed;
|
|
221
|
+
try { committed = await eventStore.findByIdempotencyKey(envelope.workspace_id, envelope.idempotency_key); }
|
|
222
|
+
catch (error) { return failure('internal_error', envelope, operationId, [`Event log is unreadable: ${error.message}`]); }
|
|
223
|
+
if (!committed) return null;
|
|
224
|
+
// An event without a request hash cannot prove it was this request.
|
|
225
|
+
if (committed.request_hash !== requestHash) return keyConflict();
|
|
226
|
+
const response = responseFromEvent(envelope, committed);
|
|
227
|
+
try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); } catch {}
|
|
228
|
+
return { ...response, replay: true };
|
|
144
229
|
};
|
|
145
230
|
const replay = await settled();
|
|
146
231
|
if (replay) return replay;
|
|
@@ -295,61 +380,97 @@ export function createKernel(options = {}) {
|
|
|
295
380
|
};
|
|
296
381
|
}
|
|
297
382
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
383
|
+
// `event` envelopes have no VFS compare-and-swap, so a key-derived id lets
|
|
384
|
+
// the event store reject a concurrent request with the same key. Other ids
|
|
385
|
+
// are assigned now so the commit intent can name the event it expects.
|
|
386
|
+
const eventId = envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : crypto.randomUUID();
|
|
387
|
+
let committedEvent = null;
|
|
388
|
+
|
|
389
|
+
// Stages 9 and 10. Returns { event } on commit, or { response, keepIntent }.
|
|
390
|
+
const commitAndAppend = async () => {
|
|
391
|
+
let commit = null;
|
|
392
|
+
let event;
|
|
393
|
+
try {
|
|
394
|
+
if (envelope.type === 'operation') {
|
|
395
|
+
commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
|
|
396
|
+
} else if (envelope.type === 'patch') {
|
|
397
|
+
commit = await vfs.writeAtomic(envelope.path, nextContent, { version: current.version });
|
|
398
|
+
} else if (envelope.type === 'delete') {
|
|
399
|
+
commit = await vfs.remove(envelope.path, { version: current.version });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
event = createCanonicalEvent({
|
|
403
|
+
event_id: eventId,
|
|
404
|
+
workspace_id: envelope.workspace_id,
|
|
405
|
+
primitive_id: definition.qualified_type,
|
|
406
|
+
primitive_version: definition.primitive_version || 1,
|
|
407
|
+
action,
|
|
408
|
+
subject: envelope.path,
|
|
409
|
+
principal,
|
|
410
|
+
operation_id: operationId,
|
|
411
|
+
idempotency_key: envelope.idempotency_key,
|
|
412
|
+
request_hash: requestHash,
|
|
413
|
+
before_hash: current?.hash || null,
|
|
414
|
+
after_hash: commit?.hash || null,
|
|
415
|
+
changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
|
|
416
|
+
resource_status: prepared ? 'prepared' : null,
|
|
417
|
+
payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
|
|
418
|
+
}, { clock });
|
|
419
|
+
await eventStore.append(event);
|
|
420
|
+
committedEvent = event;
|
|
421
|
+
return { event };
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (prepared) await reconcile('commit', error);
|
|
424
|
+
// Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
|
|
425
|
+
if (commit && envelope.type !== 'event') {
|
|
426
|
+
try {
|
|
427
|
+
if (!current) await vfs.remove(envelope.path, { version: commit.version });
|
|
428
|
+
else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
|
|
429
|
+
} catch (rollbackError) {
|
|
430
|
+
// The intent stays, so the next commit on this path or recover()
|
|
431
|
+
// finishes the rollback.
|
|
432
|
+
return { keepIntent: true, response: failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]) };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// A concurrent request with the same key may have won the commit (VFS
|
|
436
|
+
// compare-and-swap or duplicate event_id); answer with its result.
|
|
437
|
+
try {
|
|
438
|
+
const winner = await settled();
|
|
439
|
+
if (winner) return { response: winner };
|
|
440
|
+
} catch {}
|
|
441
|
+
const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
|
|
442
|
+
return { response: failure(code, envelope, operationId, [`Commit failed: ${error.message}`]) };
|
|
307
443
|
}
|
|
444
|
+
};
|
|
308
445
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
before_hash: current?.hash || null,
|
|
323
|
-
after_hash: commit?.hash || null,
|
|
324
|
-
changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
|
|
325
|
-
resource_status: prepared ? 'prepared' : null,
|
|
326
|
-
payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
|
|
327
|
-
}, { clock });
|
|
328
|
-
await eventStore.append(event);
|
|
446
|
+
// Everything but an `event` envelope writes the VFS, so it commits under a
|
|
447
|
+
// durable intent holding the prior content (spec §6.3).
|
|
448
|
+
const intent = envelope.type === 'event' ? null : {
|
|
449
|
+
version: 1, operation_id: operationId, event_id: eventId, workspace_id: envelope.workspace_id,
|
|
450
|
+
path: envelope.path, action, idempotency_key: envelope.idempotency_key, request_hash: requestHash,
|
|
451
|
+
before: current ? { exists: true, digest: digestBytes(current.bytes), bytes: current.bytes.toString('base64') } : { exists: false, digest: null },
|
|
452
|
+
after_digest: envelope.type === 'delete' ? null : digestBytes(Buffer.from(nextContent, 'utf8')),
|
|
453
|
+
started_at: clock(),
|
|
454
|
+
};
|
|
455
|
+
const warnings = [];
|
|
456
|
+
let outcome;
|
|
457
|
+
try {
|
|
458
|
+
outcome = intent ? await intentJournal.run(envelope.path, intent, commitAndAppend, { resolve: resolveIntent }) : await commitAndAppend();
|
|
329
459
|
} catch (error) {
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
|
|
336
|
-
} catch (rollbackError) {
|
|
337
|
-
return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
|
|
338
|
-
}
|
|
460
|
+
// Before the commit (lock, intent write, an earlier writer's recovery):
|
|
461
|
+
// nothing happened. After it, the event already says it happened.
|
|
462
|
+
if (!committedEvent) {
|
|
463
|
+
if (prepared) await reconcile('commit', error);
|
|
464
|
+
return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
339
465
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const winner = await settled();
|
|
344
|
-
if (winner) return winner;
|
|
345
|
-
} catch {}
|
|
346
|
-
const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
|
|
347
|
-
return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
466
|
+
outcome = { event: committedEvent };
|
|
467
|
+
warnings.push(`Commit intent cleanup failed: ${error.message}`);
|
|
348
468
|
}
|
|
469
|
+
if (outcome.response) return outcome.response;
|
|
470
|
+
const { event } = outcome;
|
|
349
471
|
|
|
350
472
|
// The appended event is the commit point (spec §6.3): nothing after it may
|
|
351
473
|
// roll the mutation back. Post-commit failures become warnings.
|
|
352
|
-
const warnings = [];
|
|
353
474
|
if (resourceCoordinator?.finalize) {
|
|
354
475
|
try { await resourceCoordinator.finalize({ resourceState, event }); }
|
|
355
476
|
catch (error) {
|
|
@@ -375,5 +496,5 @@ export function createKernel(options = {}) {
|
|
|
375
496
|
return response;
|
|
376
497
|
}
|
|
377
498
|
|
|
378
|
-
return { execute, registry: registrySet, validator };
|
|
499
|
+
return { execute, recover, registry: registrySet, validator };
|
|
379
500
|
}
|
package/src/vfs.mjs
CHANGED
|
@@ -4,10 +4,17 @@ import path from 'node:path';
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { isSafeDocumentPath } from './registry.mjs';
|
|
6
6
|
import { withFileLock } from './file-lock.mjs';
|
|
7
|
+
import { fsyncDirectory, writeFileSyncDurable } from './durable-fs.mjs';
|
|
8
|
+
import { FileIntentJournal } from './intents.mjs';
|
|
7
9
|
|
|
8
10
|
/** Reserved FileSystemVfs directory for per-path write locks; never a document path. */
|
|
9
11
|
export const VFS_LOCK_DIR = '.ssss-locks';
|
|
10
12
|
|
|
13
|
+
/** Reserved FileSystemVfs directory for commit intents (spec §6.3); never a document path. */
|
|
14
|
+
export const VFS_INTENT_DIR = '.ssss-intents';
|
|
15
|
+
|
|
16
|
+
const RESERVED_DIRS = new Set([VFS_LOCK_DIR, VFS_INTENT_DIR]);
|
|
17
|
+
|
|
11
18
|
export class VfsConflictError extends Error {
|
|
12
19
|
constructor(message) { super(message); this.name = 'VfsConflictError'; this.code = 'SSSS_VFS_CONFLICT'; }
|
|
13
20
|
}
|
|
@@ -86,6 +93,14 @@ export class FileSystemVfs {
|
|
|
86
93
|
fs.mkdirSync(root, { recursive: true });
|
|
87
94
|
this.root = fs.realpathSync(root);
|
|
88
95
|
this.lockOptions = options.lock || {};
|
|
96
|
+
this.intentLockOptions = options.intentLock || {};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#journal = null;
|
|
100
|
+
|
|
101
|
+
/** The commit intent journal co-located with this vault (spec §6.3, §6.7). */
|
|
102
|
+
intentJournal() {
|
|
103
|
+
return (this.#journal ||= new FileIntentJournal(path.join(this.root, VFS_INTENT_DIR), { lock: this.intentLockOptions }));
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
// Writers to one path serialize on a lock so precondition checks and commits
|
|
@@ -102,7 +117,7 @@ export class FileSystemVfs {
|
|
|
102
117
|
#resolve(vfsPath, { allowMissing = true } = {}) {
|
|
103
118
|
if (!isSafeDocumentPath(vfsPath)) throw new VfsPathError(`Unsafe VFS path '${vfsPath}'.`);
|
|
104
119
|
// Case-insensitive: on APFS/NTFS any casing reaches the same directory.
|
|
105
|
-
if (vfsPath.split('/')[0].toLowerCase()
|
|
120
|
+
if (RESERVED_DIRS.has(vfsPath.split('/')[0].toLowerCase())) throw new VfsPathError(`VFS path '${vfsPath}' is reserved.`);
|
|
106
121
|
const target = path.resolve(this.root, ...vfsPath.split('/'));
|
|
107
122
|
if (target === this.root || !target.startsWith(`${this.root}${path.sep}`)) {
|
|
108
123
|
throw new VfsPathError(`VFS path '${vfsPath}' escapes the root.`);
|
|
@@ -149,7 +164,7 @@ export class FileSystemVfs {
|
|
|
149
164
|
while (stack.length) {
|
|
150
165
|
const dir = stack.pop();
|
|
151
166
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
152
|
-
if (dir === this.root && entry.name
|
|
167
|
+
if (dir === this.root && RESERVED_DIRS.has(entry.name)) continue;
|
|
153
168
|
const absolute = path.join(dir, entry.name);
|
|
154
169
|
const relative = path.relative(this.root, absolute).split(path.sep).join('/');
|
|
155
170
|
if (entry.isSymbolicLink()) throw new VfsPathError(`Symlinked VFS entry is forbidden: '${relative}'.`);
|
|
@@ -177,8 +192,11 @@ export class FileSystemVfs {
|
|
|
177
192
|
const nextBytes = asBuffer(bytes);
|
|
178
193
|
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
|
179
194
|
try {
|
|
180
|
-
|
|
195
|
+
// Flushed before the rename and the directory after it, so the commit is
|
|
196
|
+
// on disk before the kernel appends its event (spec §6.3).
|
|
197
|
+
writeFileSyncDurable(temp, nextBytes);
|
|
181
198
|
fs.renameSync(temp, target);
|
|
199
|
+
fsyncDirectory(path.dirname(target));
|
|
182
200
|
} finally {
|
|
183
201
|
fs.rmSync(temp, { force: true });
|
|
184
202
|
}
|
|
@@ -201,6 +219,7 @@ export class FileSystemVfs {
|
|
|
201
219
|
checkPrecondition(current, precondition);
|
|
202
220
|
if (!target || !current) return { path: vfsPath, removed: false };
|
|
203
221
|
fs.rmSync(target);
|
|
222
|
+
fsyncDirectory(path.dirname(target));
|
|
204
223
|
return { path: vfsPath, removed: true, previous_hash: current.hash };
|
|
205
224
|
});
|
|
206
225
|
}
|
package/types/kernel.d.ts
CHANGED
|
@@ -40,16 +40,45 @@ export interface SsssResponse {
|
|
|
40
40
|
repair?: { field_errors: Array<{ field: string; issue: string }> };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** How recovery decided one commit intent (spec §6.3). */
|
|
44
|
+
export type SsssIntentOutcome = 'committed' | 'rolled_back' | 'discarded' | 'conflict' | 'failed' | 'in_flight';
|
|
45
|
+
|
|
46
|
+
export interface SsssRecoveryResult {
|
|
47
|
+
path: string | null;
|
|
48
|
+
operation_id: string | null;
|
|
49
|
+
outcome: SsssIntentOutcome;
|
|
50
|
+
reason?: string;
|
|
51
|
+
/** Journal-relative location of an intent moved aside as unresolvable. */
|
|
52
|
+
quarantined?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
export interface SsssKernel {
|
|
44
56
|
execute(command: SsssCommand, context: { principal: VerifiedPrincipal; [key: string]: unknown }): Promise<SsssResponse>;
|
|
57
|
+
/** Resolve commit intents whose writer is gone; report live ones as `in_flight`. */
|
|
58
|
+
recover(): Promise<SsssRecoveryResult[]>;
|
|
45
59
|
registry: unknown;
|
|
46
60
|
validator: unknown;
|
|
47
61
|
}
|
|
48
62
|
|
|
63
|
+
/** Commit intent journal (spec §6.7). */
|
|
64
|
+
export interface SsssIntentJournal {
|
|
65
|
+
run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
|
|
66
|
+
recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
export const ERROR_CODES: readonly SsssErrorCode[];
|
|
50
70
|
export function canonicalRequestHash(command: SsssCommand, principal: VerifiedPrincipal): string;
|
|
51
71
|
export class MemoryIdempotencyStore {
|
|
52
72
|
get(workspaceId: string, idempotencyKey: string): Promise<unknown>;
|
|
53
73
|
put(workspaceId: string, idempotencyKey: string, value: unknown): Promise<void>;
|
|
54
74
|
}
|
|
75
|
+
export class MemoryIntentJournal implements SsssIntentJournal {
|
|
76
|
+
run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
|
|
77
|
+
recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
|
|
78
|
+
}
|
|
79
|
+
export class FileIntentJournal implements SsssIntentJournal {
|
|
80
|
+
constructor(root: string, options?: { lock?: { timeoutMs?: number; staleMs?: number } });
|
|
81
|
+
run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
|
|
82
|
+
recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
|
|
83
|
+
}
|
|
55
84
|
export function createKernel(options: Record<string, unknown>): SsssKernel;
|