@xandreed/plugin-sessions 0.13.0 → 0.14.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/README.md +1 -1
- package/dist/plugin.adapter.d.ts +8 -5
- package/dist/plugin.adapter.js +3 -3
- package/dist/sessions-state.entity.d.ts +21 -2
- package/dist/sessions-state.entity.functions.d.ts +5 -1
- package/dist/sessions-state.entity.functions.js +3 -0
- package/dist/sessions-state.entity.js +4 -2
- package/dist/sessions.adapter.d.ts +7 -2
- package/dist/sessions.adapter.js +146 -93
- package/dist/sessions.conformance.test.d.ts +1 -0
- package/dist/turn-writer.adapter.d.ts +2 -0
- package/dist/turn-writer.adapter.js +10 -11
- package/package.json +2 -2
package/README.md
CHANGED
package/dist/plugin.adapter.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
1
|
+
import { SessionLog, Sessions, TurnActivity, TurnAdmission } from "@xandreed/core";
|
|
2
2
|
/**
|
|
3
3
|
* Sessions over the host's SessionLog: one event log per session, one open
|
|
4
4
|
* turn at a time, leases by lease or by process, the inbox and forks. The
|
|
5
|
-
* host admits every turn (TurnAdmissionOpen admits all).
|
|
5
|
+
* host admits every turn (TurnAdmissionOpen admits all). It also provides
|
|
6
|
+
* `TurnActivity`, the turns this process holds open or is beginning
|
|
7
|
+
* (`processActivity`, `awaitIdle`). By default a turn's lease is 60 s, renewed by its writes and
|
|
8
|
+
* by a keep-alive every 20 s.
|
|
6
9
|
*/
|
|
7
10
|
export declare const sessionsPlugin: import("@xandreed/core").TypedPlugin<{
|
|
8
11
|
readonly ownership: {
|
|
@@ -44,8 +47,8 @@ export declare const sessionsPlugin: import("@xandreed/core").TypedPlugin<{
|
|
|
44
47
|
readonly batch: number;
|
|
45
48
|
};
|
|
46
49
|
readonly retries: number;
|
|
47
|
-
}, Sessions,
|
|
48
|
-
/** Sessions as a typed layer: provides Sessions; requires SessionLog and TurnAdmission. */
|
|
50
|
+
}, Sessions | TurnActivity, import("@xandreed/core").HarnessError, SessionLog | TurnAdmission>;
|
|
51
|
+
/** Sessions as a typed layer: provides Sessions and TurnActivity; requires SessionLog and TurnAdmission. */
|
|
49
52
|
export declare const SessionsPluginLive: (options?: Partial<{
|
|
50
53
|
readonly ownership: {
|
|
51
54
|
readonly mode: "lease";
|
|
@@ -66,5 +69,5 @@ export declare const SessionsPluginLive: (options?: Partial<{
|
|
|
66
69
|
readonly batch: number;
|
|
67
70
|
};
|
|
68
71
|
readonly retries: number;
|
|
69
|
-
}> | undefined) => import("effect/Layer").Layer<Sessions, import("@xandreed/core").HarnessError, SessionLog | TurnAdmission>;
|
|
72
|
+
}> | undefined) => import("effect/Layer").Layer<Sessions | TurnActivity, import("@xandreed/core").HarnessError, SessionLog | TurnAdmission>;
|
|
70
73
|
export default sessionsPlugin;
|
package/dist/plugin.adapter.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { definePlugin, SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
1
|
+
import { definePlugin, SessionLog, Sessions, TurnActivity, TurnAdmission } from "@xandreed/core";
|
|
2
2
|
import { SessionsConfig, sessionsDefaults } from "./sessions-state.entity.js";
|
|
3
3
|
import { SessionsLive } from "./sessions.adapter.js";
|
|
4
4
|
export const sessionsPlugin = definePlugin({
|
|
5
5
|
id: "@xandreed/plugin-sessions",
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.14.0",
|
|
7
7
|
scope: "runtime",
|
|
8
8
|
config: SessionsConfig,
|
|
9
9
|
defaults: sessionsDefaults,
|
|
10
10
|
requires: [SessionLog, TurnAdmission],
|
|
11
|
-
provides: [Sessions],
|
|
11
|
+
provides: [Sessions, TurnActivity],
|
|
12
12
|
layer: SessionsLive
|
|
13
13
|
}), SessionsPluginLive = sessionsPlugin.live;
|
|
14
14
|
export default sessionsPlugin;
|
|
@@ -61,7 +61,16 @@ export declare const Ownership: Schema.Union<readonly [Schema.Struct<{
|
|
|
61
61
|
readonly mode: Schema.Literal<"lease">;
|
|
62
62
|
/** How long an open turn is held without a write (storage time). */
|
|
63
63
|
readonly ttlMs: Schema.Int;
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* `none`: the lease is fixed at begin; `on-commit`: each write extends it;
|
|
66
|
+
* `everyMs`: each write extends it, and so does a keep-alive every
|
|
67
|
+
* `everyMs` while the turn is open (the first one interval after the
|
|
68
|
+
* begin), so a silent step (a slow model call, a long tool) keeps it.
|
|
69
|
+
* `everyMs` is at most half of `ttlMs`, so a renewal lands before the
|
|
70
|
+
* lease runs out even when one is late. A renewal is queued in order
|
|
71
|
+
* with the turn's writes: it cannot pass a `write` or `transact` still
|
|
72
|
+
* running, so keep those shorter than the lease.
|
|
73
|
+
*/
|
|
65
74
|
readonly renew: Schema.Union<readonly [Schema.Literals<readonly ["none", "on-commit"]>, Schema.Struct<{
|
|
66
75
|
readonly everyMs: Schema.Int;
|
|
67
76
|
}>]>;
|
|
@@ -84,7 +93,16 @@ export declare const SessionsConfig: Schema.Struct<{
|
|
|
84
93
|
readonly mode: Schema.Literal<"lease">;
|
|
85
94
|
/** How long an open turn is held without a write (storage time). */
|
|
86
95
|
readonly ttlMs: Schema.Int;
|
|
87
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* `none`: the lease is fixed at begin; `on-commit`: each write extends it;
|
|
98
|
+
* `everyMs`: each write extends it, and so does a keep-alive every
|
|
99
|
+
* `everyMs` while the turn is open (the first one interval after the
|
|
100
|
+
* begin), so a silent step (a slow model call, a long tool) keeps it.
|
|
101
|
+
* `everyMs` is at most half of `ttlMs`, so a renewal lands before the
|
|
102
|
+
* lease runs out even when one is late. A renewal is queued in order
|
|
103
|
+
* with the turn's writes: it cannot pass a `write` or `transact` still
|
|
104
|
+
* running, so keep those shorter than the lease.
|
|
105
|
+
*/
|
|
88
106
|
readonly renew: Schema.Union<readonly [Schema.Literals<readonly ["none", "on-commit"]>, Schema.Struct<{
|
|
89
107
|
readonly everyMs: Schema.Int;
|
|
90
108
|
}>]>;
|
|
@@ -108,4 +126,5 @@ export declare const SessionsConfig: Schema.Struct<{
|
|
|
108
126
|
readonly retries: Schema.Int;
|
|
109
127
|
}>;
|
|
110
128
|
export type SessionsConfig = typeof SessionsConfig.Type;
|
|
129
|
+
/** By default a turn is leased for 60 s, renewed by every write and by a keep-alive every 20 s. */
|
|
111
130
|
export declare const sessionsDefaults: SessionsConfig;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Option, Schema } from "effect";
|
|
2
|
-
import type { JsonObject, SessionHead, SessionView, TurnEndReason } from "@xandreed/core";
|
|
2
|
+
import type { InboxItem, JsonObject, SessionHead, SessionView, TurnEndReason } from "@xandreed/core";
|
|
3
3
|
import type { HeldTurn, HolderProcess, InboxSlot, Ownership, ProcessLiveness, SessionsState as State } from "./sessions-state.entity.js";
|
|
4
4
|
/** A session's state before its first turn: a fork counts from its parent's turns. */
|
|
5
5
|
export declare const initialState: (head: SessionHead) => State;
|
|
@@ -7,6 +7,10 @@ export declare const initialState: (head: SessionHead) => State;
|
|
|
7
7
|
export declare const stateOf: (head: SessionHead) => Effect.Effect<State, Schema.SchemaError>;
|
|
8
8
|
/** The state as the head stores it. */
|
|
9
9
|
export declare const stateJson: (state: State) => JsonObject;
|
|
10
|
+
/** What `inbox.queued` stores of a delivered item. */
|
|
11
|
+
export declare const queuedData: (item: InboxItem) => JsonObject;
|
|
12
|
+
/** What `inbox.dropped` stores of an item as many turns took and failed as the inbox allows. */
|
|
13
|
+
export declare const droppedData: (id: string) => JsonObject;
|
|
10
14
|
/** Who asks whether a turn is held: this Sessions instance, and how it tells whether a process runs. */
|
|
11
15
|
export interface Holder {
|
|
12
16
|
readonly instance: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { InboxDroppedData, InboxQueuedData } from "@xandreed/core";
|
|
2
3
|
import { SessionsState } from "./sessions-state.entity.js";
|
|
3
4
|
const decodeState = Schema.decodeUnknownEffect(SessionsState), encodeState = Schema.encodeSync(SessionsState);
|
|
4
5
|
export const initialState = (head) => ({
|
|
@@ -8,6 +9,8 @@ export const initialState = (head) => ({
|
|
|
8
9
|
title: Option.none(),
|
|
9
10
|
inbox: []
|
|
10
11
|
}), stateOf = (head) => head.state.sessions === void 0 ? Effect.succeed(initialState(head)) : decodeState(head.state.sessions), stateJson = (state) => ({ sessions: encodeState(state) });
|
|
12
|
+
const encodeQueued = Schema.encodeSync(InboxQueuedData), encodeDropped = Schema.encodeSync(InboxDroppedData);
|
|
13
|
+
export const queuedData = (item) => ({ ...encodeQueued({ id: item.id, source: item.source, content: item.content }) }), droppedData = (id) => ({ ...encodeDropped({ id, reason: "attempts" }) });
|
|
11
14
|
const processHolds = (open, holder) => open.holder === holder.instance ? Effect.succeed(!0) : Option.match(open.process, {
|
|
12
15
|
onNone: () => Effect.succeed(!1),
|
|
13
16
|
onSome: (running) => Option.match(holder.process, {
|
|
@@ -15,7 +15,9 @@ export const InboxSlot = Schema.Struct({
|
|
|
15
15
|
mode: Schema.Literal("lease"),
|
|
16
16
|
ttlMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)),
|
|
17
17
|
renew: Schema.Union([Schema.Literals(["none", "on-commit"]), Schema.Struct({ everyMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(100)) })])
|
|
18
|
-
})
|
|
18
|
+
}).annotate({
|
|
19
|
+
description: 'A turn is held for ttlMs (storage time) after its begin and after each renewal. renew: "none" fixes the lease at begin, "on-commit" extends it with each write, { everyMs } also extends it with a keep-alive every everyMs while the turn is open (the first one interval after the begin). everyMs must be at most half of ttlMs (everyMs * 2 <= ttlMs), or the options fail with config.options.'
|
|
20
|
+
}).check(Schema.makeFilter((lease) => typeof lease.renew !== "object" || lease.renew.everyMs * 2 <= lease.ttlMs ? void 0 : `renew.everyMs (${lease.renew.everyMs}) must be at most half of ttlMs (${lease.ttlMs}), so a keep-alive lands before the lease runs out`)),
|
|
19
21
|
Schema.Struct({ mode: Schema.Literal("process") })
|
|
20
22
|
]), SessionsConfig = Schema.Struct({
|
|
21
23
|
ownership: Ownership,
|
|
@@ -30,7 +32,7 @@ export const InboxSlot = Schema.Struct({
|
|
|
30
32
|
}),
|
|
31
33
|
retries: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 }))
|
|
32
34
|
}), sessionsDefaults = {
|
|
33
|
-
ownership: { mode: "lease", ttlMs: 60000, renew:
|
|
35
|
+
ownership: { mode: "lease", ttlMs: 60000, renew: { everyMs: 20000 } },
|
|
34
36
|
titleChars: 80,
|
|
35
37
|
inbox: { maxPending: 20, attempts: 2 },
|
|
36
38
|
writer: { capacity: 1024, batch: 64 },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Layer } from "effect";
|
|
2
|
-
import { SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
2
|
+
import { HarnessError, SessionLog, Sessions, TurnActivity, TurnAdmission } from "@xandreed/core";
|
|
3
3
|
import type { ProcessLiveness, SessionsConfig } from "./sessions-state.entity.js";
|
|
4
4
|
/**
|
|
5
5
|
* Sessions over a SessionLog. The state (turns, the open turn, the title,
|
|
@@ -9,7 +9,12 @@ import type { ProcessLiveness, SessionsConfig } from "./sessions-state.entity.js
|
|
|
9
9
|
* compare-and-swap loop that plans again after a conflict. Under process
|
|
10
10
|
* ownership, `liveness` names this process in the turns it holds and tells
|
|
11
11
|
* whether another's still runs (by default, by host, pid and `kill(pid, 0)`).
|
|
12
|
+
* It also provides `TurnActivity`: each begin from the moment it is asked
|
|
13
|
+
* for until its writer is handed over (or it fails), then each turn until
|
|
14
|
+
* its writer's scope closes. An ownership the schema
|
|
15
|
+
* refuses (a keep-alive slower than half the lease) fails the layer with
|
|
16
|
+
* `config.options`.
|
|
12
17
|
*/
|
|
13
18
|
export declare const SessionsLive: (config: SessionsConfig, options?: {
|
|
14
19
|
readonly liveness?: ProcessLiveness;
|
|
15
|
-
}) => Layer.Layer<Sessions,
|
|
20
|
+
}) => Layer.Layer<Sessions | TurnActivity, HarnessError, SessionLog | TurnAdmission>;
|
package/dist/sessions.adapter.js
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
|
-
import { Clock, Effect, Exit, Layer, Option, PubSub, Ref, Scope, Stream } from "effect";
|
|
1
|
+
import { Clock, Context, Effect, Exit, Layer, Option, PubSub, Ref, Schema, Scope, Stream, SubscriptionRef } from "effect";
|
|
2
2
|
import {
|
|
3
3
|
canonicalJson,
|
|
4
4
|
ConversationId,
|
|
5
5
|
EntryId,
|
|
6
|
+
HarnessError,
|
|
7
|
+
harnessDefectsAsFailures,
|
|
6
8
|
InboxFull,
|
|
7
9
|
KeyConflict,
|
|
10
|
+
normalizeUserMessage,
|
|
8
11
|
NothingPending,
|
|
12
|
+
NotResumable,
|
|
9
13
|
RESERVED_KINDS,
|
|
14
|
+
resumeRefusal,
|
|
10
15
|
ReservedKind,
|
|
16
|
+
sameUserMessage,
|
|
11
17
|
SessionBusy,
|
|
12
18
|
SessionLog,
|
|
13
19
|
SessionLogError,
|
|
14
20
|
SessionMissing,
|
|
15
21
|
Sessions,
|
|
22
|
+
TurnActivity,
|
|
16
23
|
TurnAdmission,
|
|
17
24
|
TurnDuplicate,
|
|
18
25
|
turnEndedDraft,
|
|
26
|
+
turnEndedOf,
|
|
27
|
+
turnEndingOf,
|
|
19
28
|
turnStartedDraft,
|
|
20
29
|
turnStartedOf,
|
|
21
30
|
UserMessage
|
|
22
31
|
} from "@xandreed/core";
|
|
23
32
|
import { processLiveness } from "./process-liveness.adapter.js";
|
|
24
|
-
import { closedState, heldTurn, pendingOf, stateJson, stateOf, titleOf, viewOf } from "./sessions-state.entity.functions.js";
|
|
33
|
+
import { closedState, droppedData, heldTurn, pendingOf, queuedData, stateJson, stateOf, titleOf, viewOf } from "./sessions-state.entity.functions.js";
|
|
34
|
+
import { Ownership } from "./sessions-state.entity.js";
|
|
25
35
|
import { makeTurnWriter } from "./turn-writer.adapter.js";
|
|
26
36
|
const storage = (code) => (error) => new SessionLogError({ code, message: error.message }), standalone = (drafts) => drafts.map((draft) => ({ kind: draft.kind, turn: Option.none(), data: draft.data }));
|
|
27
|
-
export const SessionsLive = (config, options) => Layer.
|
|
28
|
-
|
|
37
|
+
export const SessionsLive = (config, options) => Layer.effectContext(Effect.gen(function* () {
|
|
38
|
+
yield* Schema.decodeUnknownEffect(Ownership)(config.ownership).pipe(Effect.mapError((error) => new HarnessError({ code: "config.options", plugin: "@xandreed/plugin-sessions", message: `ownership: ${error.message}` })));
|
|
39
|
+
const log = yield* SessionLog, admission = yield* TurnAdmission, ownership = config.ownership, instance = yield* Effect.sync(() => crypto.randomUUID()), liveness = options?.liveness ?? processLiveness, holder = { instance, liveness, process: ownership.mode === "process" ? yield* liveness.current : Option.none() }, signals = yield* PubSub.unbounded(), writers = yield* Ref.make(new Map), inFlight = yield* SubscriptionRef.make({ held: new Map, beginning: 0 }), heldKey = (id, turn) => `${id}:${turn}`, beginSettled = SubscriptionRef.update(inFlight, (now) => ({ ...now, beginning: now.beginning - 1 })), wake = (id) => PubSub.publish(signals, id).pipe(Effect.asVoid), freshId = Effect.sync(() => ConversationId.make(crypto.randomUUID())), stateFrom = (head) => stateOf(head).pipe(Effect.mapError(storage("session.state"))), owned = (address) => log.head(address.id).pipe(Effect.flatMap((head) => head.header.owner === address.owner ? Effect.succeed(head) : Effect.fail(new SessionMissing({ session: address.id })))), viewFrom = (head) => stateFrom(head).pipe(Effect.flatMap((state) => viewOf(head, state, ownership, holder))), guarded = (address, plan, stored = () => Effect.void) => {
|
|
29
40
|
const attempt = (tries) => Effect.gen(function* () {
|
|
30
41
|
const head = yield* owned(address), planned = yield* plan(head, yield* stateFrom(head));
|
|
31
42
|
if (planned.drafts.length === 0 && Option.isNone(planned.next))
|
|
@@ -48,118 +59,160 @@ export const SessionsLive = (config, options) => Layer.effect(Sessions, Effect.g
|
|
|
48
59
|
return attempt(0);
|
|
49
60
|
}, reaped = (head, state) => Effect.gen(function* () {
|
|
50
61
|
if (Option.isNone(state.open) || Option.isSome(yield* heldTurn(head, state, ownership, holder)))
|
|
51
|
-
return { drafts: [], state };
|
|
52
|
-
const turn = state.open.value.turn, after = closedState(state, "interrupted", config.inbox.attempts, !0), closing = yield* turnEndedDraft(turn, { reason: "interrupted", failure: Option.some({ code: "turn.lease", message: "the turn's holder stopped writing" }) }).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data:
|
|
53
|
-
return { drafts: [closing, ...dropped], state: after.state };
|
|
62
|
+
return { drafts: [], state, closed: Option.none() };
|
|
63
|
+
const turn = state.open.value.turn, after = closedState(state, "interrupted", config.inbox.attempts, !0), closing = yield* turnEndedDraft(turn, { reason: "interrupted", failure: Option.some({ code: "turn.lease", message: "the turn's holder stopped writing" }) }).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data: droppedData(id) }));
|
|
64
|
+
return { drafts: [closing, ...dropped], state: after.state, closed: Option.some(turn) };
|
|
54
65
|
}), keyed = (id, key) => log.read(id, { after: 0, limit: Option.none(), kinds: ["turn.started"] }).pipe(Effect.flatMap((events) => Option.match(Option.fromNullishOr(events.find((event) => event.data.key === key)), {
|
|
55
66
|
onNone: () => Effect.succeed(Option.none()),
|
|
56
67
|
onSome: (event) => Effect.succeed(Option.some({ turn: Option.getOrElse(event.turn, () => 0), event }))
|
|
57
|
-
}))),
|
|
68
|
+
}))), turnsOf = (id, reapedNow) => log.read(id, { after: 0, limit: Option.none(), kinds: ["turn.started", "turn.ended"] }).pipe(Effect.flatMap((events) => Effect.gen(function* () {
|
|
69
|
+
const numbered = (event) => Option.getOrElse(event.turn, () => 0), endings = new Map(yield* Effect.forEach(events.filter((event) => event.kind === "turn.ended"), (event) => turnEndedOf(event).pipe(Effect.map((ended) => [numbered(event), ended.reason]))));
|
|
70
|
+
return yield* Effect.forEach(events.filter((event) => event.kind === "turn.started"), (event) => turnStartedOf(event).pipe(Effect.map((started) => ({
|
|
71
|
+
turn: numbered(event),
|
|
72
|
+
origin: started.origin,
|
|
73
|
+
resumes: Option.fromNullishOr(started.resumes),
|
|
74
|
+
ended: Option.contains(reapedNow, numbered(event)) ? Option.some("interrupted") : Option.fromNullishOr(endings.get(numbered(event)))
|
|
75
|
+
}))));
|
|
76
|
+
}).pipe(Effect.mapError(storage("session.decode"))))), inboxItems = (id, ids) => log.read(id, { after: 0, limit: Option.none(), kinds: ["inbox.queued"] }).pipe(Effect.map((events) => ids.flatMap((wanted) => Option.toArray(Option.fromNullishOr(events.find((event) => event.data.id === wanted))).map((event) => ({
|
|
58
77
|
id: wanted,
|
|
59
78
|
source: typeof event.data.source === "object" && event.data.source !== null ? event.data.source : {},
|
|
60
79
|
content: typeof event.data.content === "string" && event.data.content.length > 0 ? event.data.content : `(inbox item ${wanted})`
|
|
61
80
|
}))))), unhanded = (address, turn, exit) => guarded(address, (_head, state) => Effect.gen(function* () {
|
|
62
81
|
if (!Option.exists(state.open, (open) => open.turn === turn && open.holder === instance))
|
|
63
82
|
return { drafts: [], next: Option.none(), result: void 0 };
|
|
64
|
-
const
|
|
83
|
+
const interrupted = Exit.hasInterrupts(exit), reason = interrupted ? "interrupted" : "failed", after = closedState(state, reason, config.inbox.attempts, !1), closing = yield* turnEndedDraft(turn, {
|
|
84
|
+
reason,
|
|
85
|
+
failure: Option.some(interrupted ? { code: "turn.interrupted", message: "the turn's begin was interrupted before its writer was handed over" } : { code: "turn.unended", message: "the turn's scope closed before it ended" })
|
|
86
|
+
}).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data: droppedData(id) }));
|
|
65
87
|
return { drafts: [closing, ...dropped], next: Option.some(after.state), result: void 0 };
|
|
66
|
-
})).pipe(Effect.timeout("10 seconds"), Effect.ignore),
|
|
67
|
-
const scope = yield* Effect.scope, at = yield* Clock.currentTimeMillis,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
})).pipe(Effect.timeout("10 seconds"), Effect.ignore), openTurn = (address, input) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
89
|
+
const scope = yield* Effect.scope, at = yield* Clock.currentTimeMillis, meta = input.meta ?? {}, resumes = input._tag === "User" ? Option.fromNullishOr(input.resumes) : Option.none(), unnumbered = Option.filter(resumes, (turn) => !(Number.isSafeInteger(turn) && turn >= 1));
|
|
90
|
+
if (Option.isSome(unnumbered))
|
|
91
|
+
return yield* Effect.fail(new NotResumable({ session: address.id, turn: unnumbered.value, reason: "missing" }));
|
|
92
|
+
const resuming = Option.match(resumes, { onNone: () => ({}), onSome: (turn) => ({ resumes: turn }) }), toAdmit = { session: address, origin: input._tag === "User" ? "user" : "inbox", runId: input.runId, key: input._tag === "User" ? input.key : input.runId, meta, ...resuming }, unowned = yield* Ref.make(Option.none());
|
|
93
|
+
yield* SubscriptionRef.update(inFlight, (now) => ({ ...now, beginning: now.beginning + 1 }));
|
|
94
|
+
yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(unowned).pipe(Effect.flatMap(Option.match({
|
|
95
|
+
onNone: () => Effect.void,
|
|
96
|
+
onSome: (turn) => unhanded(address, turn, exit).pipe(Effect.ensuring(beginSettled))
|
|
97
|
+
}))));
|
|
98
|
+
return yield* Effect.gen(function* () {
|
|
99
|
+
const begun = yield* restore(admission.admit(toAdmit, guarded(address, (head, stored) => Effect.gen(function* () {
|
|
100
|
+
const reap = yield* reaped(head, stored), state = reap.state;
|
|
101
|
+
if (input._tag === "User") {
|
|
102
|
+
const previous = yield* keyed(address.id, input.key);
|
|
103
|
+
if (Option.isSome(previous)) {
|
|
104
|
+
const started = yield* turnStartedOf(previous.value.event).pipe(Effect.mapError(storage("session.decode"))), same = sameUserMessage(started.userMessage, input.userMessage) && canonicalJson(started.command) === canonicalJson(input.command) && started.resumes === input.resumes;
|
|
105
|
+
return yield* Effect.fail(same ? new TurnDuplicate({ session: address.id, turn: previous.value.turn, open: Option.exists(state.open, (open) => open.turn === previous.value.turn) }) : new KeyConflict({ session: address.id, key: input.key, turn: previous.value.turn }));
|
|
106
|
+
}
|
|
76
107
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
if (Option.isSome(state.open))
|
|
109
|
+
return yield* Effect.fail(new SessionBusy({ session: address.id, turn: state.open.value.turn }));
|
|
110
|
+
if (Option.isSome(resumes)) {
|
|
111
|
+
const refusal = resumeRefusal(resumes.value, yield* turnsOf(address.id, reap.closed));
|
|
112
|
+
if (Option.isSome(refusal))
|
|
113
|
+
return yield* Effect.fail(new NotResumable({ session: address.id, turn: resumes.value, reason: refusal.value }));
|
|
114
|
+
}
|
|
115
|
+
const waiting = input._tag === "Inbox" ? pendingOf(state).map((slot) => slot.id) : [];
|
|
116
|
+
if (input._tag === "Inbox" && waiting.length === 0)
|
|
117
|
+
return yield* Effect.fail(new NothingPending({ session: address.id }));
|
|
118
|
+
const claimed = yield* inboxItems(address.id, waiting), userMessage = input._tag === "User" ? input.userMessage : new UserMessage({ text: claimed.map((item) => item.content).join(`
|
|
84
119
|
|
|
85
120
|
`) }), turn = state.turns + 1, key = input._tag === "User" ? input.key : input.runId, open = {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
121
|
+
turn,
|
|
122
|
+
runId: input.runId,
|
|
123
|
+
key,
|
|
124
|
+
origin: input._tag === "User" ? "user" : "inbox",
|
|
125
|
+
holder: instance,
|
|
126
|
+
expiresAt: ownership.mode === "lease" ? Option.some(head.now + ownership.ttlMs) : Option.none(),
|
|
127
|
+
process: holder.process
|
|
128
|
+
}, next = {
|
|
129
|
+
...state,
|
|
130
|
+
turns: turn,
|
|
131
|
+
open: Option.some(open),
|
|
132
|
+
title: Option.orElse(state.title, () => input._tag === "User" && input.userMessage.text.length > 0 ? Option.some(titleOf(input.userMessage.text, config.titleChars)) : Option.none()),
|
|
133
|
+
inbox: state.inbox.map((slot) => waiting.includes(slot.id) ? { ...slot, claimedBy: Option.some(turn) } : slot)
|
|
134
|
+
}, starting = yield* turnStartedDraft(turn, {
|
|
135
|
+
runId: input.runId,
|
|
136
|
+
key,
|
|
137
|
+
origin: open.origin,
|
|
138
|
+
userMessage,
|
|
139
|
+
command: input._tag === "User" ? input.command : {},
|
|
140
|
+
claimed: waiting,
|
|
141
|
+
entry: EntryId.make(`${input.runId}:0`),
|
|
142
|
+
at,
|
|
143
|
+
...input.meta === void 0 ? {} : { meta: input.meta },
|
|
144
|
+
...resuming
|
|
145
|
+
}).pipe(Effect.mapError(storage("session.encode"))), admitted = {
|
|
146
|
+
session: address,
|
|
147
|
+
turn,
|
|
148
|
+
runId: input.runId,
|
|
149
|
+
key,
|
|
150
|
+
origin: open.origin,
|
|
151
|
+
userMessage,
|
|
152
|
+
command: input._tag === "User" ? input.command : {},
|
|
153
|
+
claimed,
|
|
154
|
+
meta,
|
|
155
|
+
...resuming
|
|
156
|
+
};
|
|
157
|
+
return { drafts: [...reap.drafts, starting], next: Option.some(next), result: { admitted, next } };
|
|
158
|
+
}), (opened) => Ref.set(unowned, Option.some(opened.admitted.turn))))), committed = yield* Option.match(begun.committed, {
|
|
159
|
+
onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn was not committed" })),
|
|
160
|
+
onSome: Effect.succeed
|
|
161
|
+
}), started = yield* Option.match(Option.fromNullishOr(committed.events.at(-1)), {
|
|
162
|
+
onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn's start was not stored" })),
|
|
163
|
+
onSome: Effect.succeed
|
|
164
|
+
}), turn = begun.result.admitted.turn;
|
|
165
|
+
yield* Effect.addFinalizer(() => Ref.update(writers, (all) => new Map([...all].filter(([id, open]) => id !== address.id || open.turn !== turn))).pipe(Effect.andThen(SubscriptionRef.update(inFlight, (now) => ({ ...now, held: new Map([...now.held].filter(([key]) => key !== heldKey(address.id, turn))) })))));
|
|
166
|
+
const open = yield* makeTurnWriter({
|
|
167
|
+
log,
|
|
168
|
+
config,
|
|
169
|
+
instance,
|
|
170
|
+
header: begun.head.header,
|
|
171
|
+
admitted: begun.result.admitted,
|
|
172
|
+
started,
|
|
173
|
+
position: { revision: committed.revision, seq: committed.seq, state: begun.result.next, storageAt: committed.at },
|
|
174
|
+
wake: wake(address.id)
|
|
175
|
+
}, scope);
|
|
176
|
+
yield* Ref.set(unowned, Option.none());
|
|
177
|
+
yield* Ref.update(writers, (all) => new Map([...all, [address.id, { ...open, turn }]]));
|
|
178
|
+
const since = yield* Clock.currentTimeMillis;
|
|
179
|
+
yield* SubscriptionRef.update(inFlight, (now) => ({
|
|
180
|
+
held: new Map([...now.held, [heldKey(address.id, turn), { session: address.id, turn, since }]]),
|
|
181
|
+
beginning: now.beginning - 1
|
|
182
|
+
}));
|
|
183
|
+
return open;
|
|
184
|
+
}).pipe(Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Ref.get(unowned).pipe(Effect.flatMap((committed) => Option.isSome(committed) ? Effect.void : beginSettled))));
|
|
185
|
+
})), normalized = (input) => input._tag === "User" ? normalizeUserMessage(input.userMessage).pipe(Effect.map((userMessage) => ({ ...input, userMessage }))) : Effect.succeed(input), begin = (address, input) => normalized(input).pipe(Effect.flatMap((ready) => openTurn(address, ready)), Effect.map((open) => open.writer)), closeLocal = (id, reason, turn) => Ref.get(writers).pipe(Effect.flatMap((all) => Option.match(Option.fromNullishOr(all.get(id)), {
|
|
141
186
|
onNone: () => Effect.void,
|
|
142
187
|
onSome: (open) => Option.match(turn, { onNone: () => !0, onSome: (wanted) => wanted === open.turn }) ? open.close(reason) : Effect.void
|
|
143
188
|
}))), drain = (address, run, options) => {
|
|
144
|
-
const maxTurns = options?.maxTurns ?? 3, once = Effect.scoped(Effect.gen(function* () {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
yield*
|
|
148
|
-
yield*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
})
|
|
189
|
+
const maxTurns = options?.maxTurns ?? 3, meta = options?.meta, once = Effect.scoped(Effect.gen(function* () {
|
|
190
|
+
const runId = yield* Effect.sync(() => crypto.randomUUID()), open = yield* openTurn(address, meta === void 0 ? { _tag: "Inbox", runId } : { _tag: "Inbox", runId, meta }), exit = yield* Effect.exit(harnessDefectsAsFailures(run(open.writer))), ending = turnEndingOf(Exit.map(exit, () => ({ outcome: "completed" })));
|
|
191
|
+
if (ending.reason === "interrupted")
|
|
192
|
+
yield* open.markTried;
|
|
193
|
+
yield* Option.match(ending.failure, {
|
|
194
|
+
onNone: () => Effect.void,
|
|
195
|
+
onSome: (failure) => Effect.logWarning(`inbox turn ${open.writer.admitted.turn} of ${address.id} ended ${ending.reason}: ${failure.code}`)
|
|
196
|
+
});
|
|
197
|
+
yield* open.writer.end(ending).pipe(Effect.ignore);
|
|
152
198
|
return !0;
|
|
153
199
|
})).pipe(Effect.catchTags({
|
|
154
200
|
NothingPending: () => Effect.succeed(!1),
|
|
155
201
|
SessionBusy: () => Effect.succeed(!1),
|
|
156
202
|
TurnDuplicate: () => Effect.succeed(!1),
|
|
157
203
|
KeyConflict: () => Effect.succeed(!1),
|
|
204
|
+
NotResumable: () => Effect.succeed(!1),
|
|
158
205
|
TurnRefused: (refused) => Effect.logWarning(`inbox turn of ${address.id} refused: ${refused.message}`).pipe(Effect.as(!1))
|
|
159
206
|
})), loop = (turns) => turns >= maxTurns ? Effect.succeed({ turns }) : once.pipe(Effect.flatMap((ran) => ran ? loop(turns + 1) : Effect.succeed({ turns })));
|
|
160
207
|
return loop(0);
|
|
161
|
-
}
|
|
162
|
-
|
|
208
|
+
}, activity = TurnActivity.of({
|
|
209
|
+
current: SubscriptionRef.get(inFlight).pipe(Effect.map((now) => ({
|
|
210
|
+
open: [...now.held.values()].sort((left, right) => left.since - right.since),
|
|
211
|
+
beginning: now.beginning
|
|
212
|
+
}))),
|
|
213
|
+
idle: SubscriptionRef.changes(inFlight).pipe(Stream.filter((now) => now.held.size === 0 && now.beginning === 0), Stream.take(1), Stream.runDrain)
|
|
214
|
+
});
|
|
215
|
+
return Context.make(TurnActivity, activity).pipe(Context.add(Sessions, Sessions.of({
|
|
163
216
|
create: (input) => Effect.gen(function* () {
|
|
164
217
|
const head = yield* log.create({
|
|
165
218
|
id: input.id ?? (yield* freshId),
|
|
@@ -238,11 +291,11 @@ export const SessionsLive = (config, options) => Layer.effect(Sessions, Effect.g
|
|
|
238
291
|
return yield* Effect.fail(new InboxFull({ session: address.id, pending: pendingOf(state).length }));
|
|
239
292
|
const next = { ...state, inbox: [...state.inbox, { id: item.id, attempts: 0, claimedBy: Option.none() }] };
|
|
240
293
|
return {
|
|
241
|
-
drafts: [...reap.drafts, { kind: "inbox.queued", turn: Option.none(), data:
|
|
294
|
+
drafts: [...reap.drafts, { kind: "inbox.queued", turn: Option.none(), data: queuedData(item) }],
|
|
242
295
|
next: Option.some(next),
|
|
243
296
|
result: { delivered: !0, pending: pendingOf(next).length }
|
|
244
297
|
};
|
|
245
298
|
})).pipe(Effect.map((done) => done.result)),
|
|
246
299
|
drain
|
|
247
|
-
});
|
|
300
|
+
})));
|
|
248
301
|
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -26,6 +26,8 @@ export interface OpenWriter {
|
|
|
26
26
|
readonly writer: TurnWriter;
|
|
27
27
|
readonly close: (reason: TurnClosed["reason"]) => Effect.Effect<void>;
|
|
28
28
|
readonly ended: Effect.Effect<boolean>;
|
|
29
|
+
/** Count the inbox items the turn took as tried, though it wrote nothing (a drain `run` that interrupted itself). */
|
|
30
|
+
readonly markTried: Effect.Effect<void>;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* The open turn's writer. Items are stored strictly in queue order by one
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { Clock, Deferred, Effect, Exit, HashSet, Option, Queue, Ref,
|
|
1
|
+
import { Clock, Deferred, Effect, Exit, HashSet, Option, Queue, Ref, Scope, Semaphore } from "effect";
|
|
2
2
|
import { HarnessError, turnEndedDraft, TurnClosed } from "@xandreed/core";
|
|
3
|
-
import { closedState, pendingOf, stateJson, stateOf } from "./sessions-state.entity.functions.js";
|
|
3
|
+
import { closedState, droppedData, pendingOf, stateJson, stateOf } from "./sessions-state.entity.functions.js";
|
|
4
4
|
const harness = (code, message) => new HarnessError({ code, message }), groupsOf = (items) => items.reduce((groups, item) => {
|
|
5
5
|
const last = groups.at(-1);
|
|
6
6
|
return last?._tag === "Append" && item._tag === "Append" ? [...groups.slice(0, -1), { _tag: "Append", drafts: [...last.drafts, ...item.drafts] }] : [...groups, item];
|
|
7
7
|
}, []);
|
|
8
8
|
export const makeTurnWriter = (input, scope) => Effect.gen(function* () {
|
|
9
|
-
const { log, config, admitted } = input, id = admitted.session.id, ownership = config.ownership, queue = yield* Queue.bounded(config.writer.capacity), failed = yield* Ref.make(Option.none()), ended = yield* Ref.make(!1), sealed = yield* Ref.make(!1), tried = yield* Ref.make(!1), admission = yield* Semaphore.make(1), ending = yield* Ref.make(Option.none()), waiting = yield* Ref.make(HashSet.empty()), closed = yield* Deferred.make(), foreign = yield* Ref.make([]), position = yield* Ref.make({ ...input.position, localAt: yield* Clock.currentTimeMillis }), refuseWaiting = (error) => Ref.get(waiting).pipe(Effect.flatMap((all) => Effect.forEach(all, (refuse) => refuse(error), { discard: !0 }))), close = (reason) => Effect.gen(function* () {
|
|
9
|
+
const { log, config, admitted } = input, id = admitted.session.id, ownership = config.ownership, queue = yield* Queue.bounded(config.writer.capacity), failed = yield* Ref.make(Option.none()), ended = yield* Ref.make(!1), sealed = yield* Ref.make(!1), tried = yield* Ref.make(!1), admission = yield* Semaphore.make(1), ending = yield* Ref.make(Option.none()), attempted = yield* Ref.make(Option.none()), waiting = yield* Ref.make(HashSet.empty()), closed = yield* Deferred.make(), foreign = yield* Ref.make([]), position = yield* Ref.make({ ...input.position, localAt: yield* Clock.currentTimeMillis }), refuseWaiting = (error) => Ref.get(waiting).pipe(Effect.flatMap((all) => Effect.forEach(all, (refuse) => refuse(error), { discard: !0 }))), close = (reason) => Effect.gen(function* () {
|
|
10
10
|
if (yield* Ref.get(ended))
|
|
11
11
|
return;
|
|
12
12
|
const error = harness("turn.closed", `turn ${admitted.turn} was ${reason}`);
|
|
@@ -75,8 +75,9 @@ export const makeTurnWriter = (input, scope) => Effect.gen(function* () {
|
|
|
75
75
|
yield* admit(result, { _tag: "Commit", commit: Effect.exit(commit).pipe(Effect.flatMap((exit) => Deferred.done(result, exit)), Effect.asVoid) }, tries);
|
|
76
76
|
return yield* Deferred.await(result);
|
|
77
77
|
}), flush = Deferred.make().pipe(Effect.flatMap((done) => admission.withPermits(1)(refuseIfFailed.pipe(Effect.andThen(enqueue(done, { _tag: "Marker", done })))).pipe(Effect.andThen(Deferred.await(done))))), endNow = (ending) => Effect.gen(function* () {
|
|
78
|
+
yield* Ref.set(attempted, Option.some(ending));
|
|
78
79
|
const done = yield* commitPlan((at) => Effect.gen(function* () {
|
|
79
|
-
const after = closedState(at.state, ending.reason, config.inbox.attempts, yield* Ref.get(tried)), closing = yield* turnEndedDraft(admitted.turn, ending).pipe(Effect.mapError((error) => harness("session.encode", error.message))), dropped = after.dropped.map((item) => ({ kind: "inbox.dropped", data:
|
|
80
|
+
const after = closedState(at.state, ending.reason, config.inbox.attempts, yield* Ref.get(tried)), closing = yield* turnEndedDraft(admitted.turn, ending).pipe(Effect.mapError((error) => harness("session.encode", error.message))), dropped = after.dropped.map((item) => ({ kind: "inbox.dropped", data: droppedData(item) }));
|
|
80
81
|
return { drafts: [{ kind: closing.kind, data: closing.data }, ...dropped], next: after.state, result: pendingOf(after.state).length };
|
|
81
82
|
}), { renew: !1 });
|
|
82
83
|
yield* Ref.set(ended, !0);
|
|
@@ -96,15 +97,13 @@ export const makeTurnWriter = (input, scope) => Effect.gen(function* () {
|
|
|
96
97
|
return yield* Deferred.await(result);
|
|
97
98
|
});
|
|
98
99
|
if (ownership.mode === "lease" && typeof ownership.renew === "object") {
|
|
99
|
-
const renew = inOrder(commitPlan((at) => Effect.gen(function* () {
|
|
100
|
+
const every = `${ownership.renew.everyMs} millis`, renew = inOrder(commitPlan((at) => Effect.gen(function* () {
|
|
100
101
|
return { drafts: [], next: renewed(at.state, yield* storageNow, !0), result: void 0 };
|
|
101
102
|
}), { renew: !1 }), !1).pipe(Effect.ignore);
|
|
102
|
-
yield* Effect.forkIn(
|
|
103
|
+
yield* Effect.forkIn(Effect.sleep(every).pipe(Effect.andThen(renew), Effect.forever, Effect.asVoid), scope);
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
failure: Option.some({ code: "turn.unended", message: "the turn's scope closed before it ended" })
|
|
107
|
-
}).pipe(Effect.timeout("10 seconds"), Effect.ignore)), Effect.ensuring(Effect.gen(function* () {
|
|
105
|
+
const unended = (exit) => Exit.hasInterrupts(exit) ? { reason: "interrupted", failure: Option.some({ code: "turn.interrupted", message: "the turn's scope was interrupted before it ended" }) } : { reason: "failed", failure: Option.some({ code: "turn.unended", message: "the turn's scope closed before it ended" }) };
|
|
106
|
+
yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(ended).pipe(Effect.flatMap((done) => done ? Effect.void : Ref.get(attempted).pipe(Effect.flatMap((last) => end(Option.getOrElse(last, () => unended(exit)))), Effect.timeout("10 seconds"), Effect.ignore)), Effect.ensuring(Effect.gen(function* () {
|
|
108
107
|
const error = harness("turn.closed", `turn ${admitted.turn}'s scope closed`);
|
|
109
108
|
yield* Ref.update(failed, Option.orElse(() => Option.some(error)));
|
|
110
109
|
yield* Queue.shutdown(queue);
|
|
@@ -125,7 +124,7 @@ export const makeTurnWriter = (input, scope) => Effect.gen(function* () {
|
|
|
125
124
|
flush,
|
|
126
125
|
end,
|
|
127
126
|
closed: Deferred.await(closed)
|
|
128
|
-
}, close, ended: Ref.get(ended) };
|
|
127
|
+
}, close, ended: Ref.get(ended), markTried: Ref.set(tried, !0) };
|
|
129
128
|
});
|
|
130
129
|
const historyOf = (log, header, started, kinds, includeCurrent = !1) => {
|
|
131
130
|
const ancestors = (lineage) => Option.match(lineage, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=24",
|
|
6
6
|
"bun": ">=1.3.0"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@xandreed/core": "0.
|
|
17
|
+
"@xandreed/core": "0.14.0",
|
|
18
18
|
"effect": "4.0.7"
|
|
19
19
|
},
|
|
20
20
|
"private": false,
|