@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-sessions
2
2
 
3
- Efferent composable agent framework, 0.13.0. Node 24 or Bun.
3
+ Efferent composable agent framework, 0.14.0. Node 24 or Bun.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
@@ -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, never, SessionLog | TurnAdmission>;
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;
@@ -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.13.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
- /** `none`: the lease is fixed at begin; `on-commit`: each write extends it; `everyMs`: a keep-alive also extends it. */
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
- /** `none`: the lease is fixed at begin; `on-commit`: each write extends it; `everyMs`: a keep-alive also extends it. */
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: "on-commit" },
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, never, SessionLog | TurnAdmission>;
20
+ }) => Layer.Layer<Sessions | TurnActivity, HarnessError, SessionLog | TurnAdmission>;
@@ -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.effect(Sessions, Effect.gen(function* () {
28
- 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), 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) => {
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: { id, reason: "attempts" } }));
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
- }))), 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) => ({
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 reason = Exit.hasInterrupts(exit) ? "interrupted" : "failed", after = closedState(state, reason, config.inbox.attempts, !1), closing = yield* turnEndedDraft(turn, { reason, failure: Option.some({ code: "turn.unended", message: "the turn's scope closed before it ended" }) }).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data: { id, reason: "attempts" } }));
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), begin = (address, input) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
67
- const scope = yield* Effect.scope, at = yield* Clock.currentTimeMillis, toAdmit = { session: address, origin: input._tag === "User" ? "user" : "inbox", runId: input.runId, key: input._tag === "User" ? input.key : input.runId }, unowned = yield* Ref.make(Option.none());
68
- yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(unowned).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (turn) => unhanded(address, turn, exit) }))));
69
- const begun = yield* restore(admission.admit(toAdmit, guarded(address, (head, stored) => Effect.gen(function* () {
70
- const reap = yield* reaped(head, stored), state = reap.state;
71
- if (input._tag === "User") {
72
- const previous = yield* keyed(address.id, input.key);
73
- if (Option.isSome(previous)) {
74
- const started = yield* turnStartedOf(previous.value.event).pipe(Effect.mapError(storage("session.decode"))), same = started.userMessage.text === input.userMessage.text && canonicalJson(started.command) === canonicalJson(input.command);
75
- 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 }));
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
- if (Option.isSome(state.open))
79
- return yield* Effect.fail(new SessionBusy({ session: address.id, turn: state.open.value.turn }));
80
- const waiting = input._tag === "Inbox" ? pendingOf(state).map((slot) => slot.id) : [];
81
- if (input._tag === "Inbox" && waiting.length === 0)
82
- return yield* Effect.fail(new NothingPending({ session: address.id }));
83
- const claimed = yield* inboxItems(address.id, waiting), userMessage = input._tag === "User" ? input.userMessage : new UserMessage({ text: claimed.map((item) => item.content).join(`
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
- turn,
87
- runId: input.runId,
88
- key,
89
- origin: input._tag === "User" ? "user" : "inbox",
90
- holder: instance,
91
- expiresAt: ownership.mode === "lease" ? Option.some(head.now + ownership.ttlMs) : Option.none(),
92
- process: holder.process
93
- }, next = {
94
- ...state,
95
- turns: turn,
96
- open: Option.some(open),
97
- title: Option.orElse(state.title, () => input._tag === "User" ? Option.some(titleOf(input.userMessage.text, config.titleChars)) : Option.none()),
98
- inbox: state.inbox.map((slot) => waiting.includes(slot.id) ? { ...slot, claimedBy: Option.some(turn) } : slot)
99
- }, starting = yield* turnStartedDraft(turn, {
100
- runId: input.runId,
101
- key,
102
- origin: open.origin,
103
- userMessage,
104
- command: input._tag === "User" ? input.command : {},
105
- claimed: waiting,
106
- entry: EntryId.make(`${input.runId}:0`),
107
- at
108
- }).pipe(Effect.mapError(storage("session.encode"))), admitted = {
109
- session: address,
110
- turn,
111
- runId: input.runId,
112
- key,
113
- origin: open.origin,
114
- userMessage,
115
- command: input._tag === "User" ? input.command : {},
116
- claimed
117
- };
118
- return { drafts: [...reap.drafts, starting], next: Option.some(next), result: { admitted, next } };
119
- }), (opened) => Ref.set(unowned, Option.some(opened.admitted.turn))))), committed = yield* Option.match(begun.committed, {
120
- onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn was not committed" })),
121
- onSome: Effect.succeed
122
- }), started = yield* Option.match(Option.fromNullishOr(committed.events.at(-1)), {
123
- onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn's start was not stored" })),
124
- onSome: Effect.succeed
125
- });
126
- yield* Effect.addFinalizer(() => Ref.update(writers, (all) => new Map([...all].filter(([id, open]) => id !== address.id || open.turn !== begun.result.admitted.turn))));
127
- const open = yield* makeTurnWriter({
128
- log,
129
- config,
130
- instance,
131
- header: begun.head.header,
132
- admitted: begun.result.admitted,
133
- started,
134
- position: { revision: committed.revision, seq: committed.seq, state: begun.result.next, storageAt: committed.at },
135
- wake: wake(address.id)
136
- }, scope);
137
- yield* Ref.set(unowned, Option.none());
138
- yield* Ref.update(writers, (all) => new Map([...all, [address.id, { ...open, turn: begun.result.admitted.turn }]]));
139
- return open.writer;
140
- })), closeLocal = (id, reason, turn) => Ref.get(writers).pipe(Effect.flatMap((all) => Option.match(Option.fromNullishOr(all.get(id)), {
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 writer = yield* begin(address, { _tag: "Inbox", runId: yield* Effect.sync(() => crypto.randomUUID()) }), exit = yield* Effect.exit(run(writer));
146
- if (exit._tag === "Failure")
147
- yield* Effect.logWarning(`inbox turn ${writer.admitted.turn} of ${address.id} failed`);
148
- yield* writer.end({
149
- reason: exit._tag === "Success" ? "completed" : "failed",
150
- failure: exit._tag === "Success" ? Option.none() : Option.some({ code: "inbox.turn", message: "the inbox turn failed" })
151
- }).pipe(Effect.ignore);
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
- return Sessions.of({
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: { id: item.id, source: item.source, content: item.content } }],
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, Schedule, Scope, Semaphore } from "effect";
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: { id: item, reason: "attempts" } }));
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(renew.pipe(Effect.repeat(Schedule.spaced(`${ownership.renew.everyMs} millis`)), Effect.asVoid), scope);
103
+ yield* Effect.forkIn(Effect.sleep(every).pipe(Effect.andThen(renew), Effect.forever, Effect.asVoid), scope);
103
104
  }
104
- yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(ended).pipe(Effect.flatMap((done) => done ? Effect.void : end({
105
- reason: Exit.hasInterrupts(exit) ? "interrupted" : "failed",
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.13.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.13.0",
17
+ "@xandreed/core": "0.14.0",
18
18
  "effect": "4.0.7"
19
19
  },
20
20
  "private": false,