@effect-agent/platform-node 0.1.0-beta.42 → 0.1.0-beta.45

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.
@@ -29,6 +29,7 @@ const makeWakeScheduler = Effect.gen(function* () {
29
29
  const config = yield* NodeWakeSchedulerConfig;
30
30
  const hints = yield* PubSub.sliding<ThreadId>(WAKE_BUFFER_CAPACITY);
31
31
  const progress = yield* makeWakeSubscriptionHub;
32
+
32
33
  yield* Effect.addFinalizer(() => PubSub.shutdown(hints));
33
34
 
34
35
  /**
@@ -41,9 +42,11 @@ const makeWakeScheduler = Effect.gen(function* () {
41
42
  ).pipe(
42
43
  Effect.map((snapshots) => {
43
44
  const lanes = new Set<ThreadId>();
45
+
44
46
  for (const snapshot of snapshots) {
45
47
  lanes.add(snapshot.threadId);
46
48
  }
49
+
47
50
  return [...lanes];
48
51
  }),
49
52
  Effect.catch((error) =>
@@ -0,0 +1,209 @@
1
+ import {
2
+ WorkflowDispatchError,
3
+ WorkflowDispatchIntent,
4
+ WorkflowDispatchScan,
5
+ WorkflowDispatchStore,
6
+ WorkflowRepairTrigger,
7
+ } from "@effect-agent/workflow";
8
+ import { Cause, Duration, Effect, Layer, Option, Schema } from "effect";
9
+ import { SqlClient } from "effect/unstable/sql";
10
+
11
+ const StoredIntent = Schema.Struct({
12
+ deployment_id: Schema.String,
13
+ workflow_name: Schema.String,
14
+ execution_id: Schema.String,
15
+ intent_json: Schema.String,
16
+ });
17
+
18
+ const IntentJson = Schema.fromJsonString(WorkflowDispatchIntent);
19
+ const decodeIntent = Schema.decodeUnknownEffect(IntentJson, { onExcessProperty: "error" });
20
+ const encodeIntent = Schema.encodeEffect(IntentJson);
21
+
22
+ const dispatchError = (operation: string) => (cause: unknown) =>
23
+ Schema.is(WorkflowDispatchError)(cause)
24
+ ? cause
25
+ : new WorkflowDispatchError({
26
+ operation,
27
+ message: "Workflow dispatch storage failed or contains incompatible data",
28
+ cause,
29
+ });
30
+
31
+ const decodeRow = Effect.fn("SqlWorkflowDispatchStore.decodeRow")(function* (value: unknown) {
32
+ const row = yield* Schema.decodeUnknownEffect(StoredIntent, { onExcessProperty: "error" })(value);
33
+ const intent = yield* decodeIntent(row.intent_json);
34
+
35
+ if (
36
+ row.deployment_id !== intent.deploymentId ||
37
+ row.workflow_name !== intent.workflowName ||
38
+ row.execution_id !== intent.executionId
39
+ ) {
40
+ return yield* new WorkflowDispatchError({
41
+ operation: "decode",
42
+ message: "Stored Workflow dispatch identity disagrees with its intent",
43
+ });
44
+ }
45
+
46
+ return intent;
47
+ });
48
+
49
+ /**
50
+ * Durable dispatch outbox over an application-supplied SqlClient. This adapter uses
51
+ * SQLite/PostgreSQL SQL syntax and is certified with SQLite. It does not own an engine
52
+ * or a database connection. Agent admission, dispatch persistence, and native Workflow
53
+ * storage are separate commits; the registered repair trigger closes those gaps.
54
+ * Stored version or shape mismatches fail typed and require an explicit data reset.
55
+ */
56
+ export class SqlWorkflowDispatchStore {
57
+ static readonly layer: Layer.Layer<
58
+ WorkflowDispatchStore,
59
+ WorkflowDispatchError,
60
+ SqlClient.SqlClient
61
+ > = Layer.effect(WorkflowDispatchStore)(
62
+ Effect.gen(function* () {
63
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
64
+
65
+ yield* sql`
66
+ CREATE TABLE IF NOT EXISTS effect_agent_workflow_dispatch (
67
+ workflow_name TEXT NOT NULL,
68
+ execution_id TEXT NOT NULL,
69
+ deployment_id TEXT NOT NULL,
70
+ intent_json TEXT NOT NULL,
71
+ PRIMARY KEY (workflow_name, execution_id)
72
+ )
73
+ `;
74
+ yield* sql`
75
+ CREATE INDEX IF NOT EXISTS effect_agent_workflow_dispatch_scan
76
+ ON effect_agent_workflow_dispatch (deployment_id, workflow_name, execution_id)
77
+ `;
78
+
79
+ const put = Effect.fn("SqlWorkflowDispatchStore.put")(
80
+ function* (input: WorkflowDispatchIntent) {
81
+ const intent = yield* Schema.decodeUnknownEffect(WorkflowDispatchIntent)(input);
82
+ const encoded = yield* encodeIntent(intent);
83
+
84
+ yield* sql`
85
+ INSERT INTO effect_agent_workflow_dispatch
86
+ (workflow_name, execution_id, deployment_id, intent_json)
87
+ VALUES (${intent.workflowName}, ${intent.executionId}, ${intent.deploymentId}, ${encoded})
88
+ ON CONFLICT (workflow_name, execution_id) DO NOTHING
89
+ `;
90
+
91
+ const rows = yield* sql`
92
+ SELECT deployment_id, workflow_name, execution_id, intent_json
93
+ FROM effect_agent_workflow_dispatch
94
+ WHERE workflow_name = ${intent.workflowName} AND execution_id = ${intent.executionId}
95
+ `;
96
+
97
+ const existing = yield* decodeRow(rows[0]);
98
+
99
+ if ((yield* encodeIntent(existing)) !== encoded) {
100
+ return yield* new WorkflowDispatchError({
101
+ operation: "put",
102
+ message: "Workflow dispatch identity already belongs to a different immutable intent",
103
+ });
104
+ }
105
+ },
106
+ sql.withTransaction,
107
+ Effect.mapError(dispatchError("put")),
108
+ );
109
+
110
+ const scan = Effect.fn("SqlWorkflowDispatchStore.scan")(
111
+ function* (input: WorkflowDispatchScan) {
112
+ const request = yield* Schema.decodeUnknownEffect(WorkflowDispatchScan)(input);
113
+
114
+ const rows = yield* sql`
115
+ SELECT deployment_id, workflow_name, execution_id, intent_json
116
+ FROM effect_agent_workflow_dispatch
117
+ WHERE deployment_id = ${request.deploymentId}
118
+ AND workflow_name = ${request.workflowName}
119
+ AND execution_id > ${request.after ?? ""}
120
+ ORDER BY execution_id ASC
121
+ LIMIT ${request.limit}
122
+ `;
123
+
124
+ return yield* Effect.forEach(rows, decodeRow);
125
+ },
126
+ Effect.mapError(dispatchError("scan")),
127
+ );
128
+
129
+ const remove = Effect.fn("SqlWorkflowDispatchStore.remove")(
130
+ function* (input: WorkflowDispatchIntent) {
131
+ const intent = yield* Schema.decodeUnknownEffect(WorkflowDispatchIntent)(input);
132
+
133
+ const rows = yield* sql`
134
+ SELECT deployment_id, workflow_name, execution_id, intent_json
135
+ FROM effect_agent_workflow_dispatch
136
+ WHERE workflow_name = ${intent.workflowName} AND execution_id = ${intent.executionId}
137
+ `;
138
+
139
+ if (rows.length === 0) return;
140
+ const existing = yield* decodeRow(rows[0]);
141
+
142
+ if ((yield* encodeIntent(existing)) !== (yield* encodeIntent(intent))) {
143
+ return yield* new WorkflowDispatchError({
144
+ operation: "remove",
145
+ message: "Cannot remove a different immutable Workflow dispatch intent",
146
+ });
147
+ }
148
+ yield* sql`
149
+ DELETE FROM effect_agent_workflow_dispatch
150
+ WHERE workflow_name = ${intent.workflowName} AND execution_id = ${intent.executionId}
151
+ `;
152
+ },
153
+ sql.withTransaction,
154
+ Effect.mapError(dispatchError("remove")),
155
+ );
156
+
157
+ return WorkflowDispatchStore.of({ put, scan, remove });
158
+ }).pipe(Effect.mapError(dispatchError("initialize"))),
159
+ );
160
+ }
161
+
162
+ export class NodeWorkflowRepairConfigError extends Schema.TaggedError<NodeWorkflowRepairConfigError>()(
163
+ "NodeWorkflowRepairConfigError",
164
+ { message: Schema.String },
165
+ ) {}
166
+
167
+ /** A host-scoped startup and polling trigger. No ordinary Node agent worker is started. */
168
+ export class NodeWorkflowRepairTrigger {
169
+ static layer(
170
+ options: { readonly interval?: Duration.Input } = {},
171
+ ): Layer.Layer<WorkflowRepairTrigger, NodeWorkflowRepairConfigError> {
172
+ return Layer.effect(WorkflowRepairTrigger)(
173
+ Effect.gen(function* () {
174
+ const interval = Duration.fromInput(options.interval ?? "1 second");
175
+
176
+ if (
177
+ Option.isNone(interval) ||
178
+ !Duration.isFinite(interval.value) ||
179
+ !Duration.isPositive(interval.value)
180
+ ) {
181
+ return yield* new NodeWorkflowRepairConfigError({
182
+ message: "Workflow repair interval must be finite and greater than zero",
183
+ });
184
+ }
185
+ const delay = interval.value;
186
+
187
+ return WorkflowRepairTrigger.of({
188
+ register: Effect.fn("NodeWorkflowRepairTrigger.register")(function* (repair) {
189
+ const attempt = repair.pipe(
190
+ Effect.catchCause((cause) =>
191
+ Cause.hasInterruptsOnly(cause)
192
+ ? Effect.failCause(cause)
193
+ : Effect.logError("Workflow repair trigger failed; next poll will retry", cause),
194
+ ),
195
+ );
196
+
197
+ yield* attempt;
198
+ yield* Effect.gen(function* () {
199
+ while (true) {
200
+ yield* Effect.sleep(delay);
201
+ yield* attempt;
202
+ }
203
+ }).pipe(Effect.forkScoped);
204
+ }),
205
+ });
206
+ }),
207
+ );
208
+ }
209
+ }