@rivus/agent-kit-collab 0.0.0 → 0.3.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/lanes.js ADDED
@@ -0,0 +1,323 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { err, ok } from "@rivus/agent-kit/catalog";
3
+ import * as Cause from "effect/Cause";
4
+ import * as Deferred from "effect/Deferred";
5
+ import * as Exit from "effect/Exit";
6
+ //#region src/lanes/domain/lane/policies/admission.ts
7
+ /**
8
+ * Where a wake of an idle lane goes: it starts while a slot is free and nobody waits for one, waits at the end of the
9
+ * queue while the queue has room, and is refused otherwise. A newcomer never passes a lane that is already waiting.
10
+ */
11
+ function admit(load, limits) {
12
+ if (load.running < limits.maxConcurrent && load.queued === 0) return "start";
13
+ return load.queued < limits.maxQueued ? "queue" : "full";
14
+ }
15
+ //#endregion
16
+ //#region src/lanes/domain/lane/aggregate/lane.ts
17
+ /**
18
+ * One key's lane, held in memory only. At most one activation runs at a time, and every wake that arrives before an
19
+ * activation starts is served by that activation: wakes coalesce instead of queueing one run each. Only an idle lane
20
+ * asks for a slot, so a lane occupies at most one place in the queue.
21
+ */
22
+ var Lane = class Lane {
23
+ static create(key) {
24
+ return new Lane({
25
+ key,
26
+ state: "idle",
27
+ pending: false
28
+ });
29
+ }
30
+ snapshot;
31
+ constructor(snapshot) {
32
+ this.snapshot = snapshot;
33
+ Object.freeze(this);
34
+ }
35
+ /**
36
+ * An idle lane starts or joins the queue as `admit` decides, and is refused with `LaneQueueFull` when the queue has
37
+ * no room. A queued or running lane takes the wake as pending: it never asks for another slot.
38
+ */
39
+ wake(load, limits) {
40
+ const { key, state } = this.snapshot;
41
+ if (state !== "idle") return ok(this.to({
42
+ key,
43
+ state,
44
+ pending: true
45
+ }, {
46
+ _tag: "WakeCoalesced",
47
+ key
48
+ }));
49
+ switch (admit(load, limits)) {
50
+ case "start": return ok(this.to({
51
+ key,
52
+ state: "running",
53
+ pending: false
54
+ }, {
55
+ _tag: "ActivationStarted",
56
+ key
57
+ }));
58
+ case "queue": return ok(this.to({
59
+ key,
60
+ state: "queued",
61
+ pending: true
62
+ }, {
63
+ _tag: "LaneQueued",
64
+ key
65
+ }));
66
+ case "full": return err({
67
+ _tag: "LaneQueueFull",
68
+ key,
69
+ maxQueued: limits.maxQueued
70
+ });
71
+ }
72
+ }
73
+ /** A queued lane got a slot: its activation starts and serves every wake so far. */
74
+ start() {
75
+ const { key } = this.expect("queued");
76
+ return this.to({
77
+ key,
78
+ state: "running",
79
+ pending: false
80
+ }, {
81
+ _tag: "LaneDequeued",
82
+ key
83
+ }, {
84
+ _tag: "ActivationStarted",
85
+ key
86
+ });
87
+ }
88
+ /**
89
+ * The running activation ended. A wake it did not serve sends the lane to the end of the queue, behind the lanes
90
+ * that waited while it ran; the slot it frees goes to the head of the queue first.
91
+ */
92
+ finish() {
93
+ const { key, pending } = this.expect("running");
94
+ const ended = {
95
+ _tag: "ActivationEnded",
96
+ key
97
+ };
98
+ return pending ? this.to({
99
+ key,
100
+ state: "queued",
101
+ pending: true
102
+ }, ended, {
103
+ _tag: "LaneQueued",
104
+ key
105
+ }) : this.to({
106
+ key,
107
+ state: "idle",
108
+ pending: false
109
+ }, ended);
110
+ }
111
+ /**
112
+ * Drops the wakes the lane owes: a queued lane leaves the queue, a running one keeps running without a follow-up.
113
+ * Ending the running activation is the caller's part; wakes after the cancel are owed again.
114
+ */
115
+ cancel() {
116
+ const { key, state, pending } = this.snapshot;
117
+ if (state === "queued") return this.to({
118
+ key,
119
+ state: "idle",
120
+ pending: false
121
+ }, {
122
+ _tag: "LaneDequeued",
123
+ key
124
+ });
125
+ if (state === "running" && pending) return this.to({
126
+ key,
127
+ state,
128
+ pending: false
129
+ }, {
130
+ _tag: "PendingDropped",
131
+ key
132
+ });
133
+ return {
134
+ state: this,
135
+ events: []
136
+ };
137
+ }
138
+ toSnapshot() {
139
+ return this.snapshot;
140
+ }
141
+ to(snapshot, ...events) {
142
+ return {
143
+ state: new Lane(snapshot),
144
+ events
145
+ };
146
+ }
147
+ /** Starting a lane that is not queued, or finishing one that is not running, is a scheduler bug, not an outcome. */
148
+ expect(state) {
149
+ if (this.snapshot.state !== state) throw new Error(`lane ${this.snapshot.key} is ${this.snapshot.state}, not ${state}`);
150
+ return this.snapshot;
151
+ }
152
+ };
153
+ //#endregion
154
+ //#region src/lanes/domain/lane/value-objects/lane-limits.ts
155
+ function laneLimits(input) {
156
+ const { maxConcurrent, maxQueued = Number.POSITIVE_INFINITY, turnTimeoutMs } = input;
157
+ const invalid = (message) => err({
158
+ _tag: "LanesConfigInvalid",
159
+ message
160
+ });
161
+ if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) return invalid(`maxConcurrent must be a positive integer, got ${maxConcurrent}`);
162
+ if (maxQueued !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(maxQueued) || maxQueued < 0)) return invalid(`maxQueued must be a non-negative integer, got ${maxQueued}`);
163
+ if (turnTimeoutMs !== void 0 && (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs <= 0)) return invalid(`turnTimeoutMs must be a positive number of milliseconds, got ${turnTimeoutMs}`);
164
+ return ok({
165
+ maxConcurrent,
166
+ maxQueued,
167
+ turnTimeoutMs
168
+ });
169
+ }
170
+ //#endregion
171
+ //#region src/lanes/application/from-result.ts
172
+ /** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
173
+ const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
174
+ //#endregion
175
+ //#region src/lanes/application/create-lanes.ts
176
+ /** An end the activation did not reach by itself: an interruption, by the lanes or its own. */
177
+ const cutShort = (exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
178
+ /**
179
+ * Creates lanes in the caller's Scope. The lanes capture the context they are created in and give it to every
180
+ * activation; fails with `LanesConfigInvalid` for a limit out of range.
181
+ */
182
+ function createLanes(config) {
183
+ return Effect.gen(function* () {
184
+ const limits = yield* fromResult(laneLimits(config));
185
+ const context = yield* Effect.context();
186
+ const { activate, onExit } = config;
187
+ const lanes = /* @__PURE__ */ new Map();
188
+ const activations = /* @__PURE__ */ new Map();
189
+ const queue = [];
190
+ let closed = false;
191
+ const laneOf = (key) => {
192
+ const lane = lanes.get(key);
193
+ if (lane === void 0) throw new Error(`no lane for ${key}`);
194
+ return lane;
195
+ };
196
+ /** Records a transition and returns the activations it started; the caller forks them. */
197
+ const apply = ({ state, events }) => {
198
+ const { key, state: laneState } = state.toSnapshot();
199
+ if (laneState === "idle") lanes.delete(key);
200
+ else lanes.set(key, state);
201
+ const started = [];
202
+ for (const event of events) switch (event._tag) {
203
+ case "LaneQueued":
204
+ queue.push(key);
205
+ break;
206
+ case "LaneDequeued":
207
+ queue.splice(queue.indexOf(key), 1);
208
+ break;
209
+ case "ActivationStarted": {
210
+ const activation = {
211
+ key,
212
+ stop: Deferred.makeUnsafe(),
213
+ ended: Deferred.makeUnsafe()
214
+ };
215
+ activations.set(key, activation);
216
+ started.push(activation);
217
+ break;
218
+ }
219
+ case "ActivationEnded": activations.delete(key);
220
+ }
221
+ return started;
222
+ };
223
+ /** Gives the free slots to the lanes that have waited longest. */
224
+ const pump = () => {
225
+ const started = [];
226
+ for (let key = queue[0]; !closed && key !== void 0 && activations.size < limits.maxConcurrent; key = queue[0]) started.push(...apply(laneOf(key).start()));
227
+ return started;
228
+ };
229
+ const interrupted = (key, reason) => ({
230
+ _tag: "ActivationInterrupted",
231
+ key,
232
+ reason
233
+ });
234
+ const exitOf = (key, exit) => Exit.isSuccess(exit) ? {
235
+ _tag: "ActivationSucceeded",
236
+ key
237
+ } : {
238
+ _tag: "ActivationFailed",
239
+ key,
240
+ cause: exit.cause
241
+ };
242
+ /**
243
+ * Runs the work, raced against `stop`; the loser is interrupted and awaited, which for the work includes closing
244
+ * its Scope. `stop` comes first in the race, so an activation stopped before its fiber started never calls
245
+ * `activate`: the race forks its contenders in order and stops at the first that has ended. The turn timer only
246
+ * completes `stop`, so a cancel or close that came first keeps its reason while the cleanup outlasts the deadline.
247
+ */
248
+ const settle = ({ key, stop }) => Effect.suspend(() => {
249
+ let body;
250
+ let work;
251
+ const record = Effect.scoped(Effect.suspend(() => activate(key)).pipe(Effect.onExit((exit) => Effect.sync(() => {
252
+ body = exit;
253
+ })))).pipe(Effect.onExit((exit) => Effect.sync(() => {
254
+ work = exit;
255
+ })), Effect.exit);
256
+ const raced = Effect.raceFirst(Deferred.await(stop), record);
257
+ const { turnTimeoutMs } = limits;
258
+ const timed = turnTimeoutMs === void 0 ? raced : Effect.raceFirst(raced, Effect.sleep(turnTimeoutMs).pipe(Effect.andThen(Deferred.succeed(stop, "timeout")), Effect.andThen(Effect.never)));
259
+ return Effect.map(timed, (result) => {
260
+ if (typeof result !== "string") return exitOf(key, result);
261
+ if (body === void 0 || cutShort(body)) return interrupted(key, result);
262
+ const own = (work === void 0 || Exit.isSuccess(work) ? [] : work.cause.reasons).filter((reason) => !Cause.isInterruptReason(reason));
263
+ return exitOf(key, own.length === 0 ? body : Exit.failCause(Cause.fromReasons(own)));
264
+ });
265
+ });
266
+ /**
267
+ * One activation's fiber. It is forked uninterruptible and nothing else holds it, so it always reaches the end of
268
+ * the activation, which frees the slot; only the raced work inside can be interrupted.
269
+ */
270
+ const run = (activation) => Effect.gen(function* () {
271
+ const exit = yield* settle(activation);
272
+ if (onExit !== void 0) yield* Effect.scoped(Effect.suspend(() => onExit(exit))).pipe(Effect.catchCause((cause) => Effect.logWarning(`lanes: onExit failed for ${exit.key}`, cause)));
273
+ yield* Effect.suspend(() => {
274
+ const started = [...apply(laneOf(activation.key).finish()), ...pump()];
275
+ Deferred.doneUnsafe(activation.ended, Exit.void);
276
+ return fork(started);
277
+ });
278
+ }).pipe(Effect.provideContext(context));
279
+ const fork = (started) => Effect.forEach(started, (activation) => Effect.forkDetach(run(activation), { uninterruptible: true }), { discard: true });
280
+ const wakeResult = (events) => events.some(({ _tag }) => _tag === "ActivationStarted") ? "started" : events.some(({ _tag }) => _tag === "LaneQueued") ? "queued" : "coalesced";
281
+ const close = Effect.suspend(() => {
282
+ if (!closed) {
283
+ closed = true;
284
+ for (const lane of lanes.values()) apply(lane.cancel());
285
+ }
286
+ const running = [...activations.values()];
287
+ for (const { stop } of running) Deferred.doneUnsafe(stop, Exit.succeed("close"));
288
+ return Effect.forEach(running, ({ ended }) => Deferred.await(ended), { discard: true });
289
+ });
290
+ yield* Effect.addFinalizer(() => close);
291
+ return {
292
+ wake: (key) => Effect.uninterruptible(Effect.suspend(() => {
293
+ if (closed) return Effect.fail({
294
+ _tag: "LanesClosed",
295
+ key
296
+ });
297
+ const woken = (lanes.get(key) ?? Lane.create(key)).wake({
298
+ running: activations.size,
299
+ queued: queue.length
300
+ }, limits);
301
+ if (!woken.ok) return Effect.fail(woken.error);
302
+ return Effect.as(fork(apply(woken.value)), wakeResult(woken.value.events));
303
+ })),
304
+ cancel: (key) => Effect.suspend(() => {
305
+ const lane = lanes.get(key);
306
+ if (lane !== void 0) apply(lane.cancel());
307
+ const activation = activations.get(key);
308
+ if (activation === void 0) return Effect.void;
309
+ Deferred.doneUnsafe(activation.stop, Exit.succeed("cancel"));
310
+ return Deferred.await(activation.ended);
311
+ }),
312
+ status: Effect.sync(() => ({
313
+ running: activations.size,
314
+ queued: queue.length,
315
+ closed,
316
+ lanes: [...activations.keys(), ...queue].map((key) => laneOf(key).toSnapshot())
317
+ })),
318
+ close
319
+ };
320
+ });
321
+ }
322
+ //#endregion
323
+ export { createLanes };
@@ -0,0 +1,265 @@
1
+ import { PlatformService } from "@rivus/agent-kit/platform/effect";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Context from "effect/Context";
5
+ import { Result } from "@rivus/agent-kit/catalog";
6
+ import * as Scope from "effect/Scope";
7
+ //#region src/lease/domain/lease/value-objects/holder.d.ts
8
+ /**
9
+ * The process that holds a lease, with the same shape as the Platform's `ProcessIdentity`. The start time tells a
10
+ * reused pid apart from the holder; the boot id tells a process of an earlier boot apart.
11
+ */
12
+ interface Holder {
13
+ readonly host: string;
14
+ readonly bootId: string;
15
+ readonly pid: number;
16
+ readonly startTime: number;
17
+ }
18
+ /** `unknown` when the holder runs on another host, where the observer cannot look it up. */
19
+ type HolderLiveness = "alive" | "dead" | "unknown";
20
+ //#endregion
21
+ //#region src/lease/domain/lease/errors/lease-held.d.ts
22
+ /** Another acquisition holds the lease and is alive and renewing, as far as the observer can tell. */
23
+ interface LeaseHeld {
24
+ readonly _tag: "LeaseHeld";
25
+ readonly key: string;
26
+ readonly generation: number;
27
+ readonly holder: Holder;
28
+ readonly holderId: string;
29
+ }
30
+ //#endregion
31
+ //#region src/lease/domain/lease/errors/lease-lost.d.ts
32
+ /**
33
+ * This acquisition no longer holds the lease: another one took it over or released it, the record vanished, or no
34
+ * renewal could be confirmed within the TTL.
35
+ */
36
+ interface LeaseLost {
37
+ readonly _tag: "LeaseLost";
38
+ readonly key: string;
39
+ readonly generation: number;
40
+ readonly reason: "taken-over" | "released" | "missing" | "expired";
41
+ }
42
+ //#endregion
43
+ //#region src/lease/domain/lease/value-objects/lease-snapshot.d.ts
44
+ /** A lease record as stores keep it. A released lease stays as a tombstone with no holder. */
45
+ interface LeaseSnapshot {
46
+ readonly key: string;
47
+ /** The fencing token: +1 on every acquisition, unchanged by renewals and release. */
48
+ readonly generation: number;
49
+ /** +1 on every write; stores compare it to write only over the record a caller read. */
50
+ readonly revision: number;
51
+ readonly holder: Holder | null;
52
+ /** Names one acquisition, so that two acquisitions by the same process are told apart. */
53
+ readonly holderId: string | null;
54
+ /** Wall-clock time of the last write, for diagnostics; expiry is judged with the observer's monotonic clock. */
55
+ readonly renewedAt: number;
56
+ }
57
+ //#endregion
58
+ //#region src/lease/domain/lease/policies/acquisition.d.ts
59
+ /** What the observer found out about the current holder before trying to acquire. */
60
+ interface AcquisitionView {
61
+ /** `isFresh` for the record as read. */
62
+ readonly fresh: boolean;
63
+ /** `holderLiveness` for its holder; irrelevant for a tombstone. */
64
+ readonly liveness: HolderLiveness;
65
+ }
66
+ /**
67
+ * A lease can be taken when there is no record, the record is a tombstone, its holder is dead, or it has not been
68
+ * renewed within the TTL as the observer saw it. A live or unknown holder that keeps renewing keeps the lease.
69
+ */
70
+ export declare function canAcquire(lease: LeaseSnapshot | undefined, view: AcquisitionView): boolean;
71
+ //#endregion
72
+ //#region src/lease/domain/lease/errors/fence-rejected.d.ts
73
+ /** A fenced write carried an older generation than the lease record or the protected resource has seen. */
74
+ interface FenceRejected {
75
+ readonly _tag: "FenceRejected";
76
+ readonly key: string;
77
+ /** The generation of the rejected token. */
78
+ readonly generation: number;
79
+ /** The generation that wins, or `undefined` when the record is missing. */
80
+ readonly current: number | undefined;
81
+ }
82
+ //#endregion
83
+ //#region src/lease/domain/lease/value-objects/fencing-token.d.ts
84
+ /**
85
+ * Proof of holding a lease, passed with every fenced write. A resource that can compare atomically keeps the highest
86
+ * generation it has seen for the key and refuses a smaller one (`checkFence`).
87
+ */
88
+ interface FencingToken {
89
+ readonly key: string;
90
+ readonly generation: number;
91
+ }
92
+ //#endregion
93
+ //#region src/lease/domain/lease/policies/fence-check.d.ts
94
+ /** The token the next acquisition of `key` receives: one generation above the record, 1 for a new key. */
95
+ export declare function nextFencingToken(current: LeaseSnapshot | undefined, key: string): FencingToken;
96
+ /**
97
+ * The check a protected resource runs inside its own atomic write: `lastSeen` is the highest token it has accepted
98
+ * for the key. A smaller generation is refused; an equal one is the same holder writing again. On success, store the
99
+ * returned token as the new `lastSeen`.
100
+ */
101
+ export declare function checkFence(lastSeen: FencingToken | undefined, token: FencingToken): Result<FencingToken, FenceRejected>;
102
+ //#endregion
103
+ //#region src/lease/domain/lease/policies/freshness.d.ts
104
+ /**
105
+ * When an observer last saw a lease record change. Monotonic clocks of two processes cannot be compared, so each
106
+ * observer times a revision from the moment it first read it, as client-go's leader election does.
107
+ */
108
+ interface LeaseObservation {
109
+ readonly revision: number;
110
+ /** The observer's monotonic time when it first read `revision`. */
111
+ readonly observedAt: number;
112
+ }
113
+ /**
114
+ * Whether a held lease is still valid for this observer: its record changed less than `ttlMs` ago by the observer's
115
+ * monotonic clock. A record whose revision the observation has not seen has just changed, so it is fresh. A
116
+ * tombstone is never fresh.
117
+ */
118
+ export declare function isFresh(lease: LeaseSnapshot, observation: LeaseObservation, now: number, ttlMs: number): boolean;
119
+ //#endregion
120
+ //#region src/lease/domain/lease/policies/holder-liveness.d.ts
121
+ /**
122
+ * Judges a recorded holder from the observer's machine. `current` is what the observer's platform reports for the
123
+ * holder's pid now (`identify(holder.pid)`), `undefined` when no such process exists. A process of an earlier boot is
124
+ * dead; a pid now owned by a process with another start time is a reused pid, so the holder is dead too.
125
+ *
126
+ * Holder and observer must see the same processes: same host name and boot id are taken to mean one process table.
127
+ * Containers that share the host's name and kernel but have their own PID namespaces break that, and a live holder
128
+ * in another namespace looks dead. A host whose name changed makes its earlier holders look remote (`unknown`), so
129
+ * they are judged by the TTL alone.
130
+ */
131
+ export declare function holderLiveness(holder: Holder, observer: Holder, current: Holder | undefined): HolderLiveness;
132
+ //#endregion
133
+ //#region src/lease/application/ports.d.ts
134
+ /** The store could not read or write a record; the lease's state is unknown, not changed. */
135
+ interface LeaseStoreFailure {
136
+ readonly _tag: "LeaseStoreFailure";
137
+ readonly key: string;
138
+ /**
139
+ * `busy`: other writers kept the store locked; `invalid-record`: a stored record is unreadable or breaks the
140
+ * invariants and is left as it is; `unsupported-schema`: the store was written by a newer version; `unavailable`:
141
+ * the platform lacks what the store needs; `io`: any other failure.
142
+ */
143
+ readonly reason: "busy" | "io" | "invalid-record" | "unsupported-schema" | "unavailable";
144
+ readonly message: string;
145
+ readonly cause?: unknown;
146
+ }
147
+ /**
148
+ * Where lease records live. Calling convention for every implementation:
149
+ *
150
+ * - `compareAndSet` is atomic across every process that uses the same store location: it writes `next` only when the
151
+ * stored record's revision equals `expected` (`undefined`: no record), and reports whether it wrote.
152
+ * - Records are never deleted. A release writes a tombstone, so a key's generation never goes back.
153
+ * - `fence(key)` holds one guard per key across those processes until its Scope closes; waiting for it can be
154
+ * interrupted, and a guard whose holder process exited is freed without waiting for a TTL.
155
+ * - Only local directories are supported; neither SQLite's locks nor lock files are reliable on NFS.
156
+ *
157
+ * The lease's `runFenced` takes the guard and re-reads the holder before the work starts. That is equivalent to the
158
+ * protected resource checking the fencing token itself only when every writer of the resource goes through the same
159
+ * store on the same machine; a resource that can compare atomically should also run `checkFence`.
160
+ */
161
+ interface LeaseStoreShape {
162
+ read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseStoreFailure>;
163
+ compareAndSet(key: string, expected: number | undefined, next: LeaseSnapshot): Effect.Effect<boolean, LeaseStoreFailure>;
164
+ fence(key: string): Effect.Effect<void, LeaseStoreFailure, Scope.Scope>;
165
+ }
166
+ declare const KEY = "@rivus/agent-kit-collab/lease/LeaseStore/v1";
167
+ declare const LeaseStoreBase: Context.ServiceClass<LeaseStore, typeof KEY, LeaseStoreShape>;
168
+ /** The lease store port; `sqliteLeaseStore`, `fileLeaseStore` and `memoryLeaseStore` provide it. */
169
+ export declare class LeaseStore extends LeaseStoreBase {}
170
+ //#endregion
171
+ //#region src/lease/adapters/file-lease-store.d.ts
172
+ interface FileLeaseStoreOptions {
173
+ /** An existing local directory that holds one record file and its lock files per key. */
174
+ readonly dir: string;
175
+ }
176
+ /**
177
+ * The fallback store for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
178
+ * `writeAtomic`. Every `compareAndSet` holds the key's guard (`<key>.lease.guard`) while it reads, compares and
179
+ * writes, and reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`.
180
+ * Both are process locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a
181
+ * time, with the gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused
182
+ * and never overwritten.
183
+ */
184
+ export declare function fileLeaseStore(options: FileLeaseStoreOptions): Layer.Layer<LeaseStore, never, PlatformService>;
185
+ //#endregion
186
+ //#region src/lease/adapters/memory-lease-store.d.ts
187
+ /**
188
+ * A store for one process, such as tests or a host whose leases never cross a process boundary. Each Layer build
189
+ * starts empty.
190
+ */
191
+ export declare function memoryLeaseStore(): Layer.Layer<LeaseStore>;
192
+ //#endregion
193
+ //#region src/lease/adapters/sqlite-lease-store.d.ts
194
+ interface SqliteLeaseStoreOptions {
195
+ /**
196
+ * The database file, in an existing local directory. The per-key fence locks of `runFenced` live next to it as
197
+ * `<path>.<key>.fence` (plus a `.holder` file each), so give the store a directory of its own or a distinct name.
198
+ */
199
+ readonly path: string;
200
+ }
201
+ /**
202
+ * The lease store for one machine: one row per key in a SQLite database. `compareAndSet` runs in a
203
+ * `BEGIN IMMEDIATE` transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so
204
+ * processes sharing the file compare and write atomically. The lock is released when its process exits, so neither a
205
+ * crashed writer nor a reused pid can leave the store stuck. Fails to build with `unavailable` when the platform has
206
+ * no SQLite, and with `unsupported-schema` for a database written by a newer version.
207
+ */
208
+ export declare function sqliteLeaseStore(options: SqliteLeaseStoreOptions): Layer.Layer<LeaseStore, LeaseStoreFailure, PlatformService>;
209
+ //#endregion
210
+ //#region src/lease/application/lease-manager.d.ts
211
+ interface LeaseConfig {
212
+ /** How long a lease stays valid without a renewal, as each observer's monotonic clock measures it. */
213
+ readonly ttlMs: number;
214
+ /** How often the holder renews. Two heartbeats must fit in the TTL, so one late heartbeat does not lose it. */
215
+ readonly heartbeatMs: number;
216
+ /** How often `acquire({ wait: true })` tries again; `heartbeatMs` by default. */
217
+ readonly retryMs?: number;
218
+ }
219
+ interface LeaseConfigInvalid {
220
+ readonly _tag: "LeaseConfigInvalid";
221
+ readonly message: string;
222
+ }
223
+ interface AcquireOptions {
224
+ /**
225
+ * Keep trying every `retryMs` while the lease is held or the store is busy (a guard that stays locked), instead of
226
+ * failing with `LeaseHeld` or a `busy` `LeaseStoreFailure`. The wait ends when the lease is taken, on another store
227
+ * failure, or when the fiber is interrupted.
228
+ */
229
+ readonly wait?: boolean;
230
+ }
231
+ /** A held lease. It belongs to the Scope `acquire` ran in: closing the Scope stops the heartbeat and releases it. */
232
+ interface LeaseHandle {
233
+ readonly key: string;
234
+ readonly token: FencingToken;
235
+ /**
236
+ * Fails with `LeaseLost` once the heartbeat finds the lease taken over, released or missing, or cannot confirm a
237
+ * renewal within the TTL; it never succeeds. Race other work against it to stop that work on loss.
238
+ */
239
+ readonly lost: Effect.Effect<never, LeaseLost>;
240
+ /**
241
+ * Runs `work` under the store's per-key fence, after re-reading that this acquisition still holds the lease, and
242
+ * passes it the fencing token for the writes it makes. Waiting for the fence stops when the lease is lost. When the
243
+ * lease is lost while `work` runs, `work` is interrupted and the result fails with `LeaseLost`.
244
+ *
245
+ * The fence is released when `work`'s fiber has ended, so a successor's fenced work starts only after that. An
246
+ * interrupted fiber ends at once, though, while a Promise it started (`Effect.tryPromise`) keeps running: put a
247
+ * write that cannot be cancelled in `Effect.uninterruptible`, which keeps the fence until the write settles, or pass
248
+ * it the AbortSignal `tryPromise` provides and settle only after the write has stopped. A resource that can compare
249
+ * atomically should also keep the highest token it has seen and refuse older ones with `checkFence`.
250
+ */
251
+ runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected | LeaseStoreFailure, Exclude<R, Scope.Scope>>;
252
+ }
253
+ interface LeaseManager {
254
+ /**
255
+ * Takes the lease for `key` in the caller's Scope and keeps renewing it there. Fails with `LeaseHeld` while a live
256
+ * holder keeps it fresh; a dead holder (same host) is taken over at once, a silent one after the TTL. Throws an
257
+ * `AgentKitError` (as a defect) for an empty key.
258
+ */
259
+ acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld | LeaseStoreFailure, Scope.Scope>;
260
+ read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseStoreFailure>;
261
+ }
262
+ /** Validates `config` and builds a manager over the `LeaseStore` and the platform in context. */
263
+ export declare function createLeaseManager(config: LeaseConfig): Effect.Effect<LeaseManager, LeaseConfigInvalid, LeaseStore | PlatformService>;
264
+ //#endregion
265
+ export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken, FileLeaseStoreOptions, Holder, HolderLiveness, LeaseConfig, LeaseConfigInvalid, LeaseHandle, LeaseHeld, LeaseLost, LeaseManager, LeaseObservation, LeaseSnapshot, LeaseStoreFailure, LeaseStoreShape, SqliteLeaseStoreOptions };