@effect-agent/platform-node 0.1.0-beta.8 → 0.1.0-beta.80

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.
Files changed (37) hide show
  1. package/dist/NodeDurableAgentRuntime-CFElFzNF.d.mts +179 -0
  2. package/dist/NodeDurableAgentRuntime.d.mts +2 -0
  3. package/dist/NodeDurableAgentRuntime.mjs +221 -0
  4. package/dist/NodeDurableAgentRuntime.mjs.map +1 -0
  5. package/dist/NodeDurableHost-BMSajuQn.mjs +194 -0
  6. package/dist/NodeDurableHost-BMSajuQn.mjs.map +1 -0
  7. package/dist/NodeDurableHost.d.mts +168 -0
  8. package/dist/NodeDurableHost.mjs +2 -0
  9. package/dist/NodeScheduling.d.mts +22 -0
  10. package/dist/NodeScheduling.mjs +59 -0
  11. package/dist/NodeScheduling.mjs.map +1 -0
  12. package/dist/NodeSubscriptions.d.mts +27 -0
  13. package/dist/NodeSubscriptions.mjs +74 -0
  14. package/dist/NodeSubscriptions.mjs.map +1 -0
  15. package/dist/NodeWakeScheduler-4ZeXYwPu.d.mts +27 -0
  16. package/dist/NodeWakeScheduler.d.mts +2 -0
  17. package/dist/NodeWakeScheduler.mjs +65 -0
  18. package/dist/NodeWakeScheduler.mjs.map +1 -0
  19. package/dist/NodeWorkflow.d.mts +27 -0
  20. package/dist/NodeWorkflow.mjs +159 -0
  21. package/dist/NodeWorkflow.mjs.map +1 -0
  22. package/dist/index.d.mts +6 -214
  23. package/dist/index.mjs +6 -286
  24. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  25. package/package.json +1 -45
  26. package/src/NodeDurableAgentRuntime.ts +596 -0
  27. package/src/NodeDurableHost.ts +361 -0
  28. package/src/NodeScheduling.ts +121 -0
  29. package/src/NodeSubscriptions.ts +162 -0
  30. package/src/{wake-scheduler.ts → NodeWakeScheduler.ts} +32 -17
  31. package/src/NodeWorkflow.ts +258 -0
  32. package/src/index.ts +5 -3
  33. package/src/internal/message-delivery.ts +53 -0
  34. package/src/internal/prepared-admission.ts +81 -0
  35. package/dist/index.mjs.map +0 -1
  36. package/src/host.ts +0 -215
  37. package/src/layers.ts +0 -381
@@ -0,0 +1,361 @@
1
+ import { type ThreadId, type SubmissionId } from "@effect-agent/core/Identifiers";
2
+ import {
3
+ type IntegrityReport,
4
+ type ObligationReport,
5
+ type ObligationThresholds,
6
+ type RecoveryExplanation,
7
+ type RetryCommand,
8
+ } from "@effect-agent/thread/Admin";
9
+ import {
10
+ type AgentRegistration,
11
+ type ResolvedBinding,
12
+ type DurableBindingFailure,
13
+ } from "@effect-agent/thread/AgentRegistration";
14
+ import {
15
+ DurableAgentRuntime,
16
+ type DurableAbortFailure,
17
+ type DurableAwaitFailure,
18
+ type DurableExplainFailure,
19
+ type DurableObserveOptions,
20
+ type DurableObligationFailure,
21
+ type DurableRetryFailure,
22
+ type DurableSubmitAgent,
23
+ type DurableSubmitFailure,
24
+ type DurableSubmitOptions,
25
+ type DurableVerifyFailure,
26
+ type DurableWorkerFailure,
27
+ type Receipt,
28
+ type RecoveryReport,
29
+ } from "@effect-agent/thread/DurableAgentRuntime";
30
+ import {
31
+ type MessageDeliveryStore,
32
+ MessageDeliveryDriver,
33
+ type MessageDeliveryError,
34
+ } from "@effect-agent/thread/MessageDelivery";
35
+ import { type OperationDenied } from "@effect-agent/thread/OperationAuthorizer";
36
+ import { PreparedInputAdmission } from "@effect-agent/thread/PreparedInputAdmission";
37
+ import { type CanonicalRecordEnvelope } from "@effect-agent/thread/Records";
38
+ import {
39
+ type AbortCommand,
40
+ type AbortIntent,
41
+ type Settlement,
42
+ } from "@effect-agent/thread/SubmissionLedger";
43
+ import {
44
+ type ThreadNotMaterialized,
45
+ type ThreadStoreError,
46
+ } from "@effect-agent/thread/ThreadStore";
47
+ import { NodeCrypto } from "@effect/platform-node";
48
+ import { type Stream, Context, Effect, Fiber, Layer, Ref, Schema } from "effect";
49
+
50
+ import { runNodeMessageDeliveries } from "./internal/message-delivery.ts";
51
+ import { makeNodePreparedInputAdmission, NodeAdmission } from "./internal/prepared-admission.ts";
52
+ import {
53
+ NodeDurableAgentRuntime,
54
+ NodeDurableAgentRuntimeConfig,
55
+ type NodeDurableAgentRuntimeOptions,
56
+ } from "./NodeDurableAgentRuntime.ts";
57
+
58
+ /**
59
+ * Admission is not open on this host: it is shutting down (deployment §6 step 1, DEPLOY-005).
60
+ * Accepted work is unaffected — only NEW admissions are refused.
61
+ */
62
+ export class AdmissionClosed extends Schema.TaggedError<AdmissionClosed>()("AdmissionClosed", {
63
+ message: Schema.String,
64
+ }) {}
65
+
66
+ const makeHost = Effect.fn("NodeDurableHost.make")(function* (startWorkers: boolean) {
67
+ const runtime = yield* DurableAgentRuntime;
68
+ const config = yield* NodeDurableAgentRuntimeConfig;
69
+
70
+ // Startup gate (deployment §5, plan §host): configuration decoding and storage compatibility
71
+ // already gated this Layer's dependencies; the last gate before admission opens is recovering
72
+ // EVERY nonterminal Submission. Work needing a live Agent Binding is reported `deferred` and
73
+ // stays a visible obligation for `runWorkers`; lanes durably blocked on an Unknown Outcome are
74
+ // reported `unknown` and wait for the authorized `resolveUnknown` path (DUR-017) — they consume
75
+ // no worker permit while the settlement obligation stays owed.
76
+ const startupRecovery = yield* runtime.runRecovery;
77
+
78
+ const admission = yield* Ref.make(true);
79
+
80
+ const requireAdmission: Effect.Effect<void, AdmissionClosed> = Ref.get(admission).pipe(
81
+ Effect.flatMap((open) =>
82
+ open
83
+ ? Effect.void
84
+ : Effect.fail(
85
+ AdmissionClosed.make({ message: "The host is shutting down; admission is closed." }),
86
+ ),
87
+ ),
88
+ );
89
+
90
+ const submit = <InputSchema extends Schema.Top>(
91
+ agent: DurableSubmitAgent<InputSchema>,
92
+ input: InputSchema["Type"],
93
+ options: DurableSubmitOptions,
94
+ ): Effect.Effect<
95
+ Receipt,
96
+ AdmissionClosed | DurableSubmitFailure,
97
+ InputSchema["EncodingServices"]
98
+ > => requireAdmission.pipe(Effect.andThen(runtime.submit(agent, input, options)));
99
+
100
+ const deliveryServices = yield* Effect.context<MessageDeliveryStore>();
101
+
102
+ const deliveryContext = yield* Layer.build(
103
+ MessageDeliveryDriver.layer({
104
+ batchSize: 100,
105
+ concurrency: Math.min(config.workerConcurrency, 32),
106
+ }).pipe(
107
+ Layer.provide(NodeCrypto.layer),
108
+ Layer.provide(
109
+ Layer.effect(PreparedInputAdmission, makeNodePreparedInputAdmission).pipe(
110
+ Layer.provide(
111
+ Layer.succeed(NodeAdmission, { submit, submissionStatus: runtime.submissionStatus }),
112
+ ),
113
+ ),
114
+ ),
115
+ ),
116
+ );
117
+
118
+ const runDeliveries = runNodeMessageDeliveries(config.wakeScanInterval).pipe(
119
+ Effect.provide(Context.merge(deliveryServices, deliveryContext)),
120
+ );
121
+
122
+ const runWorkers = <A, E, R>(worker: Effect.Effect<A, E, R>): Effect.Effect<void, E, R> =>
123
+ Effect.scoped(
124
+ // Either side exiting stops and joins the other; delivery interruption cannot leave
125
+ // an apparently healthy worker pool running without message recovery.
126
+ Effect.raceFirst(
127
+ Effect.forEach(
128
+ Array.from({ length: config.workerConcurrency }, (_, index) => index),
129
+ () => worker,
130
+ { concurrency: "unbounded", discard: true },
131
+ ),
132
+ runDeliveries,
133
+ ),
134
+ );
135
+
136
+ // S2 multi-binding pool: every claimed head resolves its exact registered Binding through
137
+ // the host's exact registrations, so one bounded
138
+ // pool serves parent and child lanes — the spec §12 smallest-pool suspension/wakeup proof
139
+ // runs `workerConcurrency: 1` over exactly this loop.
140
+ const runResolvedWorkers = runWorkers(runtime.runResolvedWorker);
141
+
142
+ const run = startWorkers
143
+ ? Fiber.join(
144
+ yield* runResolvedWorkers.pipe(
145
+ Effect.onExit(() => Ref.set(admission, false)),
146
+ Effect.forkScoped,
147
+ ),
148
+ )
149
+ : runResolvedWorkers;
150
+
151
+ // Register after the worker fiber: close admission, interrupt/join workers, drain
152
+ // runtime ownership, then close storage and captured application services.
153
+ yield* Effect.addFinalizer(() => Ref.set(admission, false));
154
+
155
+ return NodeDurableHost.of({
156
+ startupRecovery,
157
+ admissionOpen: Ref.get(admission),
158
+ submit,
159
+ awaitSettlement: runtime.awaitSettlement,
160
+ submissionStatus: runtime.submissionStatus,
161
+ observe: runtime.observe,
162
+ abort: runtime.abort,
163
+ explain: runtime.explain,
164
+ explainThread: runtime.explainThread,
165
+ verify: runtime.verify,
166
+ retry: runtime.retry,
167
+ wake: runtime.wake,
168
+ scanObligations: runtime.scanObligations,
169
+ runWorkers,
170
+ run,
171
+ runResolvedWorkers: run,
172
+ });
173
+ });
174
+
175
+ /**
176
+ * Operational host service. Prefer the module's `layer` and `run` for managed workers.
177
+ * The static constructors on this class retain explicit, manual worker ownership.
178
+ *
179
+ * Startup gates run during Layer construction, so the service existing implies readiness:
180
+ * configuration was schema-decoded, the SQLite file passed the exact-version compatibility check,
181
+ * and every nonterminal Submission went through one full recovery pass BEFORE admission opened.
182
+ * `startupRecovery` is the auditable evidence of that reconciliation pass.
183
+ *
184
+ * Shutdown runs in reverse Layer order when the owning Scope closes: `submit` starts refusing
185
+ * with `AdmissionClosed` first, then the runtime Layer's ownership drain releases every claim
186
+ * still held so another host can take over the lanes immediately, then the SQLite resources
187
+ * close. Forced termination at any point stays safe — the durability protocol, not graceful
188
+ * shutdown, provides correctness (DEPLOY-006).
189
+ */
190
+ export class NodeDurableHost extends Context.Service<
191
+ NodeDurableHost,
192
+ {
193
+ /**
194
+ * The recovery decisions executed (or deferred) by this host's startup reconciliation.
195
+ * Reports with the `unknown` disposition identify lanes blocked on Unknown Outcomes that
196
+ * only the authorized DUR-017 resolution path can release.
197
+ */
198
+ readonly startupRecovery: ReadonlyArray<RecoveryReport>;
199
+ /** Admission-role readiness (deployment §7): true until shutdown begins. */
200
+ readonly admissionOpen: Effect.Effect<boolean>;
201
+ /** Observe the managed worker pool, preserving its failure. Manual hosts start their pool here. */
202
+ readonly run: Effect.Effect<void, DurableWorkerFailure | DurableBindingFailure>;
203
+ /** `DurableAgentRuntime.submit` behind the host admission gate. */
204
+ readonly submit: <InputSchema extends Schema.Top>(
205
+ agent: DurableSubmitAgent<InputSchema>,
206
+ input: InputSchema["Type"],
207
+ options: DurableSubmitOptions,
208
+ ) => Effect.Effect<
209
+ Receipt,
210
+ AdmissionClosed | DurableSubmitFailure,
211
+ InputSchema["EncodingServices"]
212
+ >;
213
+ readonly submissionStatus: DurableAgentRuntime["Service"]["submissionStatus"];
214
+ readonly awaitSettlement: (receipt: Receipt) => Effect.Effect<Settlement, DurableAwaitFailure>;
215
+ readonly observe: (
216
+ receipt: Receipt,
217
+ options?: DurableObserveOptions,
218
+ ) => Stream.Stream<
219
+ CanonicalRecordEnvelope,
220
+ ThreadStoreError | ThreadNotMaterialized | OperationDenied
221
+ >;
222
+ readonly abort: (command: AbortCommand) => Effect.Effect<AbortIntent, DurableAbortFailure>;
223
+ /** `DurableAgentRuntime.explain` — read-only recovery explanation of one Submission (P7). */
224
+ readonly explain: (
225
+ submissionId: SubmissionId,
226
+ ) => Effect.Effect<RecoveryExplanation, DurableExplainFailure>;
227
+ /** `DurableAgentRuntime.explainThread` — explain every nonterminal lane member. */
228
+ readonly explainThread: (
229
+ threadId: ThreadId,
230
+ ) => Effect.Effect<ReadonlyArray<RecoveryExplanation>, DurableExplainFailure>;
231
+ /** `DurableAgentRuntime.verify` — read-only integrity checks, never a repair (P7). */
232
+ readonly verify: (threadId: ThreadId) => Effect.Effect<IntegrityReport, DurableVerifyFailure>;
233
+ /** `DurableAgentRuntime.retry` — audited single-Submission re-drive with typed refusals. */
234
+ readonly retry: (command: RetryCommand) => Effect.Effect<RecoveryReport, DurableRetryFailure>;
235
+ /** `DurableAgentRuntime.wake` — the documented operator liveness nudge for one lane. */
236
+ readonly wake: (threadId: ThreadId) => Effect.Effect<void, OperationDenied>;
237
+ /** `DurableAgentRuntime.scanObligations` — the scan-based DUR-017/OPS-001 report. */
238
+ readonly scanObligations: (
239
+ thresholds: ObligationThresholds,
240
+ ) => Effect.Effect<ObligationReport, DurableObligationFailure>;
241
+ /**
242
+ * Run `workerConcurrency` copies of the given worker effect (typically
243
+ * `DurableAgentRuntime.runWorker(agent)`) until the caller's Scope interrupts them. The
244
+ * same Scope drives pending message admission and settlement observation independently
245
+ * of Submission liveness. The host never forks daemon fibers.
246
+ */
247
+ readonly runWorkers: <A, E, R>(worker: Effect.Effect<A, E, R>) => Effect.Effect<void, E, R>;
248
+ /**
249
+ * Run `workerConcurrency` copies of `DurableAgentRuntime.runResolvedWorker` over the host's
250
+ * registered Bindings (S2): every claimed head resolves its exact stored Binding before any
251
+ * code runs (SUB-023), so one bounded pool serves parent and attached-child lanes.
252
+ * Hosts built with the module-level `layer` instead join their existing pool.
253
+ */
254
+ readonly runResolvedWorkers: Effect.Effect<void, DurableWorkerFailure | DurableBindingFailure>;
255
+ }
256
+ >()("@effect-agent/platform-node/NodeDurableHost") {
257
+ /**
258
+ * Compile typed registrations and acquire the complete host in one Layer Scope.
259
+ * Node supplies Crypto; model, tool, instruction, and schema services remain required.
260
+ * Startup recovery and shutdown gates are unchanged. Workers start only when the caller
261
+ * runs runResolvedWorkers; this constructor never starts a background worker.
262
+ */
263
+ static layerRegistered<
264
+ const Entries extends ReadonlyArray<AgentRegistration>,
265
+ ContextError = never,
266
+ ContextRequirements = never,
267
+ AuthorizationError = never,
268
+ AuthorizationRequirements = never,
269
+ ReconcilerError = never,
270
+ ReconcilerRequirements = never,
271
+ >(
272
+ registrations: Entries,
273
+ options: NodeDurableAgentRuntimeOptions<
274
+ ContextError,
275
+ ContextRequirements,
276
+ AuthorizationError,
277
+ AuthorizationRequirements,
278
+ ReconcilerError,
279
+ ReconcilerRequirements
280
+ >,
281
+ ) {
282
+ return NodeDurableHost.layer.pipe(
283
+ Layer.provideMerge(NodeDurableAgentRuntime.layerRegistered(registrations, options)),
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Host gates over an assembled `NodeDurableAgentRuntime` stack. The runtime Layer owns
289
+ * executable registrations; omission registers no Agents, so resolved work fails closed.
290
+ */
291
+ static readonly layer: Layer.Layer<
292
+ NodeDurableHost,
293
+ DurableWorkerFailure | MessageDeliveryError,
294
+ DurableAgentRuntime | NodeDurableAgentRuntimeConfig | MessageDeliveryStore
295
+ > = Layer.effect(NodeDurableHost)(makeHost(false));
296
+
297
+ /** The complete DN host: `NodeDurableAgentRuntime.layer(options)` plus the host lifecycle gates. */
298
+ static layerStack<
299
+ ContextError = never,
300
+ ContextRequirements = never,
301
+ AuthorizationError = never,
302
+ AuthorizationRequirements = never,
303
+ ReconcilerError = never,
304
+ ReconcilerRequirements = never,
305
+ >(
306
+ options: NodeDurableAgentRuntimeOptions<
307
+ ContextError,
308
+ ContextRequirements,
309
+ AuthorizationError,
310
+ AuthorizationRequirements,
311
+ ReconcilerError,
312
+ ReconcilerRequirements
313
+ > & { readonly bindings?: ReadonlyArray<ResolvedBinding> },
314
+ ) {
315
+ const { bindings = [], ...runtimeOptions } = options;
316
+
317
+ return NodeDurableHost.layer.pipe(
318
+ Layer.provideMerge(NodeDurableAgentRuntime.layerWithBindings(bindings, runtimeOptions)),
319
+ );
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Acquire a complete Node host and start one bounded, scoped worker pool after recovery.
325
+ * Provide model, tool, instruction, and schema dependencies to this Layer. Reusing the Layer
326
+ * shares the same pool. A worker failure closes admission; observe it with `run` at the process
327
+ * boundary so the application exits and releases the host instead of remaining idle.
328
+ */
329
+ export const layer = <
330
+ const Entries extends ReadonlyArray<AgentRegistration>,
331
+ ContextError = never,
332
+ ContextRequirements = never,
333
+ AuthorizationError = never,
334
+ AuthorizationRequirements = never,
335
+ ReconcilerError = never,
336
+ ReconcilerRequirements = never,
337
+ >(
338
+ registrations: Entries,
339
+ options: NodeDurableAgentRuntimeOptions<
340
+ ContextError,
341
+ ContextRequirements,
342
+ AuthorizationError,
343
+ AuthorizationRequirements,
344
+ ReconcilerError,
345
+ ReconcilerRequirements
346
+ >,
347
+ ) =>
348
+ Layer.effect(NodeDurableHost)(makeHost(true)).pipe(
349
+ Layer.provideMerge(NodeDurableAgentRuntime.layerRegistered(registrations, options)),
350
+ );
351
+
352
+ /**
353
+ * Supervise the host's existing workers without starting another pool. Use with
354
+ * `Effect.provide(HostLive)` and `NodeRuntime.runMain`; race it with a server Effect when
355
+ * the same process also serves requests. Unlike `Layer.launch`, this observes worker failures.
356
+ */
357
+ export const run = Effect.gen(function* () {
358
+ const host = yield* NodeDurableHost;
359
+
360
+ return yield* host.run;
361
+ });
@@ -0,0 +1,121 @@
1
+ import {
2
+ type ScheduleAuthorizer,
3
+ type SchedulingLimits,
4
+ ScheduleStorageError,
5
+ ScheduleStore,
6
+ type ScheduleValidationError,
7
+ ScheduleWake,
8
+ defaultSchedulingLimits,
9
+ } from "@effect-agent/thread/Schedule";
10
+ import {
11
+ type ScheduleProcessFailure,
12
+ Scheduling,
13
+ ScheduleDriver,
14
+ } from "@effect-agent/thread/Scheduling";
15
+ import { NodeCrypto } from "@effect/platform-node";
16
+ import { Cause, Duration, Effect, Layer, Option, PubSub, Result } from "effect";
17
+
18
+ import type { NodeDurableHost } from "./NodeDurableHost.ts";
19
+ import { nodeScheduledInputAdmissionLayer } from "./NodeSubscriptions.ts";
20
+
21
+ /** One bounded hint slot for the single supported Node scheduler. Indexed polling repairs loss. */
22
+ export const nodeScheduleWakeLayer: Layer.Layer<ScheduleWake> = Layer.effect(
23
+ ScheduleWake,
24
+ Effect.gen(function* () {
25
+ const hints = yield* PubSub.sliding<void>(1);
26
+ const subscription = yield* PubSub.subscribe(hints);
27
+
28
+ yield* Effect.addFinalizer(() => PubSub.shutdown(hints));
29
+
30
+ return ScheduleWake.of({
31
+ notify: PubSub.publish(hints, undefined).pipe(Effect.asVoid),
32
+ await: PubSub.take(subscription),
33
+ });
34
+ }),
35
+ );
36
+
37
+ const reportPassFailure = (cause: Cause.Cause<ScheduleProcessFailure>): Effect.Effect<boolean> =>
38
+ Cause.hasInterruptsOnly(cause)
39
+ ? Effect.interrupt
40
+ : Effect.logWarning("Node scheduling pass failed").pipe(
41
+ Effect.annotateLogs({
42
+ failureTag: Option.match(Cause.findErrorOption(cause), {
43
+ onNone: () => "Defect",
44
+ onSome: (error) => error._tag,
45
+ }),
46
+ }),
47
+ Effect.as(false),
48
+ );
49
+
50
+ const nodeSchedulingDriverLayer = (
51
+ limits: SchedulingLimits,
52
+ ): Layer.Layer<never, never, ScheduleDriver | ScheduleStore | ScheduleWake> =>
53
+ Layer.effectDiscard(
54
+ Effect.gen(function* () {
55
+ const scheduling = yield* ScheduleDriver;
56
+ const store = yield* ScheduleStore;
57
+ const wake = yield* ScheduleWake;
58
+
59
+ const run = Effect.gen(function* () {
60
+ while (true) {
61
+ const passSucceeded = yield* scheduling.runDue().pipe(
62
+ Effect.map((pass) => pass.failed === 0),
63
+ Effect.catchCause(reportPassFailure),
64
+ );
65
+
66
+ const deadlineResult = passSucceeded
67
+ ? yield* store.nextDeadline().pipe(Effect.result)
68
+ : Result.fail(
69
+ ScheduleStorageError.make({ operation: "driver pass", reason: "unavailable" }),
70
+ );
71
+
72
+ if (Result.isFailure(deadlineResult) && passSucceeded) {
73
+ yield* Effect.logWarning("Node scheduling deadline query failed");
74
+ }
75
+ const nowMillis = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
76
+
77
+ const deadlineDelay =
78
+ Result.isSuccess(deadlineResult) && deadlineResult.success !== null
79
+ ? Math.max(0, deadlineResult.success - nowMillis)
80
+ : limits.recoveryPollMillis;
81
+
82
+ const delay = Math.min(deadlineDelay, limits.recoveryPollMillis);
83
+
84
+ yield* Effect.raceFirst(wake.await, Effect.sleep(Duration.millis(delay)));
85
+ }
86
+ });
87
+
88
+ yield* Effect.forkScoped(run);
89
+ }),
90
+ );
91
+
92
+ export interface NodeSchedulingOptions {
93
+ readonly limits?: SchedulingLimits | undefined;
94
+ }
95
+
96
+ /**
97
+ * Optional Scope-owned Node scheduling service and driver. The caller must provide the existing
98
+ * host, its SQLite ScheduleStore, and an explicit ScheduleAuthorizer.
99
+ */
100
+ export class NodeScheduling {
101
+ static layer(
102
+ options: NodeSchedulingOptions = {},
103
+ ): Layer.Layer<
104
+ Scheduling,
105
+ ScheduleValidationError,
106
+ NodeDurableHost | ScheduleStore | ScheduleAuthorizer
107
+ > {
108
+ const limits = options.limits ?? defaultSchedulingLimits;
109
+
110
+ const schedulingWithDriver = nodeSchedulingDriverLayer(limits).pipe(
111
+ Layer.provide(ScheduleDriver.layer(limits)),
112
+ Layer.merge(Scheduling.layer(limits)),
113
+ );
114
+
115
+ return schedulingWithDriver.pipe(
116
+ Layer.provide(
117
+ Layer.mergeAll(nodeScheduledInputAdmissionLayer, nodeScheduleWakeLayer, NodeCrypto.layer),
118
+ ),
119
+ );
120
+ }
121
+ }
@@ -0,0 +1,162 @@
1
+ import { type EventSources } from "@effect-agent/thread/EventSource";
2
+ import { PreparedInputAdmission } from "@effect-agent/thread/PreparedInputAdmission";
3
+ import { type ScheduledEnvelope, ScheduledInputAdmission } from "@effect-agent/thread/Schedule";
4
+ import {
5
+ type PreparedInput,
6
+ type SubscriptionAuthorizer,
7
+ type SubscriptionError,
8
+ type SubscriptionLimits,
9
+ type SubscriptionStoreFailure,
10
+ SubscriptionStore,
11
+ defaultSubscriptionLimits,
12
+ } from "@effect-agent/thread/Subscription";
13
+ import { type SubscriptionInputBindings } from "@effect-agent/thread/SubscriptionInput";
14
+ import {
15
+ SubscriptionDriver,
16
+ SubscriptionIntake,
17
+ Subscriptions,
18
+ } from "@effect-agent/thread/Subscriptions";
19
+ import { NodeCrypto } from "@effect/platform-node";
20
+ import { Cause, Duration, Effect, Exit, Layer, Option } from "effect";
21
+
22
+ import { makeNodePreparedInputAdmission, NodeAdmission } from "./internal/prepared-admission.ts";
23
+ import { NodeDurableHost } from "./NodeDurableHost.ts";
24
+
25
+ /** Ordinary prepared admission through the Scope-owned Node host gate. */
26
+ export const nodePreparedInputAdmissionLayer: Layer.Layer<
27
+ PreparedInputAdmission,
28
+ never,
29
+ NodeDurableHost
30
+ > = Layer.effect(PreparedInputAdmission, makeNodePreparedInputAdmission).pipe(
31
+ Layer.provide(Layer.effect(NodeAdmission, NodeDurableHost)),
32
+ );
33
+
34
+ const preparedFromSchedule = (envelope: ScheduledEnvelope): PreparedInput => ({
35
+ schemaVersion: 1,
36
+ threadId: envelope.threadId,
37
+ deliveryPrincipal: envelope.deliveryPrincipal,
38
+ ...(envelope.admissionGroup === undefined ? {} : { admissionGroup: envelope.admissionGroup }),
39
+ ...(envelope.admissionFence === undefined ? {} : { admissionFence: envelope.admissionFence }),
40
+ agentId: envelope.agentId,
41
+ definitions: envelope.definitions,
42
+ input: envelope.input,
43
+ inputDigest: envelope.inputDigest,
44
+ admissionKey: envelope.admissionKey,
45
+ authorization: envelope.authorization,
46
+ });
47
+
48
+ /** Compatibility adapter retaining the public scheduling admission port. */
49
+ const nodeScheduledInputAdmissionFromPreparedLayer: Layer.Layer<
50
+ ScheduledInputAdmission,
51
+ never,
52
+ PreparedInputAdmission
53
+ > = Layer.effect(
54
+ ScheduledInputAdmission,
55
+ Effect.map(PreparedInputAdmission, (admission) =>
56
+ ScheduledInputAdmission.of({
57
+ submit: (envelope) => admission.submit(preparedFromSchedule(envelope)),
58
+ }),
59
+ ),
60
+ );
61
+
62
+ export const nodeScheduledInputAdmissionLayer: Layer.Layer<
63
+ ScheduledInputAdmission,
64
+ never,
65
+ NodeDurableHost
66
+ > = nodeScheduledInputAdmissionFromPreparedLayer.pipe(
67
+ Layer.provide(nodePreparedInputAdmissionLayer),
68
+ );
69
+
70
+ const reportPassFailure = (cause: Cause.Cause<SubscriptionStoreFailure>): Effect.Effect<boolean> =>
71
+ Cause.hasInterruptsOnly(cause)
72
+ ? Effect.interrupt
73
+ : Effect.logWarning("Node subscription pass failed").pipe(
74
+ Effect.annotateLogs({
75
+ failureTag: Option.match(Cause.findErrorOption(cause), {
76
+ onNone: () => "Defect",
77
+ onSome: (error) => error._tag,
78
+ }),
79
+ }),
80
+ Effect.as(false),
81
+ );
82
+
83
+ const nodeSubscriptionDriverLayer = (
84
+ limits: SubscriptionLimits,
85
+ ): Layer.Layer<never, never, SubscriptionDriver | SubscriptionStore> =>
86
+ Layer.effectDiscard(
87
+ Effect.gen(function* () {
88
+ const driver = yield* SubscriptionDriver;
89
+ const store = yield* SubscriptionStore;
90
+
91
+ const run = Effect.gen(function* () {
92
+ while (true) {
93
+ const passSucceeded = yield* driver.runDue.pipe(
94
+ Effect.map((pass) => pass.failed === 0),
95
+ Effect.catchCause(reportPassFailure),
96
+ );
97
+
98
+ if (!passSucceeded) {
99
+ yield* Effect.sleep(Duration.millis(limits.retryMillis));
100
+ continue;
101
+ }
102
+
103
+ const deadline = yield* store.nextDeadline.pipe(Effect.exit);
104
+
105
+ if (Exit.isFailure(deadline)) {
106
+ yield* reportPassFailure(deadline.cause);
107
+ yield* Effect.sleep(Duration.millis(limits.retryMillis));
108
+ continue;
109
+ }
110
+
111
+ const nowMillis = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
112
+
113
+ const delay =
114
+ deadline.value === null
115
+ ? limits.retryMillis
116
+ : Math.max(1, Math.min(deadline.value - nowMillis, limits.retryMillis));
117
+
118
+ yield* Effect.sleep(Duration.millis(delay));
119
+ }
120
+ });
121
+
122
+ yield* Effect.forkScoped(run);
123
+ }),
124
+ );
125
+
126
+ export interface NodeSubscriptionsOptions {
127
+ readonly limits?: SubscriptionLimits | undefined;
128
+ }
129
+
130
+ /**
131
+ * One Scope-owned subscription partition in the sole process owning its SQLite database.
132
+ * Indexed polling repairs restart and lost wake state; closing the Scope interrupts the driver.
133
+ */
134
+ export class NodeSubscriptions {
135
+ static layer(
136
+ options: NodeSubscriptionsOptions = {},
137
+ ): Layer.Layer<
138
+ Subscriptions | SubscriptionIntake,
139
+ SubscriptionError,
140
+ | NodeDurableHost
141
+ | SubscriptionStore
142
+ | SubscriptionAuthorizer
143
+ | EventSources
144
+ | SubscriptionInputBindings
145
+ > {
146
+ const limits = options.limits ?? defaultSubscriptionLimits;
147
+
148
+ const publicServices = Layer.merge(
149
+ Subscriptions.layer(limits),
150
+ SubscriptionIntake.layer(limits),
151
+ );
152
+
153
+ const driver = nodeSubscriptionDriverLayer(limits).pipe(
154
+ Layer.provide(SubscriptionDriver.layer(limits)),
155
+ );
156
+
157
+ return Layer.merge(publicServices, driver).pipe(
158
+ Layer.provide(nodePreparedInputAdmissionLayer),
159
+ Layer.provide(NodeCrypto.layer),
160
+ );
161
+ }
162
+ }