@vincemakes/kiso-runtime 0.1.36 → 0.1.38

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/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./lock-adapter.js";
8
9
  export * from "./ledger.js";
9
10
  export * from "./extensions.js";
10
11
  export * from "./trust.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./lock-adapter.js";
8
9
  export * from "./ledger.js";
9
10
  export * from "./extensions.js";
10
11
  export * from "./trust.js";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ /** A live foreign writer owns the lock — never taken over. */
65
+ export declare class LockedError extends Error {
66
+ constructor(message: string);
67
+ }
68
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
69
+ export declare class LockUnavailableError extends Error {
70
+ constructor(reason: string);
71
+ }
72
+ export interface LockHandle {
73
+ readonly pid: number;
74
+ readonly token: string;
75
+ /** True iff the lock file at the path still names THIS identity. */
76
+ verify(): boolean;
77
+ /** Idempotent release; the path is left as the empty released marker. */
78
+ release(): void;
79
+ }
80
+ export interface LockAdapter {
81
+ readonly name: string;
82
+ /**
83
+ * Take (or take over) the lock at lockPath. Rejects with LockedError
84
+ * when a live foreign writer owns it (or the same-process-residue retry
85
+ * cap is hit), LockUnavailableError when the mechanism cannot operate.
86
+ * cancelled() is invoked at each retry decision and may throw to abort
87
+ * the acquisition (the store's lifecycle barrier).
88
+ */
89
+ acquire(lockPath: string, sessionId: string, cancelled: () => void): Promise<LockHandle>;
90
+ }
91
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
92
+ export declare const nativeLockAdapter: LockAdapter;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ import { closeSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
65
+ import { randomUUID } from "node:crypto";
66
+ import { join } from "node:path";
67
+ /** A live foreign writer owns the lock — never taken over. */
68
+ export class LockedError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "LockedError";
72
+ }
73
+ }
74
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
75
+ export class LockUnavailableError extends Error {
76
+ constructor(reason) {
77
+ super(`session locking unavailable: ${reason}`);
78
+ this.name = "LockUnavailableError";
79
+ }
80
+ }
81
+ function isAlive(pid) {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ }
86
+ catch (err) {
87
+ return err.code === "EPERM";
88
+ }
89
+ }
90
+ /**
91
+ * Read a lock file's holder identity (round 4 formats, unchanged — the
92
+ * cross-version channel). Empty, unreadable, or half-written locks have no
93
+ * identity: they are residue, taken over (ADR-0050 §migration).
94
+ */
95
+ function readLockIdentity(lockPath) {
96
+ let raw;
97
+ try {
98
+ raw = readFileSync(lockPath, "utf8");
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ const trimmed = raw.trim();
104
+ if (trimmed === "")
105
+ return null;
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(trimmed);
109
+ }
110
+ catch {
111
+ parsed = trimmed; // half-written JSON — try as a bare pid
112
+ }
113
+ if (typeof parsed === "number" && Number.isInteger(parsed)) {
114
+ return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
115
+ }
116
+ if (typeof parsed === "string") {
117
+ const pid = Number.parseInt(parsed, 10);
118
+ return Number.isFinite(pid) ? { pid } : null;
119
+ }
120
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
121
+ const v = parsed;
122
+ return {
123
+ ...(typeof v.pid === "number" ? { pid: v.pid } : {}),
124
+ ...(typeof v.token === "string" ? { token: v.token } : {}),
125
+ };
126
+ }
127
+ return null;
128
+ }
129
+ function sameIdentity(a, b) {
130
+ if (a === null && b === null)
131
+ return true;
132
+ if (a === null || b === null)
133
+ return false;
134
+ return a.pid === b.pid && (a.token ?? null) === (b.token ?? null);
135
+ }
136
+ function unavailable(err) {
137
+ const e = err;
138
+ return new LockUnavailableError(`${e.code ?? "?"}: ${e.message}`);
139
+ }
140
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
141
+ class NativeLockHandle {
142
+ pid;
143
+ token;
144
+ #lockPath;
145
+ #tombstone;
146
+ #released = false;
147
+ constructor(lockPath, tombstone, identity) {
148
+ this.#lockPath = lockPath;
149
+ this.#tombstone = tombstone;
150
+ this.pid = identity.pid;
151
+ this.token = identity.token;
152
+ }
153
+ verify() {
154
+ if (this.#released)
155
+ return false;
156
+ const now = readLockIdentity(this.#lockPath);
157
+ return now !== null && now.pid === this.pid && now.token === this.token;
158
+ }
159
+ release() {
160
+ if (this.#released)
161
+ return;
162
+ this.#released = true;
163
+ try {
164
+ renameSync(this.#lockPath, this.#tombstone);
165
+ }
166
+ catch (err) {
167
+ if (err.code === "ENOENT")
168
+ return; // nothing of ours at the path
169
+ // A fs-level failure: fall back to clearing in place (advisory
170
+ // best effort — a missing marker can only wedge same-process
171
+ // residue, never split a writer).
172
+ try {
173
+ writeFileSync(this.#lockPath, "");
174
+ }
175
+ catch {
176
+ // advisory only
177
+ }
178
+ return;
179
+ }
180
+ const moved = readLockIdentity(this.#tombstone);
181
+ if (moved !== null && moved.pid === this.pid && moved.token === this.token) {
182
+ // Mine. Leave the EMPTY released marker — the path is never
183
+ // deleted (a contender must be able to read it; the
184
+ // storage-identity suite pins it) — created ONLY-if-absent, so
185
+ // a rival that linked in the window keeps its lock untouched.
186
+ try {
187
+ const fd = openSync(this.#lockPath, "wx");
188
+ closeSync(fd);
189
+ }
190
+ catch (err) {
191
+ // EEXIST → a rival's lock stands; anything else → advisory.
192
+ }
193
+ try {
194
+ unlinkSync(this.#tombstone);
195
+ }
196
+ catch {
197
+ // the stray tombstone is inert
198
+ }
199
+ return;
200
+ }
201
+ // A rival's file was moved (the displacement cascade, ADR-0050
202
+ // §residual) — restore-or-keep, never clobber: link it back
203
+ // only-if-absent; on EEXIST the path already carries a newer lock
204
+ // and the moved file stays at its distinct name (inert residue).
205
+ try {
206
+ linkSync(this.#tombstone, this.#lockPath);
207
+ }
208
+ catch {
209
+ // EEXIST or fs failure — inert
210
+ }
211
+ }
212
+ }
213
+ class NativeLock {
214
+ name = "link-lock";
215
+ async acquire(lockPath, sessionId, cancelled) {
216
+ const pid = process.pid;
217
+ const token = randomUUID();
218
+ // The staging name is PER-ATTEMPT: a later rename-away in the same
219
+ // acquire must never atomically overwrite an earlier attempt's
220
+ // abandoned staging (that would clobber the displacement residue,
221
+ // ADR-0050 §residual). tmp/tombstone are unique per acquire and are
222
+ // always cleaned or inert.
223
+ const tmp = `${lockPath}.tmp-${pid}-${token}`;
224
+ const tombstone = `${lockPath}.tomb-${pid}-${token}`;
225
+ // Test-only affordances (ADR-0050 §test affordances): the cascade
226
+ // gate freezes a contender at the two decision points.
227
+ const readyDir = process.env.KISO_LOCK_TEST_READY_DIR;
228
+ const readPause = Number(process.env.KISO_LOCK_TEST_PAUSE_READ_MS ?? 0) || 0;
229
+ const takeoverPause = Number(process.env.KISO_LOCK_TEST_PAUSE_TAKEOVER_MS ?? 0) || 0;
230
+ for (let attempt = 0;; attempt++) {
231
+ cancelled();
232
+ const staging = `${lockPath}.staging-${pid}-${token}-${attempt}`;
233
+ const seen = readLockIdentity(lockPath);
234
+ if (seen === null || seen.pid === undefined || !isAlive(seen.pid)) {
235
+ // Fresh path, dead holder, or stale residue — take it over
236
+ // by identity confirmation: rename-away → verify → link.
237
+ if (readyDir !== undefined)
238
+ writeFileSync(join(readyDir, `read-${pid}`), "");
239
+ if (readPause > 0)
240
+ await sleep(readPause);
241
+ let moved = false;
242
+ try {
243
+ renameSync(lockPath, staging);
244
+ moved = true;
245
+ }
246
+ catch (err) {
247
+ if (err.code !== "ENOENT")
248
+ throw unavailable(err);
249
+ // ENOENT — the path is already absent; proceed to the link.
250
+ }
251
+ if (moved) {
252
+ if (readyDir !== undefined)
253
+ writeFileSync(join(readyDir, `takeover-${pid}`), "");
254
+ if (takeoverPause > 0)
255
+ await sleep(takeoverPause);
256
+ const s = readLockIdentity(staging);
257
+ if (!sameIdentity(s, seen)) {
258
+ // The path was replaced between my read and my rename
259
+ // — a RIVAL'S LIVE file was moved. Abort the takeover:
260
+ // restore-or-keep (link back only-if-absent; on EEXIST
261
+ // the moved file is abandoned — inert residue at its
262
+ // distinct name, ADR-0050 §residual).
263
+ try {
264
+ linkSync(staging, lockPath);
265
+ }
266
+ catch (err) {
267
+ if (err.code !== "EEXIST")
268
+ throw unavailable(err);
269
+ }
270
+ continue;
271
+ }
272
+ }
273
+ // Write my identity fully, fsync it, THEN link — the
274
+ // load-bearing order (ADR-0050 §crash-durability): the final
275
+ // path can never exist empty or half-written, even across a
276
+ // power loss.
277
+ try {
278
+ const fd = openSync(tmp, "w");
279
+ try {
280
+ writeFileSync(fd, JSON.stringify({ pid, token }), "utf8");
281
+ fsyncSync(fd);
282
+ }
283
+ finally {
284
+ closeSync(fd);
285
+ }
286
+ linkSync(tmp, lockPath);
287
+ }
288
+ catch (err) {
289
+ const code = err.code;
290
+ if (code === "EEXIST") {
291
+ // A rival linked first — retry from a fresh read.
292
+ try {
293
+ unlinkSync(tmp);
294
+ }
295
+ catch {
296
+ // the stray is inert
297
+ }
298
+ continue;
299
+ }
300
+ throw unavailable(err);
301
+ }
302
+ try {
303
+ unlinkSync(tmp);
304
+ }
305
+ catch {
306
+ // the stray is inert
307
+ }
308
+ // The takeover succeeded — our identity owns the path, and the
309
+ // verified-dead file we moved away may be cleaned up (its
310
+ // name is distinct; unlinking it can never touch the lock
311
+ // path). Only the EEXIST-abandoned staging of a FAILED
312
+ // takeover lingers — the displacement fingerprint.
313
+ if (moved) {
314
+ try {
315
+ unlinkSync(staging);
316
+ }
317
+ catch {
318
+ // the stray is inert
319
+ }
320
+ }
321
+ return new NativeLockHandle(lockPath, tombstone, { pid, token });
322
+ }
323
+ if (seen.pid === pid && seen.token !== undefined) {
324
+ // Same-process writer's residue (round 5): another store in
325
+ // THIS process holds the lock — it will release; retry until
326
+ // it does (never a spurious self-conflict). A legacy
327
+ // bare-pid lock naming our own process is refused like any
328
+ // live foreign owner.
329
+ if (attempt >= 25)
330
+ throw new LockedError(`session ${sessionId} is locked by another writer`);
331
+ await sleep(20);
332
+ continue;
333
+ }
334
+ throw new LockedError(`session ${sessionId} is locked by another writer (pid ${seen.pid})`);
335
+ }
336
+ }
337
+ }
338
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
339
+ export const nativeLockAdapter = new NativeLock();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * R-F 0.1.46 — the recovery plan: recovery as pure projection. From the
3
+ * durable event prefix the plan derives THE unique safe next step — never
4
+ * the adjudication itself (the approval pipeline stays in the runtime driver
5
+ * layer, run.ts). Purity: no I/O, no ID generation, no time — the same
6
+ * prefix always derives the same plan. The driver consumes one action at a
7
+ * time and re-derives after every append: the recovery is a loop over this
8
+ * projection, not a second state machine (the R-F thesis — fresh execution
9
+ * and resume walk the same ordinary program).
10
+ *
11
+ * The action vocabulary (the R-F directive):
12
+ * COMPLETED — no open run: nothing to recover
13
+ * TERMINAL — the open run reached its terminal
14
+ * WAIT_PERMISSION(seq) — a stored request awaits the human
15
+ * DECIDE_PERMISSION(seq) — a committed call re-enters the approval pipeline
16
+ * EXECUTE(seq) — a durable approval authorizes the persisted call
17
+ * RESOLVE_UNCERTAIN(id) — a started execution with no receipt: the crash
18
+ * window — the human decides (never auto-rerun)
19
+ * REPAIR_RESULT(id|seq) — the model-facing result is missing: complete it
20
+ * from the durable fact (the receipt or the denial)
21
+ * FILL_RESOLUTION(id) — a resolution's model-facing fill is missing
22
+ * ABANDON_DRAFT(from) — a text-bearing no-stop suffix: void it (the
23
+ * driver appends the marker AND expires the voided
24
+ * requests — one deterministic step, sentence 3)
25
+ * CONTINUE_MODEL — nothing left to repair: drive the loop
26
+ *
27
+ * The derivation order is the R-E recovery's phase order (the zero-behavior
28
+ * proof: the prefix-table gate and the healing fixtures run unchanged):
29
+ * terminal > completed > uncertain > draft > invocations (the Gap A calls,
30
+ * then the stored requests) > receipt repairs > resolution fills > continue.
31
+ *
32
+ * Inputs: `events` — the session's full event prefix (the log); `scope` —
33
+ * the open run's stored events at resume start (the run boundaries). Both
34
+ * are pure inputs; the caller loads them.
35
+ */
36
+ import type { Event } from "@vincemakes/kiso-core";
37
+ export type RecoveryAction = {
38
+ readonly kind: "COMPLETED";
39
+ } | {
40
+ readonly kind: "TERMINAL";
41
+ } | {
42
+ readonly kind: "WAIT_PERMISSION";
43
+ readonly invocationSeq: number;
44
+ } | {
45
+ readonly kind: "DECIDE_PERMISSION";
46
+ readonly invocationSeq: number;
47
+ } | {
48
+ readonly kind: "EXECUTE";
49
+ readonly invocationSeq: number;
50
+ }
51
+ /** The receipt repair (executionId) or the durable-denial repair (invocationSeq). */
52
+ | {
53
+ readonly kind: "REPAIR_RESULT";
54
+ readonly executionId?: string;
55
+ readonly invocationSeq?: number;
56
+ } | {
57
+ readonly kind: "RESOLVE_UNCERTAIN";
58
+ readonly executionId: string;
59
+ } | {
60
+ readonly kind: "FILL_RESOLUTION";
61
+ readonly executionId: string;
62
+ } | {
63
+ readonly kind: "ABANDON_DRAFT";
64
+ readonly voidFromSeq: number;
65
+ } | {
66
+ readonly kind: "CONTINUE_MODEL";
67
+ };
68
+ /** A request's framework identity: its own invocationSeq, or the old-log
69
+ * fallback (the last same-callId call before the request, in the scope). */
70
+ export declare function invocationSeqOf(request: Event & {
71
+ type: "permission_requested";
72
+ }, scope: readonly Event[]): number | undefined;
73
+ /** The one safe next step for the durable prefix (derivation order above). */
74
+ export declare function deriveRecoveryPlan(events: readonly Event[], scope: readonly Event[]): RecoveryAction;