@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.
- package/dist/index.d.mts +88 -45
- package/dist/index.mjs +33 -25
- package/dist/index.mjs.map +1 -1
- package/dist/workflow.d.mts +27 -0
- package/dist/workflow.mjs +133 -0
- package/dist/workflow.mjs.map +1 -0
- package/package.json +1 -48
- package/src/host.ts +81 -86
- package/src/layers.ts +132 -42
- package/src/scheduling.ts +9 -0
- package/src/subscriptions.ts +7 -0
- package/src/wake-scheduler.ts +3 -0
- package/src/workflow.ts +209 -0
package/src/wake-scheduler.ts
CHANGED
|
@@ -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) =>
|
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|