@gregiteen/ssss-cli 0.11.2 → 0.12.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 +17 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/package.json +1 -1
- package/scripts/conformance-09.mjs +27 -0
- package/scripts/conformance.mjs +29 -0
- package/skills/ssss/SKILL.md +5 -0
- package/src/bundle.mjs +1 -1
- package/src/idempotency.mjs +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.1] - 2026-10-29
|
|
11
|
+
### Fixed
|
|
12
|
+
- Republish under a new version: npm rejected 0.12.0 as a previously staged
|
|
13
|
+
version, so the retention change ships as 0.12.1 with no other difference.
|
|
14
|
+
|
|
15
|
+
## [0.12.0] - 2026-10-29
|
|
16
|
+
### Added
|
|
17
|
+
- `FileIdempotencyStore` now expires entries. It accepts `retentionMs` (default
|
|
18
|
+
24 hours, `0` disables) and `pruneEvery` (default every 500 puts), and exposes
|
|
19
|
+
`prune()`, which removes entries older than the window and returns the count.
|
|
20
|
+
Retention runs opportunistically inside `put` and never rejects a committed
|
|
21
|
+
operation. Previously entries were written and never removed, so a directory
|
|
22
|
+
only grew: one frequently rewritten document produced 14,438 records from a
|
|
23
|
+
single path, and a live vault reached 283k files / 1.2 GB, which slowed every
|
|
24
|
+
traversal of it. A retry arrives within minutes, so a bounded window loses
|
|
25
|
+
nothing that mattered. Lock and temp files are never treated as entries.
|
|
26
|
+
|
|
10
27
|
## [0.11.2] - 2026-10-19
|
|
11
28
|
### Added
|
|
12
29
|
- `parseFrontmatter(fm, { strict: true })` throws a `SyntaxError` on lines that
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.12.1
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"file_count": 14,
|
|
107
107
|
"provenance": {
|
|
108
108
|
"content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
|
|
109
|
-
"exporter": "@gregiteen/ssss-cli@0.
|
|
109
|
+
"exporter": "@gregiteen/ssss-cli@0.12.1"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -123,6 +124,32 @@ async function checkFileAdapterRaces(root, principal, check) {
|
|
|
123
124
|
check('filesystem idempotency store accepts one of several processes putting the same key',
|
|
124
125
|
winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
|
|
125
126
|
|
|
127
|
+
// Retention: an entry exists to reject a retry, which arrives within minutes,
|
|
128
|
+
// so entries expire rather than living forever. Without this a single
|
|
129
|
+
// frequently rewritten document produced 14,438 records and a live vault
|
|
130
|
+
// reached 283k files / 1.2 GB, slowing every traversal of the vault.
|
|
131
|
+
const retentionRoot = path.join(root, 'idempotency-retention');
|
|
132
|
+
const retention = new FileIdempotencyStore(retentionRoot, { retentionMs: 60_000, pruneEvery: 1 });
|
|
133
|
+
await retention.put('acme', 'fresh', { request_hash: 'sha256:fresh' });
|
|
134
|
+
const entryName = (workspaceId, idempotencyKey) =>
|
|
135
|
+
`${crypto.createHash('sha256').update(JSON.stringify([workspaceId, idempotencyKey])).digest('hex')}.json`;
|
|
136
|
+
const staleFile = path.join(retentionRoot, entryName('acme', 'stale'));
|
|
137
|
+
fs.writeFileSync(staleFile, '{"request_hash":"sha256:stale"}\n', { mode: 0o600 });
|
|
138
|
+
const aged = new Date(Date.now() - 10 * 60 * 1000);
|
|
139
|
+
fs.utimesSync(staleFile, aged, aged);
|
|
140
|
+
const removed = retention.prune();
|
|
141
|
+
check('filesystem idempotency store expires entries past the retention window',
|
|
142
|
+
removed === 1 && !fs.existsSync(staleFile), `removed ${removed}`);
|
|
143
|
+
check('filesystem idempotency store keeps entries inside the retention window',
|
|
144
|
+
(await retention.get('acme', 'fresh'))?.request_hash === 'sha256:fresh');
|
|
145
|
+
check('filesystem idempotency store skips retention when the window is disabled',
|
|
146
|
+
new FileIdempotencyStore(retentionRoot, { retentionMs: 0 }).prune() === 0);
|
|
147
|
+
const lockName = path.join(retentionRoot, `${entryName('acme', 'locked')}.lock`);
|
|
148
|
+
fs.writeFileSync(lockName, '');
|
|
149
|
+
const lockSurvived = retention.prune() === 0 && fs.existsSync(lockName);
|
|
150
|
+
fs.rmSync(lockName, { force: true });
|
|
151
|
+
check('filesystem idempotency store never treats a lock file as an entry', lockSurvived);
|
|
152
|
+
|
|
126
153
|
const kernelRoot = path.join(root, 'kernel');
|
|
127
154
|
const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
|
|
128
155
|
const stores = () => ({
|
package/scripts/conformance.mjs
CHANGED
|
@@ -346,6 +346,35 @@ async function runBundleConformance() {
|
|
|
346
346
|
fs.rmSync(atomicVault, { recursive: true, force: true });
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
const referenceVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-refs-'));
|
|
350
|
+
const missingVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-missing-'));
|
|
351
|
+
try {
|
|
352
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
353
|
+
const envelope = (id, target, fields) => ({
|
|
354
|
+
type: 'operation', workspace_id: 'ws-refs', idempotency_key: id,
|
|
355
|
+
path: target, actor: { role: 'system' },
|
|
356
|
+
content: ['---', 'title: Reference test', 'description: Complete planned references.',
|
|
357
|
+
'timestamp: 2026-10-28T00:00:00Z', ...fields, '---', '', 'Exact payload.', ''].join('\n'),
|
|
358
|
+
});
|
|
359
|
+
const asset = envelope('ref-asset', 'assets/source.md', [
|
|
360
|
+
'type: asset', 'name: source', 'mime_type: text/plain', 'encoding: utf-8',
|
|
361
|
+
'resource_ref: resources/store.md',
|
|
362
|
+
]);
|
|
363
|
+
const resource = envelope('ref-resource', 'resources/store.md', [
|
|
364
|
+
'type: resource', 'name: store', 'kind: storage', 'status: bound',
|
|
365
|
+
]);
|
|
366
|
+
const imported = await importBundle([asset, resource], referenceVault, engine);
|
|
367
|
+
check('fresh import resolves a forward reference from the complete plan',
|
|
368
|
+
imported.ok && imported.committed === 2,
|
|
369
|
+
JSON.stringify(imported.results.map(item => item.validation?.errors || [])));
|
|
370
|
+
const missing = await importBundle([asset], missingVault, engine);
|
|
371
|
+
check('missing planned reference rejects import without partial writes',
|
|
372
|
+
!missing.ok && missing.committed === 0 && !fs.existsSync(path.join(missingVault, 'assets/source.md')));
|
|
373
|
+
} finally {
|
|
374
|
+
fs.rmSync(referenceVault, { recursive: true, force: true });
|
|
375
|
+
fs.rmSync(missingVault, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
|
|
349
378
|
let pass = 0;
|
|
350
379
|
for (const c of checks) {
|
|
351
380
|
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
package/skills/ssss/SKILL.md
CHANGED
|
@@ -42,6 +42,11 @@ When an SSSS engine processes a write, it receives an **Envelope**:
|
|
|
42
42
|
### 4. Bundles (.ucw)
|
|
43
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
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
|
+
|
|
45
50
|
## Validation
|
|
46
51
|
|
|
47
52
|
If you are writing tests or schemas in a host repository, ensure that:
|
package/src/bundle.mjs
CHANGED
|
@@ -508,7 +508,7 @@ export async function importBundle(plan, vaultRoot, engine) {
|
|
|
508
508
|
});
|
|
509
509
|
}
|
|
510
510
|
const operationOptions = {
|
|
511
|
-
|
|
511
|
+
resolveReference: (referencePath) => plannedDocuments.get(referencePath),
|
|
512
512
|
};
|
|
513
513
|
// Two-phase import: validate the complete plan with in-plan references first.
|
|
514
514
|
// A single invalid document prevents every commit, eliminating partial bundle
|
package/src/idempotency.mjs
CHANGED
|
@@ -6,6 +6,17 @@ import { withFileLock } from './file-lock.mjs';
|
|
|
6
6
|
|
|
7
7
|
function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How long a completed operation stays replayable. A retry of the same request
|
|
11
|
+
* arrives within minutes (a network timeout, an agent restart); nothing needs a
|
|
12
|
+
* day-old result, and retaining them forever is what filled a live vault with
|
|
13
|
+
* 283k files. Override per store with `new FileIdempotencyStore(root, { retentionMs })`.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/** Prune once every N puts, so retention costs one directory scan per N writes. */
|
|
18
|
+
export const DEFAULT_PRUNE_EVERY = 500;
|
|
19
|
+
|
|
9
20
|
export class MemoryIdempotencyStore {
|
|
10
21
|
#entries = new Map();
|
|
11
22
|
async get(workspaceId, idempotencyKey) { return structuredClone(this.#entries.get(key(workspaceId, idempotencyKey)) || null); }
|
|
@@ -21,6 +32,18 @@ export class FileIdempotencyStore {
|
|
|
21
32
|
fs.mkdirSync(root, { recursive: true });
|
|
22
33
|
this.root = fs.realpathSync(root);
|
|
23
34
|
this.lockOptions = options.lock || {};
|
|
35
|
+
// An idempotency entry exists to reject a retry of the same request. That
|
|
36
|
+
// window is minutes, not forever, so entries are retained for a bounded age
|
|
37
|
+
// and then removed. Without this the directory only grows: one frequently
|
|
38
|
+
// rewritten document created 14,438 records from a single path, and a live
|
|
39
|
+
// vault reached 283k files / 1.2 GB, which slowed every traversal of it.
|
|
40
|
+
this.retentionMs = Number.isFinite(options.retentionMs) && options.retentionMs >= 0
|
|
41
|
+
? options.retentionMs
|
|
42
|
+
: DEFAULT_RETENTION_MS;
|
|
43
|
+
this.pruneEvery = Number.isInteger(options.pruneEvery) && options.pruneEvery > 0
|
|
44
|
+
? options.pruneEvery
|
|
45
|
+
: DEFAULT_PRUNE_EVERY;
|
|
46
|
+
this.puts = 0;
|
|
24
47
|
}
|
|
25
48
|
#file(workspaceId, idempotencyKey) {
|
|
26
49
|
const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
|
|
@@ -44,8 +67,37 @@ export class FileIdempotencyStore {
|
|
|
44
67
|
fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
|
|
45
68
|
fs.renameSync(temp, file);
|
|
46
69
|
} finally { fs.rmSync(temp, { force: true }); }
|
|
70
|
+
// Opportunistic retention: no scheduler, and the cost is amortised over
|
|
71
|
+
// putPushes. Never throws — a prune failure must not reject a committed
|
|
72
|
+
// operation, whose result the caller is about to rely on.
|
|
73
|
+
this.puts += 1;
|
|
74
|
+
if (this.retentionMs > 0 && this.puts % this.pruneEvery === 0) {
|
|
75
|
+
try { this.prune(); } catch { /* retention is best-effort */ }
|
|
76
|
+
}
|
|
47
77
|
}, this.lockOptions);
|
|
48
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Remove entries older than the retention window. Returns the count removed.
|
|
81
|
+
* Only regular `*.json` files are considered; lock files and temp files are
|
|
82
|
+
* left alone so a concurrent writer is never disturbed.
|
|
83
|
+
*/
|
|
84
|
+
prune(now = Date.now()) {
|
|
85
|
+
if (!(this.retentionMs > 0)) return 0;
|
|
86
|
+
const cutoff = now - this.retentionMs;
|
|
87
|
+
let removed = 0;
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = fs.readdirSync(this.root); } catch { return 0; }
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
if (!name.endsWith('.json')) continue;
|
|
92
|
+
const file = path.join(this.root, name);
|
|
93
|
+
try {
|
|
94
|
+
const stat = fs.lstatSync(file);
|
|
95
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
96
|
+
if (stat.mtimeMs < cutoff) { fs.rmSync(file, { force: true }); removed += 1; }
|
|
97
|
+
} catch { /* a vanished or unreadable entry is not a prune failure */ }
|
|
98
|
+
}
|
|
99
|
+
return removed;
|
|
100
|
+
}
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
export async function runIdempotencyContract(store) {
|