lopata 0.0.1
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/README.md +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- package/runtime/worker-registry.ts +52 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { startSpan, persistError, addSpanEvent } from "../tracing/span";
|
|
3
|
+
import { getActiveContext } from "../tracing/context";
|
|
4
|
+
|
|
5
|
+
// --- Limits ---
|
|
6
|
+
|
|
7
|
+
export interface WorkflowLimits {
|
|
8
|
+
maxConcurrentInstances?: number; // default: Infinity
|
|
9
|
+
maxRetentionMs?: number; // default: 0
|
|
10
|
+
maxStepsPerWorkflow?: number; // default: 1024
|
|
11
|
+
maxStepOutputBytes?: number; // default: 1 MiB
|
|
12
|
+
maxInstanceIdLength?: number; // default: 100
|
|
13
|
+
maxStepNameLength?: number; // default: 256
|
|
14
|
+
maxSleepMs?: number; // default: 365 days
|
|
15
|
+
maxWaitForEventTimeoutMs?: number; // default: 365 days
|
|
16
|
+
minWaitForEventTimeoutMs?: number; // default: 1s
|
|
17
|
+
defaultWaitForEventTimeoutMs?: number; // default: 24h
|
|
18
|
+
maxStepDoTimeoutMs?: number; // default: 30 min
|
|
19
|
+
maxBatchSize?: number; // default: 100
|
|
20
|
+
defaultRetryLimit?: number; // default: 5
|
|
21
|
+
defaultRetryDelayMs?: number; // default: 10_000
|
|
22
|
+
defaultRetryBackoff?: "constant" | "linear" | "exponential"; // default: "exponential"
|
|
23
|
+
defaultStepTimeoutMs?: number; // default: 600_000 (10 min)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORKFLOW_DEFAULTS: Required<WorkflowLimits> = {
|
|
27
|
+
maxConcurrentInstances: Infinity,
|
|
28
|
+
maxRetentionMs: 0,
|
|
29
|
+
maxStepsPerWorkflow: 1024,
|
|
30
|
+
maxStepOutputBytes: 1024 * 1024,
|
|
31
|
+
maxInstanceIdLength: 100,
|
|
32
|
+
maxStepNameLength: 256,
|
|
33
|
+
maxSleepMs: 365 * 86_400_000,
|
|
34
|
+
maxWaitForEventTimeoutMs: 365 * 86_400_000,
|
|
35
|
+
minWaitForEventTimeoutMs: 1_000,
|
|
36
|
+
defaultWaitForEventTimeoutMs: 24 * 3_600_000,
|
|
37
|
+
maxStepDoTimeoutMs: 30 * 60_000,
|
|
38
|
+
maxBatchSize: 100,
|
|
39
|
+
defaultRetryLimit: 5,
|
|
40
|
+
defaultRetryDelayMs: 10_000,
|
|
41
|
+
defaultRetryBackoff: "exponential",
|
|
42
|
+
defaultStepTimeoutMs: 600_000,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// --- Cloudflare-compatible limits ---
|
|
46
|
+
|
|
47
|
+
const EVENT_TYPE_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,99}$/;
|
|
48
|
+
|
|
49
|
+
// --- NonRetryableError ---
|
|
50
|
+
|
|
51
|
+
export class NonRetryableError extends Error {
|
|
52
|
+
constructor(message: string, name?: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = name ?? "NonRetryableError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Step config ---
|
|
59
|
+
|
|
60
|
+
export interface WorkflowStepConfig {
|
|
61
|
+
retries?: {
|
|
62
|
+
limit?: number;
|
|
63
|
+
delay?: string | number;
|
|
64
|
+
backoff?: "constant" | "linear" | "exponential";
|
|
65
|
+
};
|
|
66
|
+
timeout?: string | number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Event waiting registry (in-memory, per-process) ---
|
|
70
|
+
|
|
71
|
+
type EventResolver = (payload: unknown) => void;
|
|
72
|
+
const eventWaiters = new Map<string, Map<string, EventResolver>>();
|
|
73
|
+
|
|
74
|
+
function getWaitersForInstance(instanceId: string): Map<string, EventResolver> {
|
|
75
|
+
let map = eventWaiters.get(instanceId);
|
|
76
|
+
if (!map) {
|
|
77
|
+
map = new Map();
|
|
78
|
+
eventWaiters.set(instanceId, map);
|
|
79
|
+
}
|
|
80
|
+
return map;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Global abort controller registry (per-process) ---
|
|
84
|
+
// Allows get() to retrieve a running instance's abort controller for terminate()
|
|
85
|
+
|
|
86
|
+
const abortControllers = new Map<string, AbortController>();
|
|
87
|
+
|
|
88
|
+
// --- Sleep skip registry (per-process) ---
|
|
89
|
+
// Allows skipSleep() to resolve the active sleep/sleepUntil delay immediately
|
|
90
|
+
|
|
91
|
+
const sleepResolvers = new Map<string, () => void>();
|
|
92
|
+
|
|
93
|
+
// --- Step ---
|
|
94
|
+
|
|
95
|
+
class WorkflowStepImpl {
|
|
96
|
+
private abortSignal: AbortSignal;
|
|
97
|
+
private db: Database;
|
|
98
|
+
private instanceId: string;
|
|
99
|
+
private stepCount = 0;
|
|
100
|
+
private knownStepNames = new Set<string>();
|
|
101
|
+
private limits: Required<WorkflowLimits>;
|
|
102
|
+
|
|
103
|
+
constructor(abortSignal: AbortSignal, db: Database, instanceId: string, limits: Required<WorkflowLimits>) {
|
|
104
|
+
this.abortSignal = abortSignal;
|
|
105
|
+
this.db = db;
|
|
106
|
+
this.instanceId = instanceId;
|
|
107
|
+
this.limits = limits;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async checkPaused(): Promise<void> {
|
|
111
|
+
while (true) {
|
|
112
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
113
|
+
const row = this.db
|
|
114
|
+
.query("SELECT status FROM workflow_instances WHERE id = ?")
|
|
115
|
+
.get(this.instanceId) as { status: string } | null;
|
|
116
|
+
if (!row || row.status !== "paused") break;
|
|
117
|
+
await interruptibleDelay(50, this.abortSignal);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private checkStepLimit(): void {
|
|
122
|
+
this.stepCount++;
|
|
123
|
+
if (this.stepCount > this.limits.maxStepsPerWorkflow) {
|
|
124
|
+
throw new Error(`Workflow exceeded maximum of ${this.limits.maxStepsPerWorkflow} steps`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private checkDuplicateStepName(name: string): void {
|
|
129
|
+
if (this.knownStepNames.has(name)) {
|
|
130
|
+
throw new Error(`Duplicate step name "${name}". Step names must be unique within a workflow execution.`);
|
|
131
|
+
}
|
|
132
|
+
this.knownStepNames.add(name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private getCachedStep(name: string): { output: string | null } | null {
|
|
136
|
+
return this.db
|
|
137
|
+
.query("SELECT output FROM workflow_steps WHERE instance_id = ? AND step_name = ?")
|
|
138
|
+
.get(this.instanceId, name) as { output: string | null } | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private cacheStep(name: string, output: unknown): void {
|
|
142
|
+
const serialized = JSON.stringify(output);
|
|
143
|
+
if (serialized !== undefined && serialized.length > this.limits.maxStepOutputBytes) {
|
|
144
|
+
throw new Error(`Step "${name}" output exceeds maximum size of 1 MiB`);
|
|
145
|
+
}
|
|
146
|
+
this.db
|
|
147
|
+
.query("INSERT OR REPLACE INTO workflow_steps (instance_id, step_name, output, completed_at) VALUES (?, ?, ?, ?)")
|
|
148
|
+
.run(this.instanceId, name, serialized, Date.now());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async do<T>(name: string, callbackOrConfig: (() => Promise<T>) | WorkflowStepConfig, maybeCallback?: () => Promise<T>): Promise<T> {
|
|
152
|
+
if (name.length > this.limits.maxStepNameLength) {
|
|
153
|
+
throw new Error(`Step name must be ${this.limits.maxStepNameLength} characters or fewer, got ${name.length}`);
|
|
154
|
+
}
|
|
155
|
+
await this.checkPaused();
|
|
156
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
157
|
+
this.checkStepLimit();
|
|
158
|
+
this.checkDuplicateStepName(name);
|
|
159
|
+
|
|
160
|
+
// Parse overloads: do(name, callback) or do(name, config, callback)
|
|
161
|
+
let config: WorkflowStepConfig | undefined;
|
|
162
|
+
let callback: () => Promise<T>;
|
|
163
|
+
if (typeof callbackOrConfig === "function") {
|
|
164
|
+
callback = callbackOrConfig;
|
|
165
|
+
} else {
|
|
166
|
+
config = callbackOrConfig;
|
|
167
|
+
callback = maybeCallback!;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check checkpoint
|
|
171
|
+
const cached = this.getCachedStep(name);
|
|
172
|
+
if (cached) {
|
|
173
|
+
console.log(` [workflow] step: ${name} (cached)`);
|
|
174
|
+
return JSON.parse(cached.output!) as T;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(` [workflow] step: ${name}`);
|
|
178
|
+
|
|
179
|
+
return startSpan({
|
|
180
|
+
name: `step ${name}`,
|
|
181
|
+
kind: "internal",
|
|
182
|
+
attributes: { "workflow.step.name": name, "workflow.instance_id": this.instanceId },
|
|
183
|
+
}, async () => {
|
|
184
|
+
const maxRetries = config?.retries?.limit ?? this.limits.defaultRetryLimit;
|
|
185
|
+
const delayMs = config?.retries?.delay ? parseDuration(config.retries.delay) : this.limits.defaultRetryDelayMs;
|
|
186
|
+
const backoff = config?.retries?.backoff ?? this.limits.defaultRetryBackoff;
|
|
187
|
+
const timeoutMs = config?.timeout ? parseDuration(config.timeout) : this.limits.defaultStepTimeoutMs;
|
|
188
|
+
|
|
189
|
+
if (timeoutMs > this.limits.maxStepDoTimeoutMs) {
|
|
190
|
+
throw new Error(`Step timeout ${timeoutMs}ms exceeds maximum of ${this.limits.maxStepDoTimeoutMs}ms`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Load persisted failed attempts so retries survive server restarts
|
|
194
|
+
const attemptRow = this.db
|
|
195
|
+
.query("SELECT failed_attempts FROM workflow_step_attempts WHERE instance_id = ? AND step_name = ?")
|
|
196
|
+
.get(this.instanceId, name) as { failed_attempts: number } | null;
|
|
197
|
+
const startAttempt = attemptRow?.failed_attempts ?? 0;
|
|
198
|
+
|
|
199
|
+
let lastError: unknown;
|
|
200
|
+
for (let attempt = startAttempt; attempt <= maxRetries; attempt++) {
|
|
201
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
202
|
+
try {
|
|
203
|
+
let result: T;
|
|
204
|
+
result = await Promise.race([
|
|
205
|
+
callback(),
|
|
206
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Step "${name}" timed out after ${config?.timeout ?? "10 minutes"}`)), timeoutMs)),
|
|
207
|
+
]);
|
|
208
|
+
this.cacheStep(name, result);
|
|
209
|
+
// Clean up attempt counter on success
|
|
210
|
+
this.db.query("DELETE FROM workflow_step_attempts WHERE instance_id = ? AND step_name = ?")
|
|
211
|
+
.run(this.instanceId, name);
|
|
212
|
+
return result;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (err instanceof NonRetryableError) {
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
lastError = err;
|
|
218
|
+
const errName = err instanceof Error ? (err.name || "Error") : "Error";
|
|
219
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
220
|
+
// Record error as span event so it appears in trace detail
|
|
221
|
+
addSpanEvent("step.retry_error", "error", `Attempt ${attempt + 1}/${maxRetries + 1} failed: ${errMsg}`, {
|
|
222
|
+
"error.name": errName,
|
|
223
|
+
"error.message": errMsg,
|
|
224
|
+
"error.stack": err instanceof Error ? err.stack : undefined,
|
|
225
|
+
"step.attempt": attempt + 1,
|
|
226
|
+
"step.max_retries": maxRetries,
|
|
227
|
+
});
|
|
228
|
+
// Persist to errors view (ALS context is active inside startSpan)
|
|
229
|
+
const errorId = persistError(err, "workflow.step");
|
|
230
|
+
// Persist failed attempt count, error, and link to error detail
|
|
231
|
+
this.db.query("INSERT OR REPLACE INTO workflow_step_attempts (instance_id, step_name, failed_attempts, last_error, last_error_name, last_error_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
232
|
+
.run(this.instanceId, name, attempt + 1, errMsg, errName, errorId, Date.now());
|
|
233
|
+
if (attempt < maxRetries) {
|
|
234
|
+
const d = computeDelay(delayMs, attempt, backoff);
|
|
235
|
+
console.log(` [workflow] step "${name}" attempt ${attempt + 1} failed, retrying in ${d}ms`);
|
|
236
|
+
await interruptibleDelay(d, this.abortSignal);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw lastError;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async sleep(name: string, duration: string | number) {
|
|
245
|
+
await this.checkPaused();
|
|
246
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
247
|
+
this.checkDuplicateStepName(`sleep:${name}`);
|
|
248
|
+
|
|
249
|
+
console.log(` [workflow] sleep: ${name}`);
|
|
250
|
+
return startSpan({
|
|
251
|
+
name: `sleep ${name}`,
|
|
252
|
+
kind: "internal",
|
|
253
|
+
attributes: { "workflow.step.name": name, "workflow.step.type": "sleep", "workflow.instance_id": this.instanceId },
|
|
254
|
+
}, async () => {
|
|
255
|
+
const cached = this.getCachedStep(`sleep:${name}`);
|
|
256
|
+
if (cached) {
|
|
257
|
+
const { until } = JSON.parse(cached.output!) as { until: number };
|
|
258
|
+
const remaining = Math.max(0, until - Date.now());
|
|
259
|
+
if (remaining > 0) {
|
|
260
|
+
await skippableDelay(remaining, this.abortSignal, this.instanceId);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const ms = typeof duration === "number" ? duration : parseDuration(duration);
|
|
266
|
+
if (ms > this.limits.maxSleepMs) {
|
|
267
|
+
throw new Error(`Sleep duration ${ms}ms exceeds maximum of ${this.limits.maxSleepMs}ms`);
|
|
268
|
+
}
|
|
269
|
+
const until = Date.now() + ms;
|
|
270
|
+
this.cacheStep(`sleep:${name}`, { until });
|
|
271
|
+
|
|
272
|
+
if (ms > 0) {
|
|
273
|
+
await skippableDelay(ms, this.abortSignal, this.instanceId);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async sleepUntil(name: string, timestamp: Date | number) {
|
|
279
|
+
await this.checkPaused();
|
|
280
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
281
|
+
this.checkDuplicateStepName(`sleepUntil:${name}`);
|
|
282
|
+
|
|
283
|
+
const ts = typeof timestamp === "number" ? new Date(timestamp) : timestamp;
|
|
284
|
+
|
|
285
|
+
console.log(` [workflow] sleepUntil: ${name}`);
|
|
286
|
+
return startSpan({
|
|
287
|
+
name: `sleepUntil ${name}`,
|
|
288
|
+
kind: "internal",
|
|
289
|
+
attributes: { "workflow.step.name": name, "workflow.step.type": "sleepUntil", "workflow.instance_id": this.instanceId },
|
|
290
|
+
}, async () => {
|
|
291
|
+
const cached = this.getCachedStep(`sleepUntil:${name}`);
|
|
292
|
+
if (cached) {
|
|
293
|
+
const remaining = Math.max(0, ts.getTime() - Date.now());
|
|
294
|
+
if (remaining > 0) {
|
|
295
|
+
await skippableDelay(remaining, this.abortSignal, this.instanceId);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const delay = Math.max(0, ts.getTime() - Date.now());
|
|
301
|
+
if (delay > this.limits.maxSleepMs) {
|
|
302
|
+
throw new Error(`Sleep duration ${delay}ms exceeds maximum of ${this.limits.maxSleepMs}ms`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.cacheStep(`sleepUntil:${name}`, { until: ts.toISOString() });
|
|
306
|
+
|
|
307
|
+
if (delay > 0) {
|
|
308
|
+
await skippableDelay(delay, this.abortSignal, this.instanceId);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async waitForEvent<T = unknown>(name: string, options: { type: string; timeout?: string }): Promise<{ payload: T; timestamp: Date; type: string }> {
|
|
314
|
+
await this.checkPaused();
|
|
315
|
+
if (this.abortSignal.aborted) throw new Error("workflow terminated");
|
|
316
|
+
this.checkStepLimit();
|
|
317
|
+
this.checkDuplicateStepName(`waitForEvent:${name}`);
|
|
318
|
+
|
|
319
|
+
// Validate event type
|
|
320
|
+
if (!EVENT_TYPE_PATTERN.test(options.type)) {
|
|
321
|
+
throw new Error(`Invalid event type "${options.type}". Must be 1-100 characters, only letters, digits, hyphens and underscores.`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
console.log(` [workflow] waitForEvent: ${name} (type: ${options.type})`);
|
|
325
|
+
return startSpan({
|
|
326
|
+
name: `waitForEvent ${name}`,
|
|
327
|
+
kind: "internal",
|
|
328
|
+
attributes: { "workflow.step.name": name, "workflow.step.type": "waitForEvent", "workflow.event.type": options.type, "workflow.instance_id": this.instanceId },
|
|
329
|
+
}, async () => {
|
|
330
|
+
// Check checkpoint
|
|
331
|
+
const cached = this.getCachedStep(`waitForEvent:${name}`);
|
|
332
|
+
if (cached) {
|
|
333
|
+
const parsed = JSON.parse(cached.output!) as { payload: T; timestamp: string; type: string };
|
|
334
|
+
return { payload: parsed.payload, timestamp: new Date(parsed.timestamp), type: parsed.type };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Update status to waiting
|
|
338
|
+
this.db
|
|
339
|
+
.query("UPDATE workflow_instances SET status = 'waiting', updated_at = ? WHERE id = ?")
|
|
340
|
+
.run(Date.now(), this.instanceId);
|
|
341
|
+
|
|
342
|
+
// Check if event already exists in DB
|
|
343
|
+
const existing = this.db
|
|
344
|
+
.query("SELECT payload, created_at FROM workflow_events WHERE instance_id = ? AND event_type = ? ORDER BY id ASC LIMIT 1")
|
|
345
|
+
.get(this.instanceId, options.type) as { payload: string | null; created_at: number } | null;
|
|
346
|
+
|
|
347
|
+
if (existing) {
|
|
348
|
+
this.db
|
|
349
|
+
.query("DELETE FROM workflow_events WHERE instance_id = ? AND event_type = ? ORDER BY id ASC LIMIT 1")
|
|
350
|
+
.run(this.instanceId, options.type);
|
|
351
|
+
this.db
|
|
352
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
353
|
+
.run(Date.now(), this.instanceId);
|
|
354
|
+
const payload = (existing.payload !== null ? JSON.parse(existing.payload) : undefined) as T;
|
|
355
|
+
const event = { payload, timestamp: new Date(existing.created_at), type: options.type };
|
|
356
|
+
this.cacheStep(`waitForEvent:${name}`, event);
|
|
357
|
+
return event;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Wait for event to arrive via sendEvent()
|
|
361
|
+
const timeoutMs = options.timeout ? parseDuration(options.timeout) : this.limits.defaultWaitForEventTimeoutMs;
|
|
362
|
+
if (timeoutMs < this.limits.minWaitForEventTimeoutMs) {
|
|
363
|
+
throw new Error(`waitForEvent timeout ${timeoutMs}ms is below minimum of ${this.limits.minWaitForEventTimeoutMs}ms`);
|
|
364
|
+
}
|
|
365
|
+
if (timeoutMs > this.limits.maxWaitForEventTimeoutMs) {
|
|
366
|
+
throw new Error(`waitForEvent timeout ${timeoutMs}ms exceeds maximum of ${this.limits.maxWaitForEventTimeoutMs}ms`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const result = await new Promise<{ payload: T; timestamp: Date; type: string }>((resolve, reject) => {
|
|
370
|
+
const waiters = getWaitersForInstance(this.instanceId);
|
|
371
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
372
|
+
let abortHandler: (() => void) | undefined;
|
|
373
|
+
|
|
374
|
+
const cleanup = () => {
|
|
375
|
+
waiters.delete(options.type);
|
|
376
|
+
if (timer) clearTimeout(timer);
|
|
377
|
+
if (abortHandler) this.abortSignal.removeEventListener("abort", abortHandler);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
waiters.set(options.type, (payload: unknown) => {
|
|
381
|
+
cleanup();
|
|
382
|
+
resolve({ payload: payload as T, timestamp: new Date(), type: options.type });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
timer = setTimeout(() => {
|
|
386
|
+
cleanup();
|
|
387
|
+
reject(new Error(`waitForEvent timed out after ${options.timeout ?? "24 hours"}`));
|
|
388
|
+
}, timeoutMs);
|
|
389
|
+
|
|
390
|
+
abortHandler = () => {
|
|
391
|
+
cleanup();
|
|
392
|
+
reject(new Error("workflow terminated"));
|
|
393
|
+
};
|
|
394
|
+
this.abortSignal.addEventListener("abort", abortHandler);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Restore running status
|
|
398
|
+
this.db
|
|
399
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
400
|
+
.run(Date.now(), this.instanceId);
|
|
401
|
+
|
|
402
|
+
this.cacheStep(`waitForEvent:${name}`, result);
|
|
403
|
+
return result;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function computeDelay(baseMs: number, attempt: number, backoff: "constant" | "linear" | "exponential"): number {
|
|
409
|
+
switch (backoff) {
|
|
410
|
+
case "constant": return baseMs;
|
|
411
|
+
case "linear": return baseMs * (attempt + 1);
|
|
412
|
+
case "exponential": return baseMs * Math.pow(2, attempt);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function interruptibleDelay(ms: number, abortSignal: AbortSignal): Promise<void> {
|
|
417
|
+
if (ms <= 0) return Promise.resolve();
|
|
418
|
+
if (abortSignal.aborted) return Promise.reject(new Error("workflow terminated"));
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
421
|
+
const abortHandler = () => { cleanup(); reject(new Error("workflow terminated")); };
|
|
422
|
+
const cleanup = () => { clearTimeout(timer); abortSignal.removeEventListener("abort", abortHandler); };
|
|
423
|
+
abortSignal.addEventListener("abort", abortHandler);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Like interruptibleDelay, but also resolves when skipSleep() is called for the instance. */
|
|
428
|
+
function skippableDelay(ms: number, abortSignal: AbortSignal, instanceId: string): Promise<void> {
|
|
429
|
+
if (ms <= 0) return Promise.resolve();
|
|
430
|
+
if (abortSignal.aborted) return Promise.reject(new Error("workflow terminated"));
|
|
431
|
+
return new Promise((resolve, reject) => {
|
|
432
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
433
|
+
const abortHandler = () => { cleanup(); reject(new Error("workflow terminated")); };
|
|
434
|
+
const cleanup = () => {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
abortSignal.removeEventListener("abort", abortHandler);
|
|
437
|
+
sleepResolvers.delete(instanceId);
|
|
438
|
+
};
|
|
439
|
+
sleepResolvers.set(instanceId, () => { cleanup(); resolve(); });
|
|
440
|
+
abortSignal.addEventListener("abort", abortHandler);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Get the event types an instance is currently waiting for (in-memory). */
|
|
445
|
+
export function getWaitingEventTypes(instanceId: string): string[] {
|
|
446
|
+
const waiters = eventWaiters.get(instanceId);
|
|
447
|
+
if (!waiters) return [];
|
|
448
|
+
return Array.from(waiters.keys());
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Check if an instance is currently sleeping (has a registered sleep resolver). */
|
|
452
|
+
export function isInstanceSleeping(instanceId: string): boolean {
|
|
453
|
+
return sleepResolvers.has(instanceId);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function parseDuration(duration: string | number): number {
|
|
457
|
+
if (typeof duration === "number") return duration;
|
|
458
|
+
const match = duration.match(/^(\d+)\s*(ms|milliseconds?|s|seconds?|m|minutes?|h|hours?|d|days?|w|weeks?|months?|y|years?)$/i);
|
|
459
|
+
if (!match) throw new Error(`Invalid duration: "${duration}"`);
|
|
460
|
+
const value = parseInt(match[1]!, 10);
|
|
461
|
+
const unit = match[2]!.toLowerCase();
|
|
462
|
+
if (unit.startsWith("ms") || unit.startsWith("millisecond")) return value;
|
|
463
|
+
if (unit.startsWith("s")) return value * 1000;
|
|
464
|
+
if (unit === "m" || unit.startsWith("minute")) return value * 60_000;
|
|
465
|
+
if (unit.startsWith("h")) return value * 3_600_000;
|
|
466
|
+
if (unit.startsWith("d")) return value * 86_400_000;
|
|
467
|
+
if (unit.startsWith("w")) return value * 7 * 86_400_000;
|
|
468
|
+
if (unit.startsWith("month")) return value * 30 * 86_400_000;
|
|
469
|
+
if (unit.startsWith("y")) return value * 365 * 86_400_000;
|
|
470
|
+
throw new Error(`Invalid duration: "${duration}"`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- Base class ---
|
|
474
|
+
|
|
475
|
+
export class WorkflowEntrypointBase {
|
|
476
|
+
ctx: { env: unknown; waitUntil(p: Promise<unknown>): void };
|
|
477
|
+
env: unknown;
|
|
478
|
+
|
|
479
|
+
constructor(ctx: unknown, env: unknown) {
|
|
480
|
+
this.env = env;
|
|
481
|
+
this.ctx = { env, waitUntil: () => {} };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async run(_event: unknown, _step: unknown): Promise<unknown> {
|
|
485
|
+
throw new Error("run() must be implemented by subclass");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// --- Instance handle ---
|
|
490
|
+
|
|
491
|
+
export class SqliteWorkflowInstance {
|
|
492
|
+
private db: Database;
|
|
493
|
+
private instanceId: string;
|
|
494
|
+
private binding: SqliteWorkflowBinding | null;
|
|
495
|
+
|
|
496
|
+
constructor(db: Database, instanceId: string, binding: SqliteWorkflowBinding | null) {
|
|
497
|
+
this.db = db;
|
|
498
|
+
this.instanceId = instanceId;
|
|
499
|
+
this.binding = binding;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
get id(): string {
|
|
503
|
+
return this.instanceId;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async status(): Promise<{ status: string; output?: unknown; error?: { name: string; message: string } }> {
|
|
507
|
+
const row = this.db
|
|
508
|
+
.query("SELECT status, output, error, error_name FROM workflow_instances WHERE id = ?")
|
|
509
|
+
.get(this.instanceId) as { status: string; output: string | null; error: string | null; error_name: string | null } | null;
|
|
510
|
+
|
|
511
|
+
if (!row) throw new Error(`Workflow instance ${this.instanceId} not found`);
|
|
512
|
+
|
|
513
|
+
const result: { status: string; output?: unknown; error?: { name: string; message: string } } = { status: row.status };
|
|
514
|
+
if (row.output !== null) result.output = JSON.parse(row.output);
|
|
515
|
+
if (row.error !== null) result.error = { name: row.error_name ?? "Error", message: row.error };
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async pause(): Promise<void> {
|
|
520
|
+
this.db
|
|
521
|
+
.query("UPDATE workflow_instances SET status = 'paused', updated_at = ? WHERE id = ? AND status IN ('running', 'waiting')")
|
|
522
|
+
.run(Date.now(), this.instanceId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async resume(): Promise<void> {
|
|
526
|
+
this.db
|
|
527
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ? AND status = 'paused'")
|
|
528
|
+
.run(Date.now(), this.instanceId);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async terminate(): Promise<void> {
|
|
532
|
+
this.db
|
|
533
|
+
.query("UPDATE workflow_instances SET status = 'terminated', updated_at = ? WHERE id = ? AND status IN ('running', 'paused', 'waiting', 'queued')")
|
|
534
|
+
.run(Date.now(), this.instanceId);
|
|
535
|
+
// Abort via global registry so get()-retrieved instances also work
|
|
536
|
+
const ac = abortControllers.get(this.instanceId);
|
|
537
|
+
ac?.abort();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async restart(options?: { fromStep?: string }): Promise<void> {
|
|
541
|
+
if (!this.binding) throw new Error("Cannot restart: instance not associated with a workflow binding. Use the binding's get() method.");
|
|
542
|
+
const cls = this.binding._getClass();
|
|
543
|
+
const env = this.binding._getEnv();
|
|
544
|
+
const db = this.binding._getDb();
|
|
545
|
+
const workflowName = this.binding._getWorkflowName();
|
|
546
|
+
const limits = this.binding._getLimits();
|
|
547
|
+
if (!cls) throw new Error("Cannot restart: workflow class not wired yet");
|
|
548
|
+
|
|
549
|
+
const row = this.db
|
|
550
|
+
.query("SELECT params, created_at FROM workflow_instances WHERE id = ?")
|
|
551
|
+
.get(this.instanceId) as { params: string | null; created_at: number } | null;
|
|
552
|
+
if (!row) throw new Error(`Workflow instance ${this.instanceId} not found`);
|
|
553
|
+
|
|
554
|
+
// Abort existing execution
|
|
555
|
+
const existingAc = abortControllers.get(this.instanceId);
|
|
556
|
+
existingAc?.abort();
|
|
557
|
+
|
|
558
|
+
const abortController = new AbortController();
|
|
559
|
+
abortControllers.set(this.instanceId, abortController);
|
|
560
|
+
|
|
561
|
+
if (options?.fromStep) {
|
|
562
|
+
// Partial restart: find the step and delete it + all subsequent steps
|
|
563
|
+
const step = this.db
|
|
564
|
+
.query("SELECT completed_at FROM workflow_steps WHERE instance_id = ? AND step_name = ?")
|
|
565
|
+
.get(this.instanceId, options.fromStep) as { completed_at: number } | null;
|
|
566
|
+
if (!step) throw new Error(`Step "${options.fromStep}" not found in workflow instance ${this.instanceId}`);
|
|
567
|
+
this.db
|
|
568
|
+
.query("DELETE FROM workflow_steps WHERE instance_id = ? AND completed_at >= ?")
|
|
569
|
+
.run(this.instanceId, step.completed_at);
|
|
570
|
+
} else {
|
|
571
|
+
// Full restart: clear all cached steps
|
|
572
|
+
this.db.query("DELETE FROM workflow_steps WHERE instance_id = ?").run(this.instanceId);
|
|
573
|
+
}
|
|
574
|
+
// Clear step attempt counters
|
|
575
|
+
this.db.query("DELETE FROM workflow_step_attempts WHERE instance_id = ?").run(this.instanceId);
|
|
576
|
+
|
|
577
|
+
this.db
|
|
578
|
+
.query("UPDATE workflow_instances SET status = 'running', output = NULL, error = NULL, error_name = NULL, updated_at = ? WHERE id = ?")
|
|
579
|
+
.run(Date.now(), this.instanceId);
|
|
580
|
+
|
|
581
|
+
const params = row.params !== null ? JSON.parse(row.params) : {};
|
|
582
|
+
SqliteWorkflowBinding.executeWorkflow(db, this.instanceId, cls, env, params, abortController, workflowName, limits, row.created_at);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async skipSleep(): Promise<void> {
|
|
586
|
+
const resolver = sleepResolvers.get(this.instanceId);
|
|
587
|
+
if (resolver) {
|
|
588
|
+
resolver();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async sendEvent(event: { type: string; payload?: unknown }): Promise<void> {
|
|
593
|
+
// Validate event type
|
|
594
|
+
if (!EVENT_TYPE_PATTERN.test(event.type)) {
|
|
595
|
+
throw new Error(`Invalid event type "${event.type}". Must be 1-100 characters, only letters, digits, hyphens and underscores.`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Validate instance state — cannot send events to finished instances
|
|
599
|
+
const row = this.db
|
|
600
|
+
.query("SELECT status FROM workflow_instances WHERE id = ?")
|
|
601
|
+
.get(this.instanceId) as { status: string } | null;
|
|
602
|
+
if (row && ["complete", "errored", "terminated"].includes(row.status)) {
|
|
603
|
+
throw new Error(`Cannot send event to workflow instance "${this.instanceId}" with status "${row.status}"`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Check if there's a waiter for this event type in-memory
|
|
607
|
+
const waiters = eventWaiters.get(this.instanceId);
|
|
608
|
+
const resolver = waiters?.get(event.type);
|
|
609
|
+
|
|
610
|
+
if (resolver) {
|
|
611
|
+
resolver(event.payload);
|
|
612
|
+
} else {
|
|
613
|
+
this.db
|
|
614
|
+
.query("INSERT INTO workflow_events (instance_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)")
|
|
615
|
+
.run(this.instanceId, event.type, event.payload !== undefined ? JSON.stringify(event.payload) : null, Date.now());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- Binding ---
|
|
621
|
+
|
|
622
|
+
export class SqliteWorkflowBinding {
|
|
623
|
+
private db: Database;
|
|
624
|
+
private workflowName: string;
|
|
625
|
+
private className: string;
|
|
626
|
+
private _class?: new (ctx: unknown, env: unknown) => WorkflowEntrypointBase;
|
|
627
|
+
private _env?: unknown;
|
|
628
|
+
private counter = 0;
|
|
629
|
+
private limits: Required<WorkflowLimits>;
|
|
630
|
+
|
|
631
|
+
constructor(db: Database, workflowName: string, className: string, limits?: WorkflowLimits) {
|
|
632
|
+
this.db = db;
|
|
633
|
+
this.workflowName = workflowName;
|
|
634
|
+
this.className = className;
|
|
635
|
+
this.limits = { ...WORKFLOW_DEFAULTS, ...limits };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_setClass(cls: new (ctx: unknown, env: unknown) => WorkflowEntrypointBase, env: unknown) {
|
|
639
|
+
this._class = cls;
|
|
640
|
+
this._env = env;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
_getClass() { return this._class; }
|
|
644
|
+
_getEnv() { return this._env; }
|
|
645
|
+
_getDb() { return this.db; }
|
|
646
|
+
_getWorkflowName() { return this.workflowName; }
|
|
647
|
+
_getLimits() { return this.limits; }
|
|
648
|
+
|
|
649
|
+
/** Abort all running/queued/waiting instances for this workflow */
|
|
650
|
+
abortRunning(): void {
|
|
651
|
+
const rows = this.db.query(
|
|
652
|
+
"SELECT id FROM workflow_instances WHERE workflow_name = ? AND status IN ('running','queued','waiting')"
|
|
653
|
+
).all(this.workflowName) as { id: string }[];
|
|
654
|
+
for (const { id } of rows) {
|
|
655
|
+
abortControllers.get(id)?.abort();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private cleanupRetentionExpired(): void {
|
|
660
|
+
if (this.limits.maxRetentionMs <= 0) return;
|
|
661
|
+
const cutoff = Date.now() - this.limits.maxRetentionMs;
|
|
662
|
+
// Clean up step attempts for instances being deleted
|
|
663
|
+
const expiredIds = this.db
|
|
664
|
+
.query("SELECT id FROM workflow_instances WHERE workflow_name = ? AND status IN ('complete', 'errored') AND updated_at < ?")
|
|
665
|
+
.all(this.workflowName, cutoff) as { id: string }[];
|
|
666
|
+
for (const { id } of expiredIds) {
|
|
667
|
+
this.db.query("DELETE FROM workflow_step_attempts WHERE instance_id = ?").run(id);
|
|
668
|
+
}
|
|
669
|
+
this.db
|
|
670
|
+
.query("DELETE FROM workflow_instances WHERE workflow_name = ? AND status IN ('complete', 'errored') AND updated_at < ?")
|
|
671
|
+
.run(this.workflowName, cutoff);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private countRunning(): number {
|
|
675
|
+
const row = this.db
|
|
676
|
+
.query("SELECT COUNT(*) as cnt FROM workflow_instances WHERE workflow_name = ? AND status IN ('running', 'waiting')")
|
|
677
|
+
.get(this.workflowName) as { cnt: number };
|
|
678
|
+
return row.cnt;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async create(options?: { id?: string; params?: unknown; retention?: string }): Promise<SqliteWorkflowInstance> {
|
|
682
|
+
if (!this._class) throw new Error("Workflow class not wired yet");
|
|
683
|
+
|
|
684
|
+
this.cleanupRetentionExpired();
|
|
685
|
+
|
|
686
|
+
const id = options?.id ?? `wf-${++this.counter}-${Date.now()}`;
|
|
687
|
+
if (id.length > this.limits.maxInstanceIdLength) {
|
|
688
|
+
throw new Error(`Workflow instance ID must be ${this.limits.maxInstanceIdLength} characters or fewer, got ${id.length}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Check for duplicate ID
|
|
692
|
+
const existing = this.db.query("SELECT id FROM workflow_instances WHERE id = ?").get(id);
|
|
693
|
+
if (existing) throw new Error(`Workflow instance with ID "${id}" already exists`);
|
|
694
|
+
|
|
695
|
+
const params = options?.params ?? {};
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
|
|
698
|
+
// Check concurrency
|
|
699
|
+
const isQueued = this.limits.maxConcurrentInstances !== Infinity && this.countRunning() >= this.limits.maxConcurrentInstances;
|
|
700
|
+
const initialStatus = isQueued ? "queued" : "running";
|
|
701
|
+
|
|
702
|
+
this.db
|
|
703
|
+
.query(
|
|
704
|
+
"INSERT INTO workflow_instances (id, workflow_name, class_name, params, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
705
|
+
)
|
|
706
|
+
.run(id, this.workflowName, this.className, JSON.stringify(params), initialStatus, now, now);
|
|
707
|
+
|
|
708
|
+
const abortController = new AbortController();
|
|
709
|
+
abortControllers.set(id, abortController);
|
|
710
|
+
const handle = new SqliteWorkflowInstance(this.db, id, this);
|
|
711
|
+
|
|
712
|
+
if (!isQueued) {
|
|
713
|
+
console.log(`[workflow] started ${id}`);
|
|
714
|
+
SqliteWorkflowBinding.executeWorkflow(this.db, id, this._class, this._env, params, abortController, this.workflowName, this.limits, now);
|
|
715
|
+
} else {
|
|
716
|
+
console.log(`[workflow] queued ${id} (concurrency limit: ${this.limits.maxConcurrentInstances})`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return handle;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async createBatch(batch: { id?: string; params?: unknown }[]): Promise<SqliteWorkflowInstance[]> {
|
|
723
|
+
if (batch.length > this.limits.maxBatchSize) {
|
|
724
|
+
throw new Error(`Batch size ${batch.length} exceeds maximum of ${this.limits.maxBatchSize}`);
|
|
725
|
+
}
|
|
726
|
+
const results: SqliteWorkflowInstance[] = [];
|
|
727
|
+
for (const item of batch) {
|
|
728
|
+
const instance = await this.create({ id: item.id, params: item.params });
|
|
729
|
+
results.push(instance);
|
|
730
|
+
}
|
|
731
|
+
return results;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async get(id: string): Promise<SqliteWorkflowInstance> {
|
|
735
|
+
const row = this.db
|
|
736
|
+
.query("SELECT id FROM workflow_instances WHERE id = ?")
|
|
737
|
+
.get(id) as { id: string } | null;
|
|
738
|
+
if (!row) throw new Error(`Workflow instance ${id} not found`);
|
|
739
|
+
return new SqliteWorkflowInstance(this.db, id, this);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Resume any workflow instances that were running/waiting when the process last exited. */
|
|
743
|
+
resumeInterrupted(): void {
|
|
744
|
+
if (!this._class) return;
|
|
745
|
+
|
|
746
|
+
const rows = this.db
|
|
747
|
+
.query("SELECT id, params, created_at FROM workflow_instances WHERE workflow_name = ? AND status IN ('running', 'waiting')")
|
|
748
|
+
.all(this.workflowName) as { id: string; params: string | null; created_at: number }[];
|
|
749
|
+
|
|
750
|
+
for (const row of rows) {
|
|
751
|
+
const abortController = new AbortController();
|
|
752
|
+
abortControllers.set(row.id, abortController);
|
|
753
|
+
const params = row.params !== null ? JSON.parse(row.params) : {};
|
|
754
|
+
console.log(`[workflow] resuming interrupted instance ${row.id}`);
|
|
755
|
+
// Reset to running before re-executing (waiting status needs to restart from last checkpoint)
|
|
756
|
+
this.db
|
|
757
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
758
|
+
.run(Date.now(), row.id);
|
|
759
|
+
SqliteWorkflowBinding.executeWorkflow(this.db, row.id, this._class, this._env, params, abortController, this.workflowName, this.limits, row.created_at);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Try to start any queued instances for this workflow (called after an instance completes). */
|
|
764
|
+
private tryStartQueued(): void {
|
|
765
|
+
if (this.limits.maxConcurrentInstances === Infinity) return;
|
|
766
|
+
if (!this._class) return;
|
|
767
|
+
|
|
768
|
+
while (this.countRunning() < this.limits.maxConcurrentInstances) {
|
|
769
|
+
const queued = this.db
|
|
770
|
+
.query("SELECT id, params, created_at FROM workflow_instances WHERE workflow_name = ? AND status = 'queued' ORDER BY created_at ASC LIMIT 1")
|
|
771
|
+
.get(this.workflowName) as { id: string; params: string | null; created_at: number } | null;
|
|
772
|
+
if (!queued) break;
|
|
773
|
+
|
|
774
|
+
this.db
|
|
775
|
+
.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
776
|
+
.run(Date.now(), queued.id);
|
|
777
|
+
|
|
778
|
+
const abortController = new AbortController();
|
|
779
|
+
abortControllers.set(queued.id, abortController);
|
|
780
|
+
const params = queued.params !== null ? JSON.parse(queued.params) : {};
|
|
781
|
+
console.log(`[workflow] starting queued instance ${queued.id}`);
|
|
782
|
+
SqliteWorkflowBinding.executeWorkflow(this.db, queued.id, this._class, this._env, params, abortController, this.workflowName, this.limits, queued.created_at);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
static executeWorkflow(
|
|
787
|
+
db: Database,
|
|
788
|
+
id: string,
|
|
789
|
+
workflowClass: new (ctx: unknown, env: unknown) => WorkflowEntrypointBase,
|
|
790
|
+
env: unknown,
|
|
791
|
+
params: unknown,
|
|
792
|
+
abortController: AbortController,
|
|
793
|
+
workflowName?: string,
|
|
794
|
+
limits?: Required<WorkflowLimits>,
|
|
795
|
+
createdAt?: number,
|
|
796
|
+
): void {
|
|
797
|
+
const resolvedLimits = limits ?? WORKFLOW_DEFAULTS;
|
|
798
|
+
const instance = new workflowClass({}, env);
|
|
799
|
+
const step = new WorkflowStepImpl(abortController.signal, db, id, resolvedLimits);
|
|
800
|
+
const event = { payload: params, timestamp: new Date(createdAt ?? Date.now()), instanceId: id };
|
|
801
|
+
|
|
802
|
+
(async () => {
|
|
803
|
+
let workflowTraceId: string | undefined;
|
|
804
|
+
try {
|
|
805
|
+
const result = await startSpan({
|
|
806
|
+
name: `workflow ${workflowName ?? "run"}`,
|
|
807
|
+
kind: "server",
|
|
808
|
+
attributes: { "workflow.name": workflowName ?? "unknown", "workflow.instance_id": id },
|
|
809
|
+
workerName: workflowName,
|
|
810
|
+
newTrace: true,
|
|
811
|
+
}, () => {
|
|
812
|
+
workflowTraceId = getActiveContext()?.traceId;
|
|
813
|
+
return instance.run(event, step);
|
|
814
|
+
});
|
|
815
|
+
if (abortController.signal.aborted) return;
|
|
816
|
+
db.query("UPDATE workflow_instances SET status = 'complete', output = ?, updated_at = ? WHERE id = ?")
|
|
817
|
+
.run(JSON.stringify(result), Date.now(), id);
|
|
818
|
+
// Clean up step attempts on successful completion
|
|
819
|
+
db.query("DELETE FROM workflow_step_attempts WHERE instance_id = ?").run(id);
|
|
820
|
+
console.log(`[workflow] completed ${id}:`, result);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
const errorName = err instanceof Error ? (err.name || err.constructor.name || "Error") : "Error";
|
|
823
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
824
|
+
|
|
825
|
+
if (abortController.signal.aborted) {
|
|
826
|
+
// Terminated — store error info but keep "terminated" status
|
|
827
|
+
db.query("UPDATE workflow_instances SET error = ?, error_name = ?, updated_at = ? WHERE id = ?")
|
|
828
|
+
.run(message, errorName, Date.now(), id);
|
|
829
|
+
persistError(err, "workflow", workflowName, workflowTraceId);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
db.query("UPDATE workflow_instances SET status = 'errored', error = ?, error_name = ?, updated_at = ? WHERE id = ?")
|
|
833
|
+
.run(message, errorName, Date.now(), id);
|
|
834
|
+
console.error(`[workflow] failed ${id}:`, err);
|
|
835
|
+
persistError(err, "workflow", workflowName, workflowTraceId);
|
|
836
|
+
} finally {
|
|
837
|
+
eventWaiters.delete(id);
|
|
838
|
+
abortControllers.delete(id);
|
|
839
|
+
// Try to start queued instances if we have a workflow name to look up the binding
|
|
840
|
+
if (workflowName) {
|
|
841
|
+
// Dequeue next instance for same workflow
|
|
842
|
+
const queued = db
|
|
843
|
+
.query("SELECT id, params, created_at FROM workflow_instances WHERE workflow_name = ? AND status = 'queued' ORDER BY created_at ASC LIMIT 1")
|
|
844
|
+
.get(workflowName) as { id: string; params: string | null; created_at: number } | null;
|
|
845
|
+
if (queued) {
|
|
846
|
+
db.query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ?")
|
|
847
|
+
.run(Date.now(), queued.id);
|
|
848
|
+
const qParams = queued.params !== null ? JSON.parse(queued.params) : {};
|
|
849
|
+
const ac = new AbortController();
|
|
850
|
+
abortControllers.set(queued.id, ac);
|
|
851
|
+
console.log(`[workflow] starting queued instance ${queued.id}`);
|
|
852
|
+
SqliteWorkflowBinding.executeWorkflow(db, queued.id, workflowClass, env, qParams, ac, workflowName, resolvedLimits, queued.created_at);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
})();
|
|
857
|
+
}
|
|
858
|
+
}
|