@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +50 -0
  3. package/src/AlertHumanRequestOptions.ts +8 -0
  4. package/src/AlertRuntimeServices.ts +10 -0
  5. package/src/ChildWorkflowDefinition.ts +5 -0
  6. package/src/ChildWorkflowExecuteOptions.ts +14 -0
  7. package/src/ContinuationRequest.ts +3 -0
  8. package/src/HijackState.ts +19 -0
  9. package/src/HumanRequestKind.ts +1 -0
  10. package/src/HumanRequestStatus.ts +1 -0
  11. package/src/PlanNode.ts +29 -0
  12. package/src/RalphMeta.ts +7 -0
  13. package/src/RalphState.ts +4 -0
  14. package/src/RalphStateMap.ts +3 -0
  15. package/src/ScheduleResult.ts +15 -0
  16. package/src/SignalRunOptions.ts +5 -0
  17. package/src/alert-runtime.js +22 -0
  18. package/src/approvals.js +220 -0
  19. package/src/child-workflow.js +163 -0
  20. package/src/effect/ApprovalDeferredResolution.ts +13 -0
  21. package/src/effect/ApprovalDurableDeferredResolution.ts +11 -0
  22. package/src/effect/ApprovalPayload.ts +7 -0
  23. package/src/effect/ApprovalResult.ts +6 -0
  24. package/src/effect/BuilderNode.ts +52 -0
  25. package/src/effect/BuilderStepHandle.ts +47 -0
  26. package/src/effect/CancelPayload.ts +3 -0
  27. package/src/effect/CancelResult.ts +4 -0
  28. package/src/effect/DeferredResolution.ts +7 -0
  29. package/src/effect/DiffBundle.ts +7 -0
  30. package/src/effect/ExecuteTaskActivityOptions.ts +7 -0
  31. package/src/effect/FilePatch.ts +6 -0
  32. package/src/effect/GetRunPayload.ts +3 -0
  33. package/src/effect/GetRunResult.ts +3 -0
  34. package/src/effect/LegacyExecuteTaskFn.ts +24 -0
  35. package/src/effect/ListRunsPayload.ts +6 -0
  36. package/src/effect/RunStatusSchema.ts +9 -0
  37. package/src/effect/RunSummary.ts +23 -0
  38. package/src/effect/SignalPayload.ts +7 -0
  39. package/src/effect/SignalResult.ts +6 -0
  40. package/src/effect/SmithersSqliteOptions.ts +3 -0
  41. package/src/effect/SqlMessageStorageEventHistoryQuery.ts +7 -0
  42. package/src/effect/TaggedWorkerError.ts +46 -0
  43. package/src/effect/TaskActivityContext.ts +4 -0
  44. package/src/effect/TaskActivityRetryOptions.ts +4 -0
  45. package/src/effect/TaskBridgeToolConfig.ts +6 -0
  46. package/src/effect/TaskFailure.ts +3 -0
  47. package/src/effect/TaskResult.ts +5 -0
  48. package/src/effect/UnknownWorkerError.ts +5 -0
  49. package/src/effect/WaitForEventDurableDeferredResolution.ts +11 -0
  50. package/src/effect/WorkerDispatchKind.ts +1 -0
  51. package/src/effect/WorkerTask.ts +14 -0
  52. package/src/effect/WorkerTaskError.ts +4 -0
  53. package/src/effect/WorkerTaskKind.ts +1 -0
  54. package/src/effect/WorkflowPatchDecisionRecord.ts +4 -0
  55. package/src/effect/WorkflowPatchDecisions.ts +1 -0
  56. package/src/effect/WorkflowVersioningRuntime.ts +7 -0
  57. package/src/effect/activity-bridge.js +131 -0
  58. package/src/effect/bridge-utils.js +45 -0
  59. package/src/effect/builder.js +837 -0
  60. package/src/effect/compute-task-bridge.js +734 -0
  61. package/src/effect/deferred-bridge.js +63 -0
  62. package/src/effect/deferred-state-bridge.js +1343 -0
  63. package/src/effect/diff-bundle.js +352 -0
  64. package/src/effect/durable-deferred-bridge.js +282 -0
  65. package/src/effect/entity-worker.js +154 -0
  66. package/src/effect/http-runner.js +86 -0
  67. package/src/effect/rpc-schema.js +101 -0
  68. package/src/effect/single-runner.js +189 -0
  69. package/src/effect/sql-message-storage.js +817 -0
  70. package/src/effect/static-task-bridge.js +308 -0
  71. package/src/effect/versioning.js +123 -0
  72. package/src/effect/workflow-bridge.js +260 -0
  73. package/src/effect/workflow-make-bridge.js +233 -0
  74. package/src/engine.js +6933 -0
  75. package/src/events.js +237 -0
  76. package/src/external/json-schema-to-zod.js +214 -0
  77. package/src/getDefinedToolMetadata.js +10 -0
  78. package/src/hot/HotReloadEvent.ts +21 -0
  79. package/src/hot/HotWorkflowController.js +220 -0
  80. package/src/hot/OverlayOptions.ts +4 -0
  81. package/src/hot/WatchTreeOptions.ts +6 -0
  82. package/src/hot/index.js +9 -0
  83. package/src/hot/overlay.js +177 -0
  84. package/src/hot/watch.js +174 -0
  85. package/src/human-requests.js +120 -0
  86. package/src/index.d.ts +1597 -0
  87. package/src/index.js +41 -0
  88. package/src/runtime-owner.js +36 -0
  89. package/src/scheduler.js +31 -0
  90. 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
+ }