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