@vincemakes/kiso-runtime 0.1.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/dist/store.js ADDED
@@ -0,0 +1,586 @@
1
+ /**
2
+ * SessionStore — append-only JSONL durability, identity-safe (A 组).
3
+ *
4
+ * One file per session: `<root>/<id>.jsonl`, lines of
5
+ * `{"runId": string, "ts": number, "event": Event}`. The single-writer
6
+ * lock (第四轮) is an EXCLUSIVE KERNEL flock on `<id>.lock`, held by a
7
+ * dedicated helper process:
8
+ *
9
+ * - the kernel arbitrates every race — a contender can never remove or
10
+ * overwrite a live holder's lock, because there is nothing to remove;
11
+ * the lock simply exists while the helper lives and vanishes with it;
12
+ * - the lock file ALSO carries `{"pid": number, "token": string}` written
13
+ * by the holder, as a best-effort guard for OLD-format writers (whose
14
+ * O_EXCL pidfile scheme does not honor flock). 第五轮(P1-4): this guard
15
+ * is NOT a seamless rolling upgrade — an old writer that created an
16
+ * empty lock file before writing its pid creates a split-brain window
17
+ * that a pidfile read cannot close. The documented upgrade contract is
18
+ * QUARANTINE: stop every old-format process, THEN start the new
19
+ * version. A dead/empty/unreadable legacy lock is otherwise harmless —
20
+ * flock ignores content, and the kernel lock is what matters;
21
+ * - `close()` releases only THIS instance's helper; `closeAll()` every
22
+ * held helper — a foreign close can never release another writer's
23
+ * kernel lock (flock is tied to the helper's open file description).
24
+ *
25
+ * Consistency contract (A 组):
26
+ * - every id is validated BEFORE any file side effect (append, close,
27
+ * load, lock paths);
28
+ * - append runs an expected-last-seq CAS against the file's REAL last
29
+ * committed seq: a stale preloaded handle writing a duplicate seq is
30
+ * refused with StaleWriterError — and the run that fed it terminates,
31
+ * so the in-memory EventLog never continues past a rejected write;
32
+ * - the torn tail is repaired before EVERY append, and committed records
33
+ * (newline-terminated) are never truncated;
34
+ * - load is strict (A 组 round 1): a partial final line is the only
35
+ * tolerated damage; everything else throws StoreCorruptionError.
36
+ */
37
+ import { spawn } from "node:child_process";
38
+ import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, readSync, unlinkSync, writeFileSync, } from "node:fs";
39
+ import { dirname, join } from "node:path";
40
+ import { isKisoEvent } from "@vincemakes/kiso-core";
41
+ /** History that does not parse as a contiguous kiso trajectory. */
42
+ export class StoreCorruptionError extends Error {
43
+ constructor(message) {
44
+ super(`session store corruption: ${message}`);
45
+ this.name = "StoreCorruptionError";
46
+ }
47
+ }
48
+ /** A write that would duplicate or skip a seq — the handle is stale. */
49
+ export class StaleWriterError extends Error {
50
+ constructor(expected, got) {
51
+ super(`stale session handle: expected seq ${expected}, got ${got} — reload the session`);
52
+ this.name = "StaleWriterError";
53
+ }
54
+ }
55
+ /** Session ids become file names — keep them host-safe. */
56
+ const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
57
+ export class SessionStore {
58
+ root;
59
+ #fds = new Map();
60
+ /** sessionId → the lock helper process THIS instance spawned. */
61
+ #lockHelpers = new Map();
62
+ /** 第四轮(对抗): serialize concurrent acquireLock calls ON this instance —
63
+ * two racing appends must not spawn two helpers and fight each other. */
64
+ #lockAcquiring = new Map();
65
+ /** 第五轮(P1-1): serialize the WHOLE append critical section per session on
66
+ * this instance — lock check → CAS → write → fsync. A rejected write
67
+ * propagates to every append queued behind it, so a concurrent write can
68
+ * never land after a stale failure (which would fork memory and disk). */
69
+ #appendQueues = new Map();
70
+ #closed = new Set();
71
+ constructor(root) {
72
+ this.root = root;
73
+ mkdirSync(root, { recursive: true });
74
+ fsyncDir(root);
75
+ }
76
+ // ── paths and the cross-process lock ─────────────────────────────────
77
+ pathFor(sessionId) {
78
+ if (!ID_PATTERN.test(sessionId)) {
79
+ throw new Error(`invalid session id: ${sessionId}`);
80
+ }
81
+ return join(this.root, `${sessionId}.jsonl`);
82
+ }
83
+ lockPathFor(sessionId) {
84
+ return join(this.root, `${sessionId}.lock`);
85
+ }
86
+ /**
87
+ * Take the single-writer lock (第四轮): an EXCLUSIVE kernel flock held
88
+ * by a dedicated helper process. The KERNEL arbitrates every race —
89
+ * there is no stale lock to delete and no takeover to race: a
90
+ * contender either gets the flock (the previous holder is gone) or it
91
+ * fails. The lock file also carries the holder's identity so an OLD-format
92
+ * writer (which does not honor flock) still sees a live owner and
93
+ * refuses to take over — a best-effort guard, NOT a seamless rolling
94
+ * upgrade (第五轮 P1-4): the documented upgrade contract is quarantine —
95
+ * stop every old-format process, then start the new version.
96
+ * No recursion, no deletion, no window between NEW-format writers.
97
+ */
98
+ async acquireLock(sessionId) {
99
+ // 第五轮(P1-2): the lock is held only while the helper PROCESS is
100
+ // alive — flock is bound to the helper's lifetime. A dead helper's
101
+ // entry must never be trusted as "locked".
102
+ if (this.lockHeld(sessionId))
103
+ return;
104
+ const inFlight = this.#lockAcquiring.get(sessionId);
105
+ if (inFlight !== undefined)
106
+ return inFlight;
107
+ const attempt = this.#acquireLockOnce(sessionId).finally(() => this.#lockAcquiring.delete(sessionId));
108
+ this.#lockAcquiring.set(sessionId, attempt);
109
+ return attempt;
110
+ }
111
+ /** 第五轮(P1-2): true only while the helper process is alive. */
112
+ lockHeld(sessionId) {
113
+ const child = this.#lockHelpers.get(sessionId);
114
+ if (child === undefined || child.pid === undefined || child.pid <= 0)
115
+ return false;
116
+ return isAlive(child.pid);
117
+ }
118
+ async #acquireLockOnce(sessionId) {
119
+ const lockPath = this.lockPathFor(sessionId);
120
+ for (let attempt = 0;; attempt++) {
121
+ const child = spawn("python3", ["-c", LOCK_HELPER_SCRIPT, lockPath], {
122
+ stdio: ["pipe", "pipe", "ignore"],
123
+ });
124
+ const verdict = await helperVerdict(child);
125
+ if (verdict === "LOCKED") {
126
+ // The kernel flock is ours. One last compatibility gate: an
127
+ // OLD-format writer (which does not honor flock) may still
128
+ // be alive — its lock file names it. Refuse, and release
129
+ // the flock (the helper dies). A MODERN lock (with a token)
130
+ // naming OUR OWN process is a same-process writer's residue
131
+ // (第四轮: the file is advisory; the flock is the authority).
132
+ const legacy = readLockIdentity(lockPath);
133
+ if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
134
+ child.kill();
135
+ throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
136
+ }
137
+ // Record our identity in the file: irrelevant to flock, but
138
+ // an OLD-format contender reads it and refuses to take over
139
+ // a live writer's lock.
140
+ try {
141
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: crypto.randomUUID() }));
142
+ }
143
+ catch {
144
+ // the file itself is advisory — the kernel lock holds
145
+ }
146
+ this.#lockHelpers.set(sessionId, child);
147
+ // 第五轮(P1-2): the helper's death removes the entry — the
148
+ // flock dies with the process; a later append re-acquires
149
+ // (and fails honestly if a rival holds the flock now).
150
+ child.on("exit", () => {
151
+ if (this.#lockHelpers.get(sessionId) === child) {
152
+ this.#lockHelpers.delete(sessionId);
153
+ }
154
+ });
155
+ return;
156
+ }
157
+ child.kill();
158
+ if (verdict === "SPAWN_FAILED") {
159
+ // 第四轮(对抗): the helper could not start (python3 missing) —
160
+ // an HONEST error, never a fake lock conflict.
161
+ throw new Error(`session locking unavailable: the flock helper (python3) failed to start for ${sessionId}`);
162
+ }
163
+ // BUSY: either a live modern writer, or a holder that is just
164
+ // exiting (its helper is dying). A FOREIGN live writer's identity
165
+ // is in the file — refuse at once. A MODERN lock (with a token)
166
+ // naming OUR OWN process is a same-process writer — it will
167
+ // release its helper; retry until it does (第四轮: never a
168
+ // spurious self-conflict). A legacy bare-pid lock naming our own
169
+ // process is still a live foreign owner and is refused.
170
+ const legacy = readLockIdentity(lockPath);
171
+ if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
172
+ throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
173
+ }
174
+ if (attempt >= 25) {
175
+ throw new Error(`session ${sessionId} is locked by another writer`);
176
+ }
177
+ // 第五轮(P1-3): a close() that landed while we waited ends the
178
+ // acquisition immediately — no 500ms wait, no lock at all.
179
+ if (this.#closed.has(sessionId)) {
180
+ throw new Error(`session store is closed for ${sessionId}`);
181
+ }
182
+ await new Promise((resolve) => setTimeout(resolve, 20));
183
+ }
184
+ }
185
+ /**
186
+ * Release OUR lock only: kill OUR helper. The kernel releases the
187
+ * flock with the helper's death; the identity file is CLEARED so a
188
+ * same-process successor is never mistaken for a live legacy owner —
189
+ * the flock is the authority, the file is advisory (第四轮).
190
+ */
191
+ releaseLock(sessionId) {
192
+ const child = this.#lockHelpers.get(sessionId);
193
+ if (child === undefined)
194
+ return;
195
+ this.#lockHelpers.delete(sessionId);
196
+ // 第四轮(对抗): the identity is cleared BEFORE the helper dies — a
197
+ // contender that acquires the flock in the release gap writes its
198
+ // own identity AFTER our clear, so it is never wiped by us (the
199
+ // file is advisory; the kernel flock is the authority).
200
+ try {
201
+ writeFileSync(this.lockPathFor(sessionId), "");
202
+ }
203
+ catch {
204
+ // advisory only
205
+ }
206
+ child.kill();
207
+ }
208
+ // ── append: lock, open, repair, CAS, write, fsync ────────────────────
209
+ /** Write-ahead: durable (written + fsynced) before returning. */
210
+ async append(sessionId, runId, event) {
211
+ if (this.#closed.has(sessionId)) {
212
+ throw new Error(`session store is closed for ${sessionId}`);
213
+ }
214
+ this.pathFor(sessionId); // id validated before ANY file side effect
215
+ // 第五轮(P1-1): the WHOLE critical section is serialized per session
216
+ // on this instance — and a rejection PROPAGATES to every append
217
+ // queued behind it: a concurrent write can never land after a
218
+ // stale failure that poisoned the session.
219
+ const previous = this.#appendQueues.get(sessionId) ?? Promise.resolve();
220
+ const run = previous.then(() => this.#appendOnce(sessionId, runId, event));
221
+ this.#appendQueues.set(sessionId, run);
222
+ try {
223
+ await run;
224
+ }
225
+ finally {
226
+ if (this.#appendQueues.get(sessionId) === run) {
227
+ this.#appendQueues.delete(sessionId);
228
+ }
229
+ }
230
+ }
231
+ async #appendOnce(sessionId, runId, event) {
232
+ // 第五轮(P1-3): close() may have returned while we waited — the
233
+ // lifecycle barrier is re-checked after the lock acquisition.
234
+ if (this.#closed.has(sessionId)) {
235
+ throw new Error(`session store is closed for ${sessionId}`);
236
+ }
237
+ await this.acquireLock(sessionId);
238
+ if (this.#closed.has(sessionId)) {
239
+ // The lock was acquired AFTER close() returned — release it and
240
+ // fail; nothing of this instance may outlive close().
241
+ this.releaseLock(sessionId);
242
+ throw new Error(`session store is closed for ${sessionId}`);
243
+ }
244
+ let fd;
245
+ try {
246
+ fd = this.fd(sessionId);
247
+ }
248
+ catch (err) {
249
+ // The lock was acquired but the JSONL could not be opened:
250
+ // release the lock — it must not leak (A 组).
251
+ this.releaseLock(sessionId);
252
+ throw err;
253
+ }
254
+ repairTornTail(fd);
255
+ // Expected-last-seq CAS against the file's REAL last committed seq
256
+ // (A 组): a stale preloaded handle cannot write a duplicate seq.
257
+ const last = lastCommittedSeq(fd);
258
+ const expected = (last ?? -1) + 1;
259
+ if (event.seq !== expected) {
260
+ throw new StaleWriterError(expected, event.seq);
261
+ }
262
+ appendFileSync(fd, `${JSON.stringify({ runId, ts: Date.now(), event })}\n`);
263
+ fsyncSync(fd);
264
+ // 第五轮(P1-3): a close() that landed during the write must not
265
+ // leave our helper behind.
266
+ if (this.#closed.has(sessionId)) {
267
+ this.releaseLock(sessionId);
268
+ }
269
+ }
270
+ /**
271
+ * Open (creating if needed) the session file. The torn tail is repaired
272
+ * here AND before every append — an in-process append failure cannot
273
+ * poison the next one. The parent directory is fsynced so the file's
274
+ * existence survives a crash.
275
+ */
276
+ fd(sessionId) {
277
+ const existing = this.#fds.get(sessionId);
278
+ if (existing !== undefined)
279
+ return existing;
280
+ const path = this.pathFor(sessionId);
281
+ const fd = openSync(path, "a+");
282
+ repairTornTail(fd);
283
+ fsyncDir(dirname(path));
284
+ this.#fds.set(sessionId, fd);
285
+ return fd;
286
+ }
287
+ // ── load: strict replay ──────────────────────────────────────────────
288
+ /**
289
+ * Replay a session's log. The ONLY tolerated damage is a partial final
290
+ * line (a crash mid-write): it is dropped and the contiguous prefix is
291
+ * returned. Anything else — mid-file garbage, valid JSON that is not a
292
+ * kiso record, a seq that is not 0..N — throws StoreCorruptionError.
293
+ */
294
+ load(sessionId) {
295
+ const path = this.pathFor(sessionId);
296
+ if (!existsSync(path))
297
+ return [];
298
+ const raw = readFileSync(path, "utf8");
299
+ const lines = raw.split("\n");
300
+ const nonEmpty = [];
301
+ for (let i = 0; i < lines.length; i++) {
302
+ if (lines[i] !== "")
303
+ nonEmpty.push(i);
304
+ }
305
+ // 二: a line WITHOUT a trailing newline is NOT committed — whether
306
+ // or not it happens to parse. load and append must agree: append's
307
+ // torn-tail repair truncates exactly what load refuses to return.
308
+ const tolerantTail = !raw.endsWith("\n");
309
+ const records = [];
310
+ for (let k = 0; k < nonEmpty.length; k++) {
311
+ const line = lines[nonEmpty[k]];
312
+ const isLast = k === nonEmpty.length - 1;
313
+ if (isLast && tolerantTail)
314
+ break; // uncommitted — drop it
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(line);
318
+ }
319
+ catch {
320
+ throw new StoreCorruptionError(`line ${nonEmpty[k] + 1} is not JSON`);
321
+ }
322
+ if (!isRecord(parsed)) {
323
+ throw new StoreCorruptionError(`line ${nonEmpty[k] + 1} is not a session record`);
324
+ }
325
+ records.push(parsed);
326
+ }
327
+ for (let i = 0; i < records.length; i++) {
328
+ const seq = records[i].event.seq;
329
+ if (seq !== i) {
330
+ throw new StoreCorruptionError(`seq discontinuity: expected ${i}, got ${seq}`);
331
+ }
332
+ }
333
+ return records;
334
+ }
335
+ has(sessionId) {
336
+ return existsSync(this.pathFor(sessionId));
337
+ }
338
+ list() {
339
+ const metas = [];
340
+ for (const entry of readdirSync(this.root)) {
341
+ if (!entry.endsWith(".jsonl"))
342
+ continue;
343
+ const id = entry.slice(0, -".jsonl".length);
344
+ const records = this.load(id);
345
+ if (records.length === 0)
346
+ continue;
347
+ const first = records[0].event;
348
+ metas.push({
349
+ id,
350
+ title: first.type === "user_input" && typeof first.content === "string"
351
+ ? first.content.slice(0, 60)
352
+ : "(no prompt)",
353
+ events: records.length,
354
+ runs: new Set(records.map((r) => r.runId)).size,
355
+ createdAt: records[0]?.ts ?? 0,
356
+ updatedAt: records.at(-1)?.ts ?? 0,
357
+ });
358
+ }
359
+ return metas.sort((a, b) => a.id.localeCompare(b.id));
360
+ }
361
+ // ── lifecycle ────────────────────────────────────────────────────────
362
+ /** Release a session's fd and OUR writer lock. Idempotent. */
363
+ close(sessionId) {
364
+ this.pathFor(sessionId); // id validated before ANY file side effect
365
+ const fd = this.#fds.get(sessionId);
366
+ if (fd !== undefined) {
367
+ closeSync(fd);
368
+ this.#fds.delete(sessionId);
369
+ }
370
+ this.releaseLock(sessionId);
371
+ this.#closed.add(sessionId);
372
+ }
373
+ /** Release every held fd and lock, including locks whose JSONL open failed. */
374
+ closeAll() {
375
+ for (const id of new Set([...this.#fds.keys(), ...this.#lockHelpers.keys()])) {
376
+ this.close(id);
377
+ }
378
+ }
379
+ }
380
+ function isRecord(value) {
381
+ if (typeof value !== "object" || value === null)
382
+ return false;
383
+ const v = value;
384
+ return typeof v.runId === "string" && typeof v.ts === "number" && isKisoEvent(v.event);
385
+ }
386
+ /**
387
+ * Read a lock file's holder identity (第四轮). Formats:
388
+ * modern: {"pid": 123, "token": "..."}
389
+ * legacy: a bare pid — either the STRING "123" or, because
390
+ * JSON.parse("123") yields the NUMBER 123, the number itself.
391
+ * Neither may be mistaken for an object without a pid.
392
+ * Empty, unreadable, or half-written locks have no identity — the kernel
393
+ * flock supersedes them (there is nothing to refuse, and nothing to
394
+ * delete).
395
+ */
396
+ function readLockIdentity(lockPath) {
397
+ let raw;
398
+ try {
399
+ raw = readFileSync(lockPath, "utf8");
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ const trimmed = raw.trim();
405
+ if (trimmed === "")
406
+ return null;
407
+ let parsed;
408
+ try {
409
+ parsed = JSON.parse(trimmed);
410
+ }
411
+ catch {
412
+ parsed = trimmed; // half-written JSON — try as a bare pid
413
+ }
414
+ if (typeof parsed === "number" && Number.isInteger(parsed)) {
415
+ return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
416
+ }
417
+ if (typeof parsed === "string") {
418
+ const pid = Number.parseInt(parsed, 10);
419
+ return Number.isFinite(pid) ? { pid } : null;
420
+ }
421
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
422
+ const v = parsed;
423
+ return {
424
+ ...(typeof v.pid === "number" ? { pid: v.pid } : {}),
425
+ ...(typeof v.token === "string" ? { token: v.token } : {}),
426
+ };
427
+ }
428
+ return null;
429
+ }
430
+ /**
431
+ * The lock helper: a python3 process that takes an EXCLUSIVE flock on the
432
+ * lock path and HOLDS it until it dies (its stdin is closed / it is
433
+ * killed). The kernel releases the flock with the helper — the lock is
434
+ * tied to the open file description, so a dead helper can never leave a
435
+ * stale lock behind, and no contender can ever remove a live one.
436
+ * python3's `fcntl` module provides flock on both macOS and Linux.
437
+ */
438
+ const LOCK_HELPER_SCRIPT = [
439
+ "import fcntl, os, sys",
440
+ "fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT, 0o644)",
441
+ "try:",
442
+ " fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)",
443
+ "except OSError:",
444
+ " print('BUSY', flush=True)",
445
+ " sys.exit(0)",
446
+ "print('LOCKED', flush=True)",
447
+ "try:",
448
+ " while sys.stdin.buffer.read(1):",
449
+ " pass",
450
+ "except Exception:",
451
+ " pass",
452
+ ].join("\n");
453
+ /** The helper's first stdout line: "LOCKED" or anything else = busy/dead. */
454
+ function helperVerdict(child) {
455
+ return new Promise((resolve) => {
456
+ let buf = "";
457
+ let settled = false;
458
+ const done = (verdict) => {
459
+ if (settled)
460
+ return;
461
+ settled = true;
462
+ child.stdout?.removeAllListeners();
463
+ // The helper is a LOCK DAEMON: it must never keep the parent's
464
+ // event loop alive (a finished store exits cleanly), and when the
465
+ // parent DOES exit the pipes close, the helper's read hits EOF,
466
+ // the helper exits, and the kernel releases the flock. The child
467
+ // process handle, its stdin hold, and its verdict channel are all
468
+ // unref'd — the lock outlives nothing the parent does not.
469
+ const unref = (s) => s?.unref?.();
470
+ unref(child);
471
+ unref(child.stdin);
472
+ unref(child.stdout);
473
+ resolve(verdict);
474
+ };
475
+ child.stdout?.on("data", (d) => {
476
+ buf += d.toString();
477
+ const nl = buf.indexOf("\n");
478
+ if (nl !== -1)
479
+ done(buf.slice(0, nl).trim());
480
+ });
481
+ child.stdout?.on("end", () => done(buf.trim()));
482
+ child.stdout?.on("error", () => done("FAILED"));
483
+ // 第五轮(P2-1): a spawn failure (python3 missing, exec denied) is
484
+ // DISTINCT from a busy lock — the caller must not report "locked by
485
+ // another writer" for a missing helper. The verdict is SPAWN_FAILED
486
+ // and the acquire path checks exactly that string.
487
+ child.on("error", (err) => {
488
+ void err;
489
+ done("SPAWN_FAILED");
490
+ });
491
+ });
492
+ }
493
+ function isAlive(pid) {
494
+ try {
495
+ process.kill(pid, 0);
496
+ return true;
497
+ }
498
+ catch (err) {
499
+ return err.code === "EPERM";
500
+ }
501
+ }
502
+ /**
503
+ * If the file does not end with a newline, truncate to the last complete
504
+ * line (or 0) — the torn-tail repair. Runs on open AND before every append,
505
+ * so an in-process append failure cannot poison the next one.
506
+ */
507
+ function repairTornTail(fd) {
508
+ const size = fstatSync(fd).size;
509
+ if (size === 0)
510
+ return;
511
+ const last = Buffer.alloc(1);
512
+ readSync(fd, last, 0, 1, size - 1);
513
+ if (last[0] === 0x0a)
514
+ return; // ends cleanly
515
+ const lastNewline = lastNewlineOffset(fd, size);
516
+ ftruncateSync(fd, lastNewline + 1);
517
+ fsyncSync(fd);
518
+ }
519
+ /** Offset of the last '\n' in the file, or -1 when none exists. */
520
+ function lastNewlineOffset(fd, size) {
521
+ const chunk = 64 * 1024;
522
+ let offset = size;
523
+ while (offset > 0) {
524
+ const readLen = Math.min(chunk, offset);
525
+ const buf = Buffer.alloc(readLen);
526
+ offset -= readLen;
527
+ readSync(fd, buf, 0, readLen, offset);
528
+ const idx = buf.lastIndexOf(0x0a);
529
+ if (idx !== -1)
530
+ return offset + idx;
531
+ }
532
+ return -1;
533
+ }
534
+ /**
535
+ * The seq of the file's last COMMITTED (newline-terminated) record —
536
+ * the CAS anchor. Grows the tail read until the last record parses.
537
+ */
538
+ function lastCommittedSeq(fd) {
539
+ const size = fstatSync(fd).size;
540
+ if (size === 0)
541
+ return undefined;
542
+ let chunk = 4096;
543
+ while (true) {
544
+ const readLen = Math.min(chunk, size);
545
+ const buf = Buffer.alloc(readLen);
546
+ readSync(fd, buf, 0, readLen, size - readLen);
547
+ const lines = buf
548
+ .toString("utf8")
549
+ .split("\n")
550
+ .filter((l) => l.trim() !== "");
551
+ if (lines.length > 0) {
552
+ try {
553
+ const parsed = JSON.parse(lines[lines.length - 1]);
554
+ if (typeof parsed?.event?.seq !== "number") {
555
+ throw new Error("not a record");
556
+ }
557
+ return parsed.event.seq;
558
+ }
559
+ catch {
560
+ if (readLen >= size) {
561
+ throw new StoreCorruptionError("cannot determine the last committed seq — the tail is not a record");
562
+ }
563
+ chunk *= 2;
564
+ continue;
565
+ }
566
+ }
567
+ if (readLen >= size)
568
+ return undefined;
569
+ chunk *= 2;
570
+ }
571
+ }
572
+ /** Durability of directory entries: fsync the directory itself. */
573
+ function fsyncDir(dir) {
574
+ let fd;
575
+ try {
576
+ fd = openSync(dir, "r");
577
+ fsyncSync(fd);
578
+ }
579
+ catch {
580
+ // Some platforms refuse dir fsync; the file-level fsync still holds.
581
+ }
582
+ finally {
583
+ if (fd !== undefined)
584
+ closeSync(fd);
585
+ }
586
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@vincemakes/kiso-runtime",
3
+ "version": "0.1.0",
4
+ "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "vitest run"
22
+ },
23
+ "dependencies": {
24
+ "@vincemakes/kiso-core": "0.1.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@vincemakes/kiso-provider-anthropic": "0.1.0",
28
+ "@vincemakes/kiso-provider-openai": "0.1.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@vincemakes/kiso-provider-anthropic": {
32
+ "optional": true
33
+ },
34
+ "@vincemakes/kiso-provider-openai": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@vincemakes/kiso-evals": "0.1.0",
40
+ "@types/node": "^26.1.2",
41
+ "typescript": "^5.7.2",
42
+ "vitest": "^3.0.0"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/vincemakes/kiso.git",
47
+ "directory": "packages/runtime"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/vincemakes/kiso/issues"
51
+ },
52
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/runtime#readme"
53
+ }