@rivus/agent-kit-collab 0.0.0 → 0.4.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/lease.js ADDED
@@ -0,0 +1,711 @@
1
+ import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-BCvK1ehZ.js";
2
+ import { PlatformService } from "@rivus/agent-kit/platform/effect";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer from "effect/Layer";
5
+ import * as z from "zod/mini";
6
+ import * as Context from "effect/Context";
7
+ import { AgentKitError, err, ok } from "@rivus/agent-kit/catalog";
8
+ import * as Semaphore from "effect/Semaphore";
9
+ import * as Schedule from "effect/Schedule";
10
+ import * as Cause from "effect/Cause";
11
+ import * as Deferred from "effect/Deferred";
12
+ import * as Exit from "effect/Exit";
13
+ import * as Ref from "effect/Ref";
14
+ //#region src/lease/application/ports.ts
15
+ const LeaseRepositoryBase = Context.Service()("@rivus/agent-kit-collab/lease/LeaseRepository/v1");
16
+ /** The lease repository port; `sqliteLeaseRepository`, `fileLeaseRepository` and `memoryLeaseRepository` provide it. */
17
+ var LeaseRepository = class extends LeaseRepositoryBase {};
18
+ //#endregion
19
+ //#region src/lease/application/services/repository-failure.ts
20
+ function repositoryFailure(key, reason, message, cause) {
21
+ return cause === void 0 ? {
22
+ _tag: "LeaseRepositoryFailure",
23
+ key,
24
+ reason,
25
+ message
26
+ } : {
27
+ _tag: "LeaseRepositoryFailure",
28
+ key,
29
+ reason,
30
+ message,
31
+ cause
32
+ };
33
+ }
34
+ function revisionConflict(key, expectedRevision, storedRevision) {
35
+ return {
36
+ _tag: "RevisionConflict",
37
+ key,
38
+ expectedRevision,
39
+ storedRevision
40
+ };
41
+ }
42
+ //#endregion
43
+ //#region src/lease/infra/models/key-file-name.ts
44
+ /** File names stay well under the usual 255-byte limit even with a suffix such as `.lease.json.stale`. */
45
+ const MAX_LENGTH = 160;
46
+ /**
47
+ * The part of a file name that stands for `key`. Percent-encoding keeps `/`, `.` and other separators from forming a
48
+ * path; a longer key keeps a readable prefix and a SHA-256 of the whole key.
49
+ */
50
+ function keyFileName(key) {
51
+ const encoded = encodeURIComponent(key).replace(/[!'()*.~]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
52
+ if (encoded.length <= MAX_LENGTH) return Effect.succeed(encoded);
53
+ return Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))).pipe(Effect.map((digest) => {
54
+ const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
55
+ return `${encoded.slice(0, 64)}~${hex}`;
56
+ }));
57
+ }
58
+ //#endregion
59
+ //#region src/lease/infra/models/lease-record-codec.ts
60
+ const holderSchema = z.object({
61
+ host: z.string(),
62
+ bootId: z.string(),
63
+ pid: z.number(),
64
+ startTime: z.number()
65
+ });
66
+ const snapshotSchema = z.object({
67
+ key: z.string(),
68
+ generation: z.number(),
69
+ revision: z.number(),
70
+ holder: z.nullable(holderSchema),
71
+ holderId: z.nullable(z.string()),
72
+ renewedAt: z.number()
73
+ });
74
+ /** Checks the shape of a stored record; the Lease aggregate checks its invariants when the manager restores it. */
75
+ function decodeSnapshot(value) {
76
+ const parsed = snapshotSchema.safeParse(value);
77
+ return parsed.success ? parsed.data : void 0;
78
+ }
79
+ function decodeHolder(json) {
80
+ if (json === null) return null;
81
+ try {
82
+ const parsed = holderSchema.safeParse(JSON.parse(json));
83
+ return parsed.success ? parsed.data : void 0;
84
+ } catch {
85
+ return;
86
+ }
87
+ }
88
+ /** Only the identity fields, whatever else the platform's `ProcessIdentity` carries. */
89
+ function encodeHolder(holder) {
90
+ if (holder === null) return null;
91
+ const { host, bootId, pid, startTime } = holder;
92
+ return JSON.stringify({
93
+ host,
94
+ bootId,
95
+ pid,
96
+ startTime
97
+ });
98
+ }
99
+ //#endregion
100
+ //#region src/lease/domain/lease/policies/acquisition.ts
101
+ /**
102
+ * A lease can be taken when there is no record, the record is a tombstone, its holder is dead, or it has not been
103
+ * renewed within the TTL as the observer saw it. A live or unknown holder that keeps renewing keeps the lease.
104
+ */
105
+ function canAcquire(lease, view) {
106
+ return lease === void 0 || lease.holder === null || view.liveness === "dead" || !view.fresh;
107
+ }
108
+ //#endregion
109
+ //#region src/lease/domain/lease/policies/fence-check.ts
110
+ /** The token the next acquisition of `key` receives: one generation above the record, 1 for a new key. */
111
+ function nextFencingToken(current, key) {
112
+ return {
113
+ key,
114
+ generation: (current?.generation ?? 0) + 1
115
+ };
116
+ }
117
+ /**
118
+ * The check a protected resource runs inside its own atomic write: `lastSeen` is the highest token it has accepted
119
+ * for the key. A smaller generation is refused; an equal one is the same holder writing again. On success, store the
120
+ * returned token as the new `lastSeen`.
121
+ */
122
+ function checkFence(lastSeen, token) {
123
+ if (lastSeen !== void 0 && lastSeen.key === token.key && token.generation < lastSeen.generation) return err({
124
+ _tag: "FenceRejected",
125
+ key: token.key,
126
+ generation: token.generation,
127
+ current: lastSeen.generation
128
+ });
129
+ return ok(token);
130
+ }
131
+ //#endregion
132
+ //#region src/lease/domain/lease/policies/loss.ts
133
+ /**
134
+ * Why an acquisition that could not renew or write no longer holds, judged from the record it sees: the record is
135
+ * gone (`missing`), a tombstone says it was released, or another holder wrote it (`taken-over`). A tombstone is a
136
+ * record with no holder, so the holder field decides, whatever `holderId` says. How a read that failed counts is
137
+ * the caller's error mapping, not a property of a record.
138
+ */
139
+ function lossReason(record) {
140
+ if (record === void 0) return "missing";
141
+ return record.holder === null ? "released" : "taken-over";
142
+ }
143
+ //#endregion
144
+ //#region src/lease/domain/lease/aggregates/lease.ts
145
+ /**
146
+ * One key's lease. It has one holder at a time; its generation never decreases, release included, because release
147
+ * keeps the record as a tombstone; every write increases its revision. Stores persist the snapshot and compare
148
+ * revisions, so a transition only takes effect when no other writer got there first.
149
+ */
150
+ var Lease = class Lease {
151
+ /** The first acquisition of a key that has no record. */
152
+ static create(claim) {
153
+ const { generation } = nextFencingToken(void 0, claim.key);
154
+ const state = new Lease({
155
+ key: claim.key,
156
+ generation,
157
+ revision: 1,
158
+ holder: claim.holder,
159
+ holderId: claim.holderId,
160
+ renewedAt: claim.now
161
+ });
162
+ return {
163
+ state,
164
+ events: [acquired(state.snapshot, null)]
165
+ };
166
+ }
167
+ static restore(snapshot) {
168
+ const problem = invalidity(snapshot);
169
+ if (problem !== void 0) return err({
170
+ _tag: "LeaseRecordInvalid",
171
+ key: snapshot.key,
172
+ message: problem
173
+ });
174
+ return ok(new Lease({ ...snapshot }));
175
+ }
176
+ snapshot;
177
+ constructor(snapshot) {
178
+ this.snapshot = snapshot;
179
+ Object.freeze(this);
180
+ }
181
+ /** Takes the lease when `canAcquire` allows it; the generation moves to the next fencing token. */
182
+ acquire(claim, view) {
183
+ const { holder, holderId, generation, key } = this.snapshot;
184
+ if (holder !== null && holderId !== null && !canAcquire(this.snapshot, view)) return err({
185
+ _tag: "LeaseHeld",
186
+ key,
187
+ generation,
188
+ holder,
189
+ holderId
190
+ });
191
+ const state = new Lease({
192
+ key,
193
+ generation: nextFencingToken(this.snapshot, key).generation,
194
+ revision: this.snapshot.revision + 1,
195
+ holder: claim.holder,
196
+ holderId: claim.holderId,
197
+ renewedAt: claim.now
198
+ });
199
+ return ok({
200
+ state,
201
+ events: [acquired(state.snapshot, holderId)]
202
+ });
203
+ }
204
+ /** A heartbeat: only the holding acquisition renews, and the generation stays. */
205
+ renew(holding, now) {
206
+ const lost = this.lossOf(holding);
207
+ if (lost !== void 0) return err(lost);
208
+ const state = new Lease({
209
+ ...this.snapshot,
210
+ revision: this.snapshot.revision + 1,
211
+ renewedAt: now
212
+ });
213
+ const { key, generation, revision } = state.snapshot;
214
+ return ok({
215
+ state,
216
+ events: [{
217
+ _tag: "LeaseRenewed",
218
+ key,
219
+ generation,
220
+ revision
221
+ }]
222
+ });
223
+ }
224
+ /** Leaves a tombstone: no holder, the same generation, so the next acquisition still moves forward. */
225
+ release(holding, now) {
226
+ const lost = this.lossOf(holding);
227
+ if (lost !== void 0) return err(lost);
228
+ const state = new Lease({
229
+ ...this.snapshot,
230
+ revision: this.snapshot.revision + 1,
231
+ holder: null,
232
+ holderId: null,
233
+ renewedAt: now
234
+ });
235
+ const { key, generation } = state.snapshot;
236
+ return ok({
237
+ state,
238
+ events: [{
239
+ _tag: "LeaseReleased",
240
+ key,
241
+ generation,
242
+ holderId: holding.holderId
243
+ }]
244
+ });
245
+ }
246
+ toSnapshot() {
247
+ return this.snapshot;
248
+ }
249
+ /** Whether this record is still the given acquisition's: the same holder id and generation. */
250
+ holds(holding) {
251
+ const { holderId, generation } = this.snapshot;
252
+ return holderId === holding.holderId && generation === holding.generation;
253
+ }
254
+ /** Why this acquisition no longer holds, or `undefined` when it still does. */
255
+ lossOf(holding) {
256
+ if (this.holds(holding)) return;
257
+ return {
258
+ _tag: "LeaseLost",
259
+ key: this.snapshot.key,
260
+ generation: holding.generation,
261
+ reason: lossReason(this.snapshot)
262
+ };
263
+ }
264
+ };
265
+ function acquired(snapshot, previousHolderId) {
266
+ const { key, generation, holderId } = snapshot;
267
+ return {
268
+ _tag: "LeaseAcquired",
269
+ key,
270
+ generation,
271
+ holderId: holderId ?? "",
272
+ previousHolderId
273
+ };
274
+ }
275
+ function invalidity(snapshot) {
276
+ if (snapshot.key === "") return "the key is empty";
277
+ if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 1) return `generation ${snapshot.generation} is not a positive integer`;
278
+ if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < snapshot.generation) return `revision ${snapshot.revision} is not an integer at least as large as the generation`;
279
+ if (snapshot.holder === null !== (snapshot.holderId === null)) return "a holder and a holder id come together";
280
+ return Number.isFinite(snapshot.renewedAt) ? void 0 : "renewedAt is not a number";
281
+ }
282
+ //#endregion
283
+ //#region src/lease/domain/lease/policies/freshness.ts
284
+ /** Restarts the observer's clock when the record has a revision it has not seen. */
285
+ function observe(previous, lease, now) {
286
+ return previous?.revision === lease.revision ? previous : {
287
+ revision: lease.revision,
288
+ observedAt: now
289
+ };
290
+ }
291
+ /**
292
+ * Whether a held lease is still valid for this observer: its record changed less than `ttlMs` ago by the observer's
293
+ * monotonic clock. A record whose revision the observation has not seen has just changed, so it is fresh. A
294
+ * tombstone is never fresh.
295
+ */
296
+ function isFresh(lease, observation, now, ttlMs) {
297
+ return lease.holder !== null && (observation.revision !== lease.revision || now - observation.observedAt < ttlMs);
298
+ }
299
+ /**
300
+ * Whether the holder itself must give up: it has not confirmed a renewal for a full TTL, so an observer's clock may
301
+ * already have expired the lease and a takeover can have happened. Both times come from the holder's own monotonic
302
+ * clock; `confirmedAt` is when its last renewal write was accepted.
303
+ */
304
+ function holderExpired(confirmedAt, now, ttlMs) {
305
+ return now - confirmedAt >= ttlMs;
306
+ }
307
+ //#endregion
308
+ //#region src/lease/domain/lease/value-objects/lease-timing.ts
309
+ /** Accepts a timing where every duration is positive and one late heartbeat cannot lose the lease. */
310
+ function leaseTiming(input) {
311
+ const { ttlMs, heartbeatMs } = input;
312
+ const retryMs = input.retryMs ?? heartbeatMs;
313
+ const invalid = (message) => err({
314
+ _tag: "LeaseConfigInvalid",
315
+ message
316
+ });
317
+ for (const [name, value] of [
318
+ ["ttlMs", ttlMs],
319
+ ["heartbeatMs", heartbeatMs],
320
+ ["retryMs", retryMs]
321
+ ]) if (!Number.isFinite(value) || value <= 0) return invalid(`${name} must be a positive number of milliseconds, got ${value}`);
322
+ if (heartbeatMs * 2 > ttlMs) return invalid(`heartbeatMs × 2 must not exceed ttlMs (${heartbeatMs} × 2 > ${ttlMs})`);
323
+ return ok({
324
+ ttlMs,
325
+ heartbeatMs,
326
+ retryMs
327
+ });
328
+ }
329
+ //#endregion
330
+ //#region src/lease/infra/adapters/process-fence.ts
331
+ /** The first retry of a waiting fence or guard; later ones back off from it. */
332
+ const RETRY_MS = 10;
333
+ /**
334
+ * Holds a process lock at `path` until the Scope closes: an exclusive SQLite lock when the platform has SQLite, a
335
+ * lock file otherwise. Each attempt is uninterruptible and registers its release in the same step; waiting happens
336
+ * between attempts, where interruption is safe, with a growing pause. A holder that exits frees the lock at once.
337
+ */
338
+ function holdProcessLock(platform, key, path, options = {}) {
339
+ const attempt = (first) => Effect.acquireRelease(Effect.tryPromise({
340
+ try: () => tryProcessLock(platform, path, { first }),
341
+ catch: (cause) => repositoryFailure(key, "io", `cannot lock ${path}`, cause)
342
+ }).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(repositoryFailure(key, "busy", `${path} is locked`)))), (lock) => Effect.promise(() => lock.release()));
343
+ return Effect.gen(function* () {
344
+ const started = platform.clock.monotonic();
345
+ for (let turn = 0;; turn += 1) {
346
+ if (yield* attempt(turn === 0).pipe(Effect.as(true), Effect.catchIf((failure) => failure.reason === "busy", () => Effect.succeed(false)))) return;
347
+ if (options.timeoutMs !== void 0 && platform.clock.monotonic() - started >= options.timeoutMs) return yield* Effect.fail(repositoryFailure(key, "busy", `${path} is still locked after ${options.timeoutMs} ms`));
348
+ yield* Effect.sleep(backoffMs(RETRY_MS, turn));
349
+ }
350
+ });
351
+ }
352
+ //#endregion
353
+ //#region src/lease/infra/repository/file-lease-repository.ts
354
+ const SCHEMA_VERSION$1 = 1;
355
+ /**
356
+ * A guard is held only for one read, compare and write, so waiting longer means a stalled holder: the write reports
357
+ * `busy` instead of waiting, and callers such as a heartbeat retry it later.
358
+ */
359
+ const GUARD_TIMEOUT_MS = 1e3;
360
+ /**
361
+ * The fallback repository for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
362
+ * `writeAtomic`. Every `save` holds the key's guard (`<key>.lease.guard`) while it reads, compares and writes, and
363
+ * reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`. Both are process
364
+ * locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a time, with the
365
+ * gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused and never
366
+ * overwritten.
367
+ */
368
+ function fileLeaseRepository(options) {
369
+ const { dir } = options;
370
+ return Layer.effect(LeaseRepository, Effect.gen(function* () {
371
+ const platform = yield* PlatformService;
372
+ const pathOf = (key, suffix) => keyFileName(key).pipe(Effect.map((name) => `${dir}/${name}.lease${suffix}`));
373
+ const load = (key) => pathOf(key, ".json").pipe(Effect.flatMap((path) => readRecord(platform, key, path)));
374
+ return {
375
+ load,
376
+ save: (key, next, expectedRevision) => Effect.scoped(Effect.gen(function* () {
377
+ yield* holdProcessLock(platform, key, yield* pathOf(key, ".guard"), { timeoutMs: GUARD_TIMEOUT_MS });
378
+ const path = yield* pathOf(key, ".json");
379
+ return yield* Effect.uninterruptible(Effect.gen(function* () {
380
+ const current = yield* readRecord(platform, key, path);
381
+ if (current?.revision !== expectedRevision) return yield* Effect.fail(revisionConflict(key, expectedRevision, current?.revision));
382
+ yield* Effect.tryPromise({
383
+ try: () => platform.fs.writeAtomic(path, JSON.stringify({
384
+ schemaVersion: SCHEMA_VERSION$1,
385
+ record: next
386
+ })),
387
+ catch: (cause) => repositoryFailure(key, "io", `cannot write ${path}`, cause)
388
+ });
389
+ }));
390
+ })),
391
+ fence: (key) => pathOf(key, ".fence").pipe(Effect.flatMap((path) => holdProcessLock(platform, key, path)))
392
+ };
393
+ }));
394
+ }
395
+ function readRecord(platform, key, path) {
396
+ return Effect.tryPromise({
397
+ try: () => readText(platform, path),
398
+ catch: (cause) => repositoryFailure(key, "io", `cannot read ${path}`, cause)
399
+ }).pipe(Effect.flatMap((text) => text === void 0 ? Effect.succeed(void 0) : decodeFile(key, path, text)));
400
+ }
401
+ function decodeFile(key, path, text) {
402
+ let json;
403
+ try {
404
+ json = JSON.parse(text);
405
+ } catch (cause) {
406
+ return Effect.fail(repositoryFailure(key, "invalid-record", `${path} is not JSON`, cause));
407
+ }
408
+ const { schemaVersion, record } = typeof json === "object" && json !== null ? json : {};
409
+ if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(repositoryFailure(key, "unsupported-schema", `${path} has schemaVersion ${String(schemaVersion)}; expected 1`));
410
+ const snapshot = decodeSnapshot(record);
411
+ return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `${path} holds a record of an unexpected shape`)) : Effect.succeed(snapshot);
412
+ }
413
+ //#endregion
414
+ //#region src/lease/infra/repository/memory-lease-repository.ts
415
+ /**
416
+ * A repository for one process, such as tests or a host whose leases never cross a process boundary. Each Layer
417
+ * build starts empty.
418
+ */
419
+ function memoryLeaseRepository() {
420
+ return Layer.sync(LeaseRepository, () => {
421
+ const records = /* @__PURE__ */ new Map();
422
+ const fences = /* @__PURE__ */ new Map();
423
+ const fenceOf = (key) => {
424
+ const fence = fences.get(key) ?? Semaphore.makeUnsafe(1);
425
+ fences.set(key, fence);
426
+ return fence;
427
+ };
428
+ return {
429
+ load: (key) => Effect.sync(() => records.get(key)),
430
+ save: (key, next, expectedRevision) => Effect.suspend(() => {
431
+ const stored = records.get(key);
432
+ if (stored?.revision !== expectedRevision) return Effect.fail(revisionConflict(key, expectedRevision, stored?.revision));
433
+ records.set(key, next);
434
+ return Effect.void;
435
+ }),
436
+ fence: (key) => Effect.acquireRelease(fenceOf(key).take(1), () => fenceOf(key).release(1), { interruptible: true }).pipe(Effect.asVoid)
437
+ };
438
+ });
439
+ }
440
+ //#endregion
441
+ //#region src/lease/infra/repository/sqlite-lease-repository.ts
442
+ const SCHEMA_VERSION = 1;
443
+ /** Long enough for another writer's single-row transaction; longer waits block the event loop, so retry instead. */
444
+ const BUSY_TIMEOUT_MS = 50;
445
+ const BUSY_RETRIES = 100;
446
+ const BUSY_RETRY_MS = 5;
447
+ /**
448
+ * The lease repository for one machine: one row per key in a SQLite database. `save` runs in a `BEGIN IMMEDIATE`
449
+ * transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so processes sharing the
450
+ * file compare and write atomically. The lock is released when its process exits, so neither a crashed writer nor a
451
+ * reused pid can leave the repository stuck. Fails to build with `unavailable` when the platform has no SQLite, and
452
+ * with `unsupported-schema` for a database written by a newer version.
453
+ */
454
+ function sqliteLeaseRepository(options) {
455
+ const { path } = options;
456
+ return Layer.effect(LeaseRepository, Effect.gen(function* () {
457
+ const platform = yield* PlatformService;
458
+ const { sqlite } = platform;
459
+ if (sqlite === void 0) return yield* Effect.fail(repositoryFailure("", "unavailable", "the platform has no SQLite"));
460
+ const db = yield* Effect.acquireRelease(sqliteTry("", `cannot open ${path}`, () => sqlite.open(path)), (opened) => Effect.sync(() => opened.close()));
461
+ yield* retryBusy(sqliteTry("", `cannot prepare ${path}`, () => migrate(db)));
462
+ const statements = {
463
+ select: db.prepare("SELECT key, generation, revision, holder, holder_id, renewed_at FROM lease_record WHERE key = ?"),
464
+ selectRevision: db.prepare("SELECT revision FROM lease_record WHERE key = ?"),
465
+ upsert: db.prepare(`INSERT INTO lease_record (key, generation, revision, holder, holder_id, renewed_at) VALUES (?, ?, ?, ?, ?, ?)
466
+ ON CONFLICT (key) DO UPDATE SET generation = excluded.generation, revision = excluded.revision,
467
+ holder = excluded.holder, holder_id = excluded.holder_id, renewed_at = excluded.renewed_at`)
468
+ };
469
+ return {
470
+ load: (key) => retryBusy(sqliteTry(key, `cannot read ${path}`, () => statements.select.get(key))).pipe(Effect.flatMap((row) => decodeRow(key, row))),
471
+ save: (key, next, expectedRevision) => retryBusy(sqliteTry(key, `cannot write ${path}`, () => saveInTransaction(db, statements, key, next, expectedRevision))).pipe(Effect.flatMap((outcome) => outcome === void 0 ? Effect.void : Effect.fail(outcome))),
472
+ fence: (key) => keyFileName(key).pipe(Effect.flatMap((name) => holdProcessLock(platform, key, `${path}.${name}.fence`)))
473
+ };
474
+ }));
475
+ }
476
+ var UnsupportedSchema = class extends Error {};
477
+ function migrate(db) {
478
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
479
+ db.exec("PRAGMA journal_mode = WAL");
480
+ transaction(db, () => {
481
+ const version = Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
482
+ if (version === 0) {
483
+ db.exec(`CREATE TABLE IF NOT EXISTS lease_record (
484
+ key TEXT PRIMARY KEY,
485
+ generation INTEGER NOT NULL,
486
+ revision INTEGER NOT NULL,
487
+ holder TEXT,
488
+ holder_id TEXT,
489
+ renewed_at INTEGER NOT NULL
490
+ ) STRICT`);
491
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
492
+ } else if (version !== SCHEMA_VERSION) throw new UnsupportedSchema(`the lease database has schema version ${version}; this version reads ${SCHEMA_VERSION}`);
493
+ });
494
+ }
495
+ function saveInTransaction(db, statements, key, next, expectedRevision) {
496
+ return transaction(db, () => {
497
+ const storedRow = statements.selectRevision.get(next.key);
498
+ const stored = storedRow === void 0 ? void 0 : Number(storedRow.revision);
499
+ if (stored !== expectedRevision) return revisionConflict(key, expectedRevision, stored);
500
+ statements.upsert.run(next.key, next.generation, next.revision, encodeHolder(next.holder), next.holderId, next.renewedAt);
501
+ });
502
+ }
503
+ /** Runs `body` in a `BEGIN IMMEDIATE` transaction, committing when it returns and rolling back when it throws. */
504
+ function transaction(db, body) {
505
+ db.exec("BEGIN IMMEDIATE");
506
+ try {
507
+ const result = body();
508
+ db.exec("COMMIT");
509
+ return result;
510
+ } catch (error) {
511
+ try {
512
+ db.exec("ROLLBACK");
513
+ } catch {}
514
+ throw error;
515
+ }
516
+ }
517
+ function sqliteTry(key, message, body) {
518
+ return Effect.try({
519
+ try: body,
520
+ catch: (cause) => {
521
+ if (cause instanceof UnsupportedSchema) return repositoryFailure(key, "unsupported-schema", cause.message);
522
+ return repositoryFailure(key, isSqliteBusy(cause) ? "busy" : "io", message, cause);
523
+ }
524
+ });
525
+ }
526
+ function retryBusy(effect) {
527
+ return effect.pipe(Effect.retry({
528
+ while: (failure) => failure.reason === "busy",
529
+ times: BUSY_RETRIES,
530
+ schedule: Schedule.spaced(BUSY_RETRY_MS)
531
+ }));
532
+ }
533
+ function decodeRow(key, row) {
534
+ if (row === void 0) return Effect.succeed(void 0);
535
+ const holder = typeof row.holder === "string" || row.holder === null ? decodeHolder(row.holder) : void 0;
536
+ const snapshot = holder === void 0 ? void 0 : decodeSnapshot({
537
+ key: row.key,
538
+ generation: row.generation,
539
+ revision: row.revision,
540
+ holder,
541
+ holderId: row.holder_id,
542
+ renewedAt: row.renewed_at
543
+ });
544
+ return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `the stored record for ${key} has an unexpected shape`)) : Effect.succeed(snapshot);
545
+ }
546
+ //#endregion
547
+ //#region src/lease/application/services/from-result.ts
548
+ /** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
549
+ const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
550
+ //#endregion
551
+ //#region src/lease/application/use-cases/lease-manager.ts
552
+ /** CAS attempts within one acquisition before the repository counts as busy; each lost race re-reads the record. */
553
+ const MAX_CAS_TURNS = 8;
554
+ /** Validates `config` and builds a manager over the `LeaseRepository` and the platform in context. */
555
+ function createLeaseManager(config) {
556
+ return Effect.gen(function* () {
557
+ return makeManager(yield* fromResult(leaseTiming(config)), yield* LeaseRepository, yield* PlatformService);
558
+ });
559
+ }
560
+ function makeManager(timing, store, platform) {
561
+ const observations = /* @__PURE__ */ new Map();
562
+ const now = () => platform.clock.monotonic();
563
+ const self = () => {
564
+ const { host, bootId, pid, startTime } = platform.process.self;
565
+ return {
566
+ host,
567
+ bootId,
568
+ pid,
569
+ startTime
570
+ };
571
+ };
572
+ /** A holder that cannot be looked up (the platform failed to identify the pid) is judged by its TTL alone. */
573
+ const judge = (holder, observer) => Effect.try({
574
+ try: () => holderLiveness(holder, observer, platform.process.identify(holder.pid)),
575
+ catch: (cause) => cause
576
+ }).pipe(Effect.orElseSucceed(() => "unknown"));
577
+ /** One acquisition attempt: reads the record, applies the Lease rules, and writes over exactly that revision. */
578
+ const take = (key, holderId) => Effect.gen(function* () {
579
+ for (let turn = 0; turn < MAX_CAS_TURNS; turn += 1) {
580
+ const record = yield* store.load(key);
581
+ const claim = {
582
+ key,
583
+ holder: self(),
584
+ holderId,
585
+ now: platform.clock.now()
586
+ };
587
+ let transition;
588
+ if (record === void 0) transition = Lease.create(claim);
589
+ else {
590
+ const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message)));
591
+ const at = now();
592
+ const observation = observe(observations.get(key), record, at);
593
+ observations.set(key, observation);
594
+ const fresh = isFresh(record, observation, at, timing.ttlMs);
595
+ const { holder } = record;
596
+ const liveness = fresh && holder !== null ? yield* judge(holder, claim.holder) : "unknown";
597
+ transition = yield* fromResult(lease.acquire(claim, {
598
+ fresh,
599
+ liveness
600
+ }));
601
+ }
602
+ const next = transition.state.toSnapshot();
603
+ if (yield* store.save(key, next, record?.revision).pipe(Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false)))) {
604
+ observations.delete(key);
605
+ return next;
606
+ }
607
+ }
608
+ return yield* Effect.fail(repositoryFailure(key, "busy", "the lease record kept changing during acquisition"));
609
+ });
610
+ const lostBy = (key, holding) => Effect.gen(function* () {
611
+ const read = yield* Effect.exit(store.load(key));
612
+ const reason = Exit.isSuccess(read) ? lossReason(read.value) : "taken-over";
613
+ return {
614
+ _tag: "LeaseLost",
615
+ key,
616
+ generation: holding.generation,
617
+ reason
618
+ };
619
+ });
620
+ /**
621
+ * Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a
622
+ * repository failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it
623
+ * over. The write stays interruptible while it waits for the repository; `release` re-reads the record, so a
624
+ * renewal interrupted between its write and `Ref.set` cannot keep the tombstone from being written.
625
+ */
626
+ const heartbeat = (key, holding, ref) => Effect.gen(function* () {
627
+ let confirmedAt = now();
628
+ for (;;) {
629
+ yield* Effect.sleep(timing.heartbeatMs);
630
+ const current = yield* Ref.get(ref);
631
+ const restored = Lease.restore(current);
632
+ const transition = restored.ok ? restored.value.renew(holding, platform.clock.now()) : void 0;
633
+ if (transition?.ok !== true) return yield* Effect.fail(yield* lostBy(key, holding));
634
+ const next = transition.value.state.toSnapshot();
635
+ const written = yield* Effect.exit(store.save(key, next, current.revision).pipe(Effect.tap(() => Ref.set(ref, next)), Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false))));
636
+ if (Exit.isSuccess(written)) {
637
+ if (!written.value) return yield* Effect.fail(yield* lostBy(key, holding));
638
+ confirmedAt = now();
639
+ } else if (holderExpired(confirmedAt, now(), timing.ttlMs)) return yield* Effect.fail({
640
+ _tag: "LeaseLost",
641
+ key,
642
+ generation: holding.generation,
643
+ reason: "expired"
644
+ });
645
+ }
646
+ });
647
+ /**
648
+ * Writes the tombstone over the record as read now, if it is still this acquisition's. A failure leaves the lease
649
+ * to expire after the TTL; a finalizer cannot fail.
650
+ */
651
+ const release = (key, holding) => Effect.gen(function* () {
652
+ const current = yield* store.load(key);
653
+ const restored = current === void 0 ? void 0 : Lease.restore(current);
654
+ const transition = restored?.ok === true ? restored.value.release(holding, platform.clock.now()) : void 0;
655
+ if (current !== void 0 && transition?.ok === true) yield* store.save(key, transition.value.state.toSnapshot(), current.revision);
656
+ }).pipe(Effect.ignore);
657
+ const handle = (key, holding, lost) => {
658
+ const token = {
659
+ key,
660
+ generation: holding.generation
661
+ };
662
+ return {
663
+ key,
664
+ token,
665
+ lost: Deferred.await(lost),
666
+ runFenced: (work) => Effect.scoped(Effect.gen(function* () {
667
+ if (yield* Deferred.isDone(lost)) return yield* Deferred.await(lost);
668
+ yield* store.fence(key).pipe(Effect.raceFirst(Deferred.await(lost)));
669
+ const record = yield* store.load(key);
670
+ if ((record === void 0 ? void 0 : yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message))))?.holds(holding) !== true) {
671
+ const current = record?.generation;
672
+ return yield* Effect.fail({
673
+ _tag: "FenceRejected",
674
+ key,
675
+ generation: token.generation,
676
+ current
677
+ });
678
+ }
679
+ return yield* Effect.raceFirst(work(token), Deferred.await(lost));
680
+ }))
681
+ };
682
+ };
683
+ return {
684
+ acquire: (key, options = {}) => {
685
+ if (key === "") return Effect.die(new AgentKitError("invalid-lease-key", "A lease key must not be empty"));
686
+ const holderId = crypto.randomUUID();
687
+ const attempt = Effect.acquireRelease(take(key, holderId).pipe(Effect.flatMap((snapshot) => Ref.make(snapshot))), (ref) => Effect.flatMap(Ref.get(ref), ({ generation }) => release(key, {
688
+ holderId,
689
+ generation
690
+ })));
691
+ const acquired = options.wait === true ? attempt.pipe(Effect.retry({
692
+ while: (error) => error._tag === "LeaseHeld" || error.reason === "busy",
693
+ schedule: Schedule.spaced(timing.retryMs)
694
+ })) : attempt;
695
+ return Effect.gen(function* () {
696
+ const ref = yield* acquired;
697
+ const { generation } = yield* Ref.get(ref);
698
+ const holding = {
699
+ holderId,
700
+ generation
701
+ };
702
+ const lost = yield* Deferred.make();
703
+ yield* Effect.forkScoped(heartbeat(key, holding, ref).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Deferred.failCause(lost, cause))));
704
+ return handle(key, holding, lost);
705
+ });
706
+ },
707
+ read: (key) => store.load(key)
708
+ };
709
+ }
710
+ //#endregion
711
+ export { LeaseRepository, canAcquire, checkFence, createLeaseManager, fileLeaseRepository, holderLiveness, isFresh, memoryLeaseRepository, nextFencingToken, sqliteLeaseRepository };