@smithers-orchestrator/engine 0.16.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/LICENSE +21 -0
- package/package.json +50 -0
- package/src/AlertHumanRequestOptions.ts +8 -0
- package/src/AlertRuntimeServices.ts +10 -0
- package/src/ChildWorkflowDefinition.ts +5 -0
- package/src/ChildWorkflowExecuteOptions.ts +14 -0
- package/src/ContinuationRequest.ts +3 -0
- package/src/HijackState.ts +19 -0
- package/src/HumanRequestKind.ts +1 -0
- package/src/HumanRequestStatus.ts +1 -0
- package/src/PlanNode.ts +29 -0
- package/src/RalphMeta.ts +7 -0
- package/src/RalphState.ts +4 -0
- package/src/RalphStateMap.ts +3 -0
- package/src/ScheduleResult.ts +15 -0
- package/src/SignalRunOptions.ts +5 -0
- package/src/alert-runtime.js +22 -0
- package/src/approvals.js +220 -0
- package/src/child-workflow.js +163 -0
- package/src/effect/ApprovalDeferredResolution.ts +13 -0
- package/src/effect/ApprovalDurableDeferredResolution.ts +11 -0
- package/src/effect/ApprovalPayload.ts +7 -0
- package/src/effect/ApprovalResult.ts +6 -0
- package/src/effect/BuilderNode.ts +52 -0
- package/src/effect/BuilderStepHandle.ts +47 -0
- package/src/effect/CancelPayload.ts +3 -0
- package/src/effect/CancelResult.ts +4 -0
- package/src/effect/DeferredResolution.ts +7 -0
- package/src/effect/DiffBundle.ts +7 -0
- package/src/effect/ExecuteTaskActivityOptions.ts +7 -0
- package/src/effect/FilePatch.ts +6 -0
- package/src/effect/GetRunPayload.ts +3 -0
- package/src/effect/GetRunResult.ts +3 -0
- package/src/effect/LegacyExecuteTaskFn.ts +24 -0
- package/src/effect/ListRunsPayload.ts +6 -0
- package/src/effect/RunStatusSchema.ts +9 -0
- package/src/effect/RunSummary.ts +23 -0
- package/src/effect/SignalPayload.ts +7 -0
- package/src/effect/SignalResult.ts +6 -0
- package/src/effect/SmithersSqliteOptions.ts +3 -0
- package/src/effect/SqlMessageStorageEventHistoryQuery.ts +7 -0
- package/src/effect/TaggedWorkerError.ts +46 -0
- package/src/effect/TaskActivityContext.ts +4 -0
- package/src/effect/TaskActivityRetryOptions.ts +4 -0
- package/src/effect/TaskBridgeToolConfig.ts +6 -0
- package/src/effect/TaskFailure.ts +3 -0
- package/src/effect/TaskResult.ts +5 -0
- package/src/effect/UnknownWorkerError.ts +5 -0
- package/src/effect/WaitForEventDurableDeferredResolution.ts +11 -0
- package/src/effect/WorkerDispatchKind.ts +1 -0
- package/src/effect/WorkerTask.ts +14 -0
- package/src/effect/WorkerTaskError.ts +4 -0
- package/src/effect/WorkerTaskKind.ts +1 -0
- package/src/effect/WorkflowPatchDecisionRecord.ts +4 -0
- package/src/effect/WorkflowPatchDecisions.ts +1 -0
- package/src/effect/WorkflowVersioningRuntime.ts +7 -0
- package/src/effect/activity-bridge.js +131 -0
- package/src/effect/bridge-utils.js +45 -0
- package/src/effect/builder.js +837 -0
- package/src/effect/compute-task-bridge.js +734 -0
- package/src/effect/deferred-bridge.js +63 -0
- package/src/effect/deferred-state-bridge.js +1343 -0
- package/src/effect/diff-bundle.js +352 -0
- package/src/effect/durable-deferred-bridge.js +282 -0
- package/src/effect/entity-worker.js +154 -0
- package/src/effect/http-runner.js +86 -0
- package/src/effect/rpc-schema.js +101 -0
- package/src/effect/single-runner.js +189 -0
- package/src/effect/sql-message-storage.js +817 -0
- package/src/effect/static-task-bridge.js +308 -0
- package/src/effect/versioning.js +123 -0
- package/src/effect/workflow-bridge.js +260 -0
- package/src/effect/workflow-make-bridge.js +233 -0
- package/src/engine.js +6933 -0
- package/src/events.js +237 -0
- package/src/external/json-schema-to-zod.js +214 -0
- package/src/getDefinedToolMetadata.js +10 -0
- package/src/hot/HotReloadEvent.ts +21 -0
- package/src/hot/HotWorkflowController.js +220 -0
- package/src/hot/OverlayOptions.ts +4 -0
- package/src/hot/WatchTreeOptions.ts +6 -0
- package/src/hot/index.js +9 -0
- package/src/hot/overlay.js +177 -0
- package/src/hot/watch.js +174 -0
- package/src/human-requests.js +120 -0
- package/src/index.d.ts +1597 -0
- package/src/index.js +41 -0
- package/src/runtime-owner.js +36 -0
- package/src/scheduler.js +31 -0
- package/src/signals.js +82 -0
package/src/events.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
6
|
+
import { trackEvent } from "@smithers-orchestrator/observability/metrics";
|
|
7
|
+
import { correlationContextToLogAnnotations, getCurrentCorrelationContext, mergeCorrelationContext, withCurrentCorrelationContext, } from "@smithers-orchestrator/observability/correlation";
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/observability/correlation").CorrelationContext} CorrelationContext */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {SmithersEvent & { correlation?: CorrelationContext; }} CorrelatedSmithersEvent
|
|
12
|
+
*/
|
|
13
|
+
/** @typedef {import("@smithers-orchestrator/observability/SmithersEvent").SmithersEvent} SmithersEvent */
|
|
14
|
+
/** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase<Record<string, unknown>>} _BunSQLiteDatabase */
|
|
15
|
+
|
|
16
|
+
export class EventBus extends EventEmitter {
|
|
17
|
+
seq = 0;
|
|
18
|
+
logDir;
|
|
19
|
+
db;
|
|
20
|
+
persistTail = Promise.resolve();
|
|
21
|
+
persistError = null;
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ db?: BunSQLiteDatabase; logDir?: string; startSeq?: number }} opts
|
|
24
|
+
*/
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
super();
|
|
27
|
+
this.db = opts.db;
|
|
28
|
+
this.logDir = opts.logDir;
|
|
29
|
+
this.seq = opts.startSeq ?? 0;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @param {SmithersEvent} event
|
|
33
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
34
|
+
*/
|
|
35
|
+
emitEvent(event) {
|
|
36
|
+
const correlatedEvent = this.attachCorrelation(event);
|
|
37
|
+
const self = this;
|
|
38
|
+
return withCurrentCorrelationContext(Effect.gen(function* () {
|
|
39
|
+
yield* self.emitAndTrack(correlatedEvent);
|
|
40
|
+
if (self.db) {
|
|
41
|
+
yield* self.persistDb(correlatedEvent);
|
|
42
|
+
}
|
|
43
|
+
}).pipe(Effect.annotateLogs(this.eventLogAnnotations(correlatedEvent)), Effect.withLogSpan(`event:${correlatedEvent.type}`)));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @param {SmithersEvent} event
|
|
47
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
48
|
+
*/
|
|
49
|
+
emitEventWithPersist(event) {
|
|
50
|
+
const correlatedEvent = this.attachCorrelation(event);
|
|
51
|
+
const self = this;
|
|
52
|
+
return withCurrentCorrelationContext(Effect.gen(function* () {
|
|
53
|
+
yield* self.emitAndTrack(correlatedEvent);
|
|
54
|
+
yield* self.persist(correlatedEvent);
|
|
55
|
+
}).pipe(Effect.annotateLogs(this.eventLogAnnotations(correlatedEvent)), Effect.withLogSpan(`event:${correlatedEvent.type}:persist`)));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @param {SmithersEvent} event
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
emitEventQueued(event) {
|
|
62
|
+
const correlatedEvent = this.attachCorrelation(event);
|
|
63
|
+
this.emit("event", correlatedEvent);
|
|
64
|
+
return Effect.runPromise(withCurrentCorrelationContext(trackEvent(correlatedEvent).pipe(Effect.andThen(this.enqueuePersist(correlatedEvent)))));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
68
|
+
*/
|
|
69
|
+
flush() {
|
|
70
|
+
return withCurrentCorrelationContext(Effect.tryPromise({
|
|
71
|
+
try: async () => {
|
|
72
|
+
await this.persistTail;
|
|
73
|
+
if (this.persistError) {
|
|
74
|
+
const err = this.persistError;
|
|
75
|
+
this.persistError = null;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
catch: (cause) => toSmithersError(cause, "flush queued events"),
|
|
80
|
+
}).pipe(Effect.withLogSpan("event:flush")));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* @param {CorrelatedSmithersEvent} event
|
|
84
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
85
|
+
*/
|
|
86
|
+
persist(event) {
|
|
87
|
+
const self = this;
|
|
88
|
+
return withCurrentCorrelationContext(Effect.gen(function* () {
|
|
89
|
+
yield* self.persistDb(event);
|
|
90
|
+
const persistedLog = yield* Effect.either(self.persistLog(event));
|
|
91
|
+
if (persistedLog._tag === "Left") {
|
|
92
|
+
yield* Effect.logWarning(`[smithers] failed to append event log: ${persistedLog.left instanceof Error ? persistedLog.left.message : String(persistedLog.left)}`);
|
|
93
|
+
}
|
|
94
|
+
}).pipe(Effect.annotateLogs(this.eventLogAnnotations(event)), Effect.withLogSpan("event:persist")));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* @param {CorrelatedSmithersEvent} event
|
|
98
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
99
|
+
*/
|
|
100
|
+
emitAndTrack(event) {
|
|
101
|
+
const self = this;
|
|
102
|
+
return Effect.gen(function* () {
|
|
103
|
+
yield* Effect.sync(() => self.emit("event", event));
|
|
104
|
+
yield* trackEvent(event);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* @param {CorrelatedSmithersEvent} event
|
|
109
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
110
|
+
*/
|
|
111
|
+
enqueuePersist(event) {
|
|
112
|
+
const task = this.persistTail.then(() => Effect.runPromise(this.persist(event)));
|
|
113
|
+
this.persistTail = task.catch((error) => {
|
|
114
|
+
this.persistError = error;
|
|
115
|
+
});
|
|
116
|
+
return Effect.tryPromise({
|
|
117
|
+
try: () => task,
|
|
118
|
+
catch: (cause) => toSmithersError(cause, "enqueue event persistence"),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* @param {CorrelatedSmithersEvent} event
|
|
123
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
124
|
+
*/
|
|
125
|
+
persistDb(event) {
|
|
126
|
+
if (!this.db)
|
|
127
|
+
return Effect.void;
|
|
128
|
+
const payloadJson = JSON.stringify(event);
|
|
129
|
+
const nextSeqRow = {
|
|
130
|
+
runId: event.runId,
|
|
131
|
+
timestampMs: event.timestampMs,
|
|
132
|
+
type: event.type,
|
|
133
|
+
payloadJson,
|
|
134
|
+
};
|
|
135
|
+
const eventRow = {
|
|
136
|
+
...nextSeqRow,
|
|
137
|
+
seq: this.seq++,
|
|
138
|
+
};
|
|
139
|
+
if (typeof this.db.insertEventWithNextSeqEffect === "function") {
|
|
140
|
+
return this.callDbPersistence(`insert event ${event.type}`, this.db.insertEventWithNextSeqEffect, nextSeqRow);
|
|
141
|
+
}
|
|
142
|
+
if (typeof this.db.insertEventWithNextSeq === "function") {
|
|
143
|
+
return this.callDbPersistence(`insert event ${event.type}`, this.db.insertEventWithNextSeq, nextSeqRow);
|
|
144
|
+
}
|
|
145
|
+
if (typeof this.db.insertEventEffect === "function") {
|
|
146
|
+
return this.callDbPersistence(`insert event ${event.type}`, this.db.insertEventEffect, eventRow);
|
|
147
|
+
}
|
|
148
|
+
if (typeof this.db.insertEvent === "function") {
|
|
149
|
+
return this.callDbPersistence(`insert event ${event.type}`, this.db.insertEvent, eventRow);
|
|
150
|
+
}
|
|
151
|
+
return Effect.void;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} label
|
|
155
|
+
* @param {(row: any) => unknown} method
|
|
156
|
+
* @param {any} row
|
|
157
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
158
|
+
*/
|
|
159
|
+
callDbPersistence(label, method, row) {
|
|
160
|
+
const db = this.db;
|
|
161
|
+
return Effect.try({
|
|
162
|
+
try: () => method.call(db, row),
|
|
163
|
+
catch: (cause) => toSmithersError(cause, label),
|
|
164
|
+
}).pipe(Effect.flatMap((result) => {
|
|
165
|
+
if (Effect.isEffect(result)) {
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
if (result &&
|
|
169
|
+
typeof result === "object" &&
|
|
170
|
+
typeof result.then === "function") {
|
|
171
|
+
return Effect.tryPromise({
|
|
172
|
+
try: () => result,
|
|
173
|
+
catch: (cause) => toSmithersError(cause, label),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return Effect.void;
|
|
177
|
+
}), Effect.asVoid);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* @param {CorrelatedSmithersEvent} event
|
|
181
|
+
* @returns {Effect.Effect<void, unknown>}
|
|
182
|
+
*/
|
|
183
|
+
persistLog(event) {
|
|
184
|
+
if (!this.logDir)
|
|
185
|
+
return Effect.void;
|
|
186
|
+
const dir = this.logDir;
|
|
187
|
+
return Effect.tryPromise({
|
|
188
|
+
try: async () => {
|
|
189
|
+
await mkdir(dir, { recursive: true });
|
|
190
|
+
const file = join(dir, "stream.ndjson");
|
|
191
|
+
const line = JSON.stringify(event) + "\n";
|
|
192
|
+
let prefix = "";
|
|
193
|
+
try {
|
|
194
|
+
prefix = await readFile(file, "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!error ||
|
|
198
|
+
typeof error !== "object" ||
|
|
199
|
+
error.code !== "ENOENT") {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await writeFile(file, prefix + line);
|
|
204
|
+
},
|
|
205
|
+
catch: (cause) => toSmithersError(cause, "append event log"),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* @param {SmithersEvent} event
|
|
210
|
+
* @returns {CorrelatedSmithersEvent}
|
|
211
|
+
*/
|
|
212
|
+
attachCorrelation(event) {
|
|
213
|
+
const correlation = mergeCorrelationContext(getCurrentCorrelationContext(), {
|
|
214
|
+
runId: event.runId,
|
|
215
|
+
nodeId: "nodeId" in event && typeof event.nodeId === "string"
|
|
216
|
+
? event.nodeId
|
|
217
|
+
: undefined,
|
|
218
|
+
iteration: "iteration" in event && typeof event.iteration === "number"
|
|
219
|
+
? event.iteration
|
|
220
|
+
: undefined,
|
|
221
|
+
attempt: "attempt" in event && typeof event.attempt === "number"
|
|
222
|
+
? event.attempt
|
|
223
|
+
: undefined,
|
|
224
|
+
});
|
|
225
|
+
return correlation ? { ...event, correlation } : event;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* @param {CorrelatedSmithersEvent} event
|
|
229
|
+
*/
|
|
230
|
+
eventLogAnnotations(event) {
|
|
231
|
+
return {
|
|
232
|
+
...correlationContextToLogAnnotations(event.correlation),
|
|
233
|
+
runId: event.runId,
|
|
234
|
+
eventType: event.type,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert JSON Schema to Zod schemas.
|
|
3
|
+
*
|
|
4
|
+
* Handles standard JSON Schema patterns:
|
|
5
|
+
* - $defs for nested model references
|
|
6
|
+
* - anyOf + null for Optional fields → .nullable()
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Record<string, any>} JsonSchema
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert a JSON Schema to a Zod object schema.
|
|
15
|
+
*
|
|
16
|
+
* @param {JsonSchema} rootSchema
|
|
17
|
+
* @returns {z.ZodObject<any>}
|
|
18
|
+
*/
|
|
19
|
+
export function jsonSchemaToZod(rootSchema) {
|
|
20
|
+
const result = convertNode(rootSchema, rootSchema, new Set());
|
|
21
|
+
if (result instanceof z.ZodObject)
|
|
22
|
+
return result;
|
|
23
|
+
return z.object({}).catchall(z.unknown());
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* @param {JsonSchema | undefined} node
|
|
27
|
+
* @param {JsonSchema} root
|
|
28
|
+
* @param {Set<string>} visited
|
|
29
|
+
* @returns {z.ZodType}
|
|
30
|
+
*/
|
|
31
|
+
function convertNode(node, root, visited) {
|
|
32
|
+
if (!node)
|
|
33
|
+
return z.any();
|
|
34
|
+
if (node.$ref && typeof node.$ref === "string") {
|
|
35
|
+
const ref = node.$ref;
|
|
36
|
+
if (visited.has(ref)) {
|
|
37
|
+
return z.any().describe(`Circular reference: ${ref}`);
|
|
38
|
+
}
|
|
39
|
+
visited.add(ref);
|
|
40
|
+
const resolved = resolveJsonPointer(root, ref);
|
|
41
|
+
const result = convertNode(resolved, root, visited);
|
|
42
|
+
visited.delete(ref);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
if (node.allOf && Array.isArray(node.allOf) && node.allOf.length > 0) {
|
|
46
|
+
const schemas = node.allOf.map((sub) => convertNode(sub, root, visited));
|
|
47
|
+
if (schemas.length === 1)
|
|
48
|
+
return maybeDescribe(schemas[0], node);
|
|
49
|
+
let result = schemas[0];
|
|
50
|
+
for (let i = 1; i < schemas.length; i++) {
|
|
51
|
+
result = z.intersection(result, schemas[i]);
|
|
52
|
+
}
|
|
53
|
+
return maybeDescribe(result, node);
|
|
54
|
+
}
|
|
55
|
+
if (node.anyOf && Array.isArray(node.anyOf) && node.anyOf.length > 0) {
|
|
56
|
+
return buildAnyOf(node.anyOf, root, visited, node);
|
|
57
|
+
}
|
|
58
|
+
if (node.oneOf && Array.isArray(node.oneOf) && node.oneOf.length > 0) {
|
|
59
|
+
return buildUnion(node.oneOf, root, visited, node);
|
|
60
|
+
}
|
|
61
|
+
const type = node.type;
|
|
62
|
+
if (type === "string")
|
|
63
|
+
return buildString(node);
|
|
64
|
+
if (type === "number" || type === "integer")
|
|
65
|
+
return buildNumber(node);
|
|
66
|
+
if (type === "boolean")
|
|
67
|
+
return maybeDescribe(z.boolean(), node);
|
|
68
|
+
if (type === "array") {
|
|
69
|
+
const items = convertNode(node.items, root, visited);
|
|
70
|
+
return maybeDescribe(z.array(items), node);
|
|
71
|
+
}
|
|
72
|
+
if (type === "object" || node.properties) {
|
|
73
|
+
return buildObject(node, root, visited);
|
|
74
|
+
}
|
|
75
|
+
if (type === "null")
|
|
76
|
+
return z.null();
|
|
77
|
+
return z.any();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @param {JsonSchema[]} variants
|
|
81
|
+
* @param {JsonSchema} root
|
|
82
|
+
* @param {Set<string>} visited
|
|
83
|
+
* @param {JsonSchema} parent
|
|
84
|
+
* @returns {z.ZodType}
|
|
85
|
+
*/
|
|
86
|
+
function buildAnyOf(variants, root, visited, parent) {
|
|
87
|
+
const nullIdx = variants.findIndex((v) => v.type === "null");
|
|
88
|
+
if (nullIdx !== -1 && variants.length === 2) {
|
|
89
|
+
const other = variants[1 - nullIdx];
|
|
90
|
+
const inner = convertNode(other, root, visited);
|
|
91
|
+
const result = inner.nullable();
|
|
92
|
+
return parent.default !== undefined ? maybeDefault(maybeDescribe(result, parent), parent) : maybeDescribe(result, parent);
|
|
93
|
+
}
|
|
94
|
+
return buildUnion(variants, root, visited, parent);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* @param {JsonSchema} s
|
|
98
|
+
* @returns {z.ZodType}
|
|
99
|
+
*/
|
|
100
|
+
function buildString(s) {
|
|
101
|
+
let schema;
|
|
102
|
+
if (s.enum && Array.isArray(s.enum) && s.enum.length > 0) {
|
|
103
|
+
schema = z.enum(s.enum);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
let str = z.string();
|
|
107
|
+
if (s.minLength !== undefined)
|
|
108
|
+
str = str.min(s.minLength);
|
|
109
|
+
if (s.maxLength !== undefined)
|
|
110
|
+
str = str.max(s.maxLength);
|
|
111
|
+
if (s.pattern)
|
|
112
|
+
str = str.regex(new RegExp(s.pattern));
|
|
113
|
+
schema = str;
|
|
114
|
+
}
|
|
115
|
+
return maybeDescribe(maybeNullable(maybeDefault(schema, s), s), s);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @param {JsonSchema} s
|
|
119
|
+
* @returns {z.ZodType}
|
|
120
|
+
*/
|
|
121
|
+
function buildNumber(s) {
|
|
122
|
+
let num = z.number();
|
|
123
|
+
if (s.type === "integer")
|
|
124
|
+
num = num.int();
|
|
125
|
+
if (s.minimum !== undefined)
|
|
126
|
+
num = num.min(s.minimum);
|
|
127
|
+
if (s.maximum !== undefined)
|
|
128
|
+
num = num.max(s.maximum);
|
|
129
|
+
return maybeDescribe(maybeNullable(maybeDefault(num, s), s), s);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* @param {JsonSchema} s
|
|
133
|
+
* @param {JsonSchema} root
|
|
134
|
+
* @param {Set<string>} visited
|
|
135
|
+
* @returns {z.ZodType}
|
|
136
|
+
*/
|
|
137
|
+
function buildObject(s, root, visited) {
|
|
138
|
+
const props = {};
|
|
139
|
+
const required = new Set(s.required ?? []);
|
|
140
|
+
for (const [key, propSchema] of Object.entries(s.properties ?? {})) {
|
|
141
|
+
let zodProp = convertNode(propSchema, root, visited);
|
|
142
|
+
if (!required.has(key)) {
|
|
143
|
+
zodProp = zodProp.optional();
|
|
144
|
+
}
|
|
145
|
+
props[key] = zodProp;
|
|
146
|
+
}
|
|
147
|
+
const obj = z.object(props);
|
|
148
|
+
return maybeDescribe(maybeNullable(obj, s), s);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* @param {JsonSchema[]} variants
|
|
152
|
+
* @param {JsonSchema} root
|
|
153
|
+
* @param {Set<string>} visited
|
|
154
|
+
* @param {JsonSchema} parent
|
|
155
|
+
* @returns {z.ZodType}
|
|
156
|
+
*/
|
|
157
|
+
function buildUnion(variants, root, visited, parent) {
|
|
158
|
+
const schemas = variants.map((v) => convertNode(v, root, visited));
|
|
159
|
+
if (schemas.length === 0)
|
|
160
|
+
return z.any();
|
|
161
|
+
if (schemas.length === 1)
|
|
162
|
+
return maybeDescribe(schemas[0], parent);
|
|
163
|
+
return maybeDescribe(z.union(schemas), parent);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* @param {z.ZodType} schema
|
|
167
|
+
* @param {JsonSchema} s
|
|
168
|
+
* @returns {z.ZodType}
|
|
169
|
+
*/
|
|
170
|
+
function maybeDescribe(schema, s) {
|
|
171
|
+
if (s.description)
|
|
172
|
+
return schema.describe(s.description);
|
|
173
|
+
return schema;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* @param {z.ZodType} schema
|
|
177
|
+
* @param {JsonSchema} s
|
|
178
|
+
* @returns {z.ZodType}
|
|
179
|
+
*/
|
|
180
|
+
function maybeNullable(schema, s) {
|
|
181
|
+
if (s.nullable)
|
|
182
|
+
return schema.nullable();
|
|
183
|
+
return schema;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @param {z.ZodType} schema
|
|
187
|
+
* @param {JsonSchema} s
|
|
188
|
+
* @returns {z.ZodType}
|
|
189
|
+
*/
|
|
190
|
+
function maybeDefault(schema, s) {
|
|
191
|
+
if (s.default !== undefined)
|
|
192
|
+
return schema.default(s.default);
|
|
193
|
+
return schema;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* @param {JsonSchema} root
|
|
197
|
+
* @param {string} ref
|
|
198
|
+
* @returns {JsonSchema}
|
|
199
|
+
*/
|
|
200
|
+
function resolveJsonPointer(root, ref) {
|
|
201
|
+
if (!ref.startsWith("#/")) {
|
|
202
|
+
throw new Error(`Unsupported $ref format: ${ref}`);
|
|
203
|
+
}
|
|
204
|
+
const parts = ref.slice(2).split("/");
|
|
205
|
+
let current = root;
|
|
206
|
+
for (const part of parts) {
|
|
207
|
+
const decoded = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
208
|
+
current = current?.[decoded];
|
|
209
|
+
if (current === undefined) {
|
|
210
|
+
throw new Error(`Could not resolve $ref: ${ref}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return current;
|
|
214
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const smithersToolMetadata = Symbol.for("smithers.tool.metadata");
|
|
2
|
+
/**
|
|
3
|
+
* @param {unknown} value
|
|
4
|
+
* @returns {| { name: string; sideEffect: boolean; idempotent: boolean; } | null}
|
|
5
|
+
*/
|
|
6
|
+
export function getDefinedToolMetadata(value) {
|
|
7
|
+
return value && typeof value === "object"
|
|
8
|
+
? (value[smithersToolMetadata] ?? null)
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SmithersWorkflow } from "@smithers-orchestrator/components/SmithersWorkflow";
|
|
2
|
+
|
|
3
|
+
export type HotReloadEvent =
|
|
4
|
+
| {
|
|
5
|
+
type: "reloaded";
|
|
6
|
+
generation: number;
|
|
7
|
+
changedFiles: string[];
|
|
8
|
+
newBuild: SmithersWorkflow<unknown>["build"];
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
type: "failed";
|
|
12
|
+
generation: number;
|
|
13
|
+
changedFiles: string[];
|
|
14
|
+
error: unknown;
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
type: "unsafe";
|
|
18
|
+
generation: number;
|
|
19
|
+
changedFiles: string[];
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./HotReloadEvent.ts").HotReloadEvent} HotReloadEvent */
|
|
3
|
+
// @smithers-type-exports-end
|
|
4
|
+
|
|
5
|
+
import { resolve, dirname } from "node:path";
|
|
6
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { Effect } from "effect";
|
|
9
|
+
import { WatchTree } from "./watch.js";
|
|
10
|
+
import { buildOverlayEffect, cleanupGenerationsEffect, resolveOverlayEntry, } from "./overlay.js";
|
|
11
|
+
import { Metric } from "effect";
|
|
12
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
13
|
+
import { logInfo, logWarning } from "@smithers-orchestrator/observability/logging";
|
|
14
|
+
import { hotReloads, hotReloadFailures, hotReloadDuration } from "@smithers-orchestrator/observability/metrics";
|
|
15
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
16
|
+
/** @typedef {import("@smithers-orchestrator/driver/RunOptions").HotReloadOptions} HotReloadOptions */
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_GENERATIONS = 3;
|
|
19
|
+
const DEFAULT_DEBOUNCE_MS = 100;
|
|
20
|
+
export class HotWorkflowController {
|
|
21
|
+
entryPath;
|
|
22
|
+
hotRoot;
|
|
23
|
+
outDir;
|
|
24
|
+
maxGenerations;
|
|
25
|
+
watcher;
|
|
26
|
+
generation = 0;
|
|
27
|
+
closed = false;
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} entryPath
|
|
30
|
+
* @param {HotReloadOptions} [opts]
|
|
31
|
+
*/
|
|
32
|
+
constructor(entryPath, opts) {
|
|
33
|
+
this.entryPath = resolve(entryPath);
|
|
34
|
+
this.hotRoot = opts?.rootDir
|
|
35
|
+
? resolve(opts.rootDir)
|
|
36
|
+
: dirname(this.entryPath);
|
|
37
|
+
this.outDir = opts?.outDir
|
|
38
|
+
? resolve(opts.outDir)
|
|
39
|
+
: resolve(this.hotRoot, ".smithers", "hmr");
|
|
40
|
+
this.maxGenerations = opts?.maxGenerations ?? DEFAULT_MAX_GENERATIONS;
|
|
41
|
+
this.watcher = new WatchTree(this.hotRoot, {
|
|
42
|
+
debounceMs: opts?.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Initialize: start file watchers. Call once before using wait/reload. */
|
|
46
|
+
async init() {
|
|
47
|
+
await Effect.runPromise(this.initEffect());
|
|
48
|
+
}
|
|
49
|
+
/** Current generation number. */
|
|
50
|
+
get gen() {
|
|
51
|
+
return this.generation;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Wait for the next file change event.
|
|
55
|
+
* Returns the list of changed file paths.
|
|
56
|
+
* Use this in Promise.race with inflight tasks to wake the engine loop.
|
|
57
|
+
*/
|
|
58
|
+
async wait() {
|
|
59
|
+
return Effect.runPromise(this.waitEffect());
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Perform a hot reload:
|
|
63
|
+
* 1. Build a new generation overlay
|
|
64
|
+
* 2. Import the workflow module from the overlay
|
|
65
|
+
* 3. Validate the module
|
|
66
|
+
* 4. Return the result (reloaded, failed, or unsafe)
|
|
67
|
+
*
|
|
68
|
+
* The caller is responsible for swapping workflow.build on success.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} changedFiles
|
|
71
|
+
* @returns {Promise<HotReloadEvent>}
|
|
72
|
+
*/
|
|
73
|
+
async reload(changedFiles) {
|
|
74
|
+
return Effect.runPromise(this.reloadEffect(changedFiles));
|
|
75
|
+
}
|
|
76
|
+
initEffect() {
|
|
77
|
+
return Effect.gen(this, function* () {
|
|
78
|
+
yield* Effect.tryPromise({
|
|
79
|
+
try: () => mkdir(this.outDir, { recursive: true }),
|
|
80
|
+
catch: (cause) => toSmithersError(cause, "create hot reload output dir"),
|
|
81
|
+
});
|
|
82
|
+
yield* this.watcher.startEffect();
|
|
83
|
+
yield* Effect.sync(() => {
|
|
84
|
+
logInfo("initialized hot workflow controller", {
|
|
85
|
+
entryPath: this.entryPath,
|
|
86
|
+
hotRoot: this.hotRoot,
|
|
87
|
+
outDir: this.outDir,
|
|
88
|
+
}, "hot:controller");
|
|
89
|
+
});
|
|
90
|
+
}).pipe(Effect.annotateLogs({
|
|
91
|
+
entryPath: this.entryPath,
|
|
92
|
+
hotRoot: this.hotRoot,
|
|
93
|
+
outDir: this.outDir,
|
|
94
|
+
}), Effect.withLogSpan("hot:init"));
|
|
95
|
+
}
|
|
96
|
+
waitEffect() {
|
|
97
|
+
return this.watcher.waitEffect().pipe(Effect.annotateLogs({
|
|
98
|
+
entryPath: this.entryPath,
|
|
99
|
+
generation: this.generation,
|
|
100
|
+
}), Effect.withLogSpan("hot:wait"));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* @param {string[]} changedFiles
|
|
104
|
+
*/
|
|
105
|
+
reloadEffect(changedFiles) {
|
|
106
|
+
this.generation += 1;
|
|
107
|
+
const gen = this.generation;
|
|
108
|
+
const entryPath = this.entryPath;
|
|
109
|
+
const hotRoot = this.hotRoot;
|
|
110
|
+
const outDir = this.outDir;
|
|
111
|
+
const maxGenerations = this.maxGenerations;
|
|
112
|
+
return Effect.gen(this, function* () {
|
|
113
|
+
const reloadStart = performance.now();
|
|
114
|
+
const genDir = yield* buildOverlayEffect(hotRoot, outDir, gen);
|
|
115
|
+
const overlayEntry = resolveOverlayEntry(entryPath, hotRoot, genDir);
|
|
116
|
+
const overlayUrl = pathToFileURL(overlayEntry).href;
|
|
117
|
+
const mod = yield* Effect.either(Effect.tryPromise({
|
|
118
|
+
try: () => import(overlayUrl),
|
|
119
|
+
catch: (cause) => toSmithersError(cause, "import hot workflow generation"),
|
|
120
|
+
}));
|
|
121
|
+
if (mod._tag === "Left") {
|
|
122
|
+
logWarning("hot workflow import failed", {
|
|
123
|
+
entryPath,
|
|
124
|
+
generation: gen,
|
|
125
|
+
changedFileCount: changedFiles.length,
|
|
126
|
+
error: mod.left instanceof Error ? mod.left.message : String(mod.left),
|
|
127
|
+
}, "hot:reload");
|
|
128
|
+
return { type: "failed", generation: gen, changedFiles, error: mod.left };
|
|
129
|
+
}
|
|
130
|
+
const workflow = mod.right.default;
|
|
131
|
+
if (!workflow) {
|
|
132
|
+
return {
|
|
133
|
+
type: "failed",
|
|
134
|
+
generation: gen,
|
|
135
|
+
changedFiles,
|
|
136
|
+
error: new SmithersError("HOT_RELOAD_INVALID_MODULE", "Reloaded module does not export default", { changedFiles, entryPath, generation: gen }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (typeof workflow.build !== "function") {
|
|
140
|
+
return {
|
|
141
|
+
type: "failed",
|
|
142
|
+
generation: gen,
|
|
143
|
+
changedFiles,
|
|
144
|
+
error: new SmithersError("HOT_RELOAD_INVALID_MODULE", "Reloaded module default does not have a build function", { changedFiles, entryPath, generation: gen }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
yield* cleanupGenerationsEffect(outDir, maxGenerations);
|
|
148
|
+
yield* Metric.increment(hotReloads);
|
|
149
|
+
yield* Metric.update(hotReloadDuration, performance.now() - reloadStart);
|
|
150
|
+
logInfo("reloaded hot workflow generation", {
|
|
151
|
+
entryPath,
|
|
152
|
+
generation: gen,
|
|
153
|
+
changedFileCount: changedFiles.length,
|
|
154
|
+
}, "hot:reload");
|
|
155
|
+
return {
|
|
156
|
+
type: "reloaded",
|
|
157
|
+
generation: gen,
|
|
158
|
+
changedFiles,
|
|
159
|
+
newBuild: workflow.build,
|
|
160
|
+
};
|
|
161
|
+
}).pipe(Effect.catchAll((err) => Effect.gen(function* () {
|
|
162
|
+
yield* Metric.increment(hotReloadFailures);
|
|
163
|
+
if (err instanceof Error && err.message?.includes("Schema change detected")) {
|
|
164
|
+
logWarning("hot workflow reload marked unsafe", {
|
|
165
|
+
entryPath,
|
|
166
|
+
generation: gen,
|
|
167
|
+
changedFileCount: changedFiles.length,
|
|
168
|
+
reason: err.message,
|
|
169
|
+
}, "hot:reload");
|
|
170
|
+
return {
|
|
171
|
+
type: "unsafe",
|
|
172
|
+
generation: gen,
|
|
173
|
+
changedFiles,
|
|
174
|
+
reason: err.message,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
logWarning("hot workflow reload failed", {
|
|
178
|
+
entryPath,
|
|
179
|
+
generation: gen,
|
|
180
|
+
changedFileCount: changedFiles.length,
|
|
181
|
+
error: err instanceof Error ? err.message : String(err),
|
|
182
|
+
}, "hot:reload");
|
|
183
|
+
return {
|
|
184
|
+
type: "failed",
|
|
185
|
+
generation: gen,
|
|
186
|
+
changedFiles,
|
|
187
|
+
error: err,
|
|
188
|
+
};
|
|
189
|
+
})), Effect.annotateLogs({
|
|
190
|
+
entryPath,
|
|
191
|
+
hotRoot,
|
|
192
|
+
generation: gen,
|
|
193
|
+
}), Effect.withLogSpan("hot:reload"));
|
|
194
|
+
}
|
|
195
|
+
/** Stop watchers and clean up overlay directory. */
|
|
196
|
+
async close() {
|
|
197
|
+
await Effect.runPromise(this.closeEffect());
|
|
198
|
+
}
|
|
199
|
+
closeEffect() {
|
|
200
|
+
return Effect.gen(this, function* () {
|
|
201
|
+
if (this.closed)
|
|
202
|
+
return;
|
|
203
|
+
this.closed = true;
|
|
204
|
+
this.watcher.close();
|
|
205
|
+
yield* Effect.either(Effect.tryPromise({
|
|
206
|
+
try: () => rm(this.outDir, { recursive: true, force: true }),
|
|
207
|
+
catch: (cause) => toSmithersError(cause, "remove hot reload output dir"),
|
|
208
|
+
}));
|
|
209
|
+
logInfo("closed hot workflow controller", {
|
|
210
|
+
entryPath: this.entryPath,
|
|
211
|
+
outDir: this.outDir,
|
|
212
|
+
generation: this.generation,
|
|
213
|
+
}, "hot:controller");
|
|
214
|
+
}).pipe(Effect.annotateLogs({
|
|
215
|
+
entryPath: this.entryPath,
|
|
216
|
+
outDir: this.outDir,
|
|
217
|
+
generation: this.generation,
|
|
218
|
+
}), Effect.withLogSpan("hot:close"));
|
|
219
|
+
}
|
|
220
|
+
}
|