@omg-dev/server 0.4.24

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.
@@ -0,0 +1,18 @@
1
+ declare module "web-push" {
2
+ export interface PushSubscription {
3
+ endpoint: string
4
+ keys: {
5
+ p256dh: string
6
+ auth: string
7
+ }
8
+ }
9
+
10
+ export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void
11
+ export function sendNotification(subscription: PushSubscription, payload?: string): Promise<unknown>
12
+
13
+ const webPush: {
14
+ setVapidDetails: typeof setVapidDetails
15
+ sendNotification: typeof sendNotification
16
+ }
17
+ export default webPush
18
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, beforeAll } from "vitest"
2
+ import {
3
+ registerWorkflows,
4
+ clearWorkflows,
5
+ startWorkflow,
6
+ listWorkflows,
7
+ devInspectWorkflowRuns,
8
+ workflowServiceName,
9
+ type StepContext,
10
+ } from "./workflows.ts"
11
+
12
+ // Force dev mode before the module's first mode check.
13
+ beforeAll(() => {
14
+ process.env.VIBES_MODE = "dev"
15
+ })
16
+
17
+ function entry(name: string, fn: (...args: any[]) => any) {
18
+ return {
19
+ name,
20
+ handler: `test.${name}`,
21
+ module: "/virtual/test.ts",
22
+ exportName: name,
23
+ mod: { [name]: fn },
24
+ }
25
+ }
26
+
27
+ async function waitFor(pred: () => boolean, ms = 2000): Promise<void> {
28
+ const start = Date.now()
29
+ while (!pred()) {
30
+ if (Date.now() - start > ms) throw new Error("waitFor timeout")
31
+ await new Promise((r) => setTimeout(r, 10))
32
+ }
33
+ }
34
+
35
+ describe("dev workflow engine", () => {
36
+ it("runs steps in order and records the run", async () => {
37
+ clearWorkflows()
38
+ const order: string[] = []
39
+ await registerWorkflows([
40
+ entry("happy", async (step: StepContext, payload: { n: number }) => {
41
+ const a = await step.run("double", () => payload.n * 2)
42
+ order.push("after-double")
43
+ await step.sleep("tiny-nap", 5)
44
+ const b = await step.run("add-one", () => a + 1)
45
+ return b
46
+ }),
47
+ ])
48
+
49
+ const { runId } = await startWorkflow("happy", { n: 20 })
50
+ expect(runId).toMatch(/^run_dev_/)
51
+
52
+ await waitFor(() => {
53
+ const run = devInspectWorkflowRuns().find((r) => r.id === runId)
54
+ return run?.status === "done"
55
+ })
56
+ const run = devInspectWorkflowRuns().find((r) => r.id === runId)!
57
+ expect(run.result).toBe(41)
58
+ expect(run.steps.map((s) => s.name)).toEqual(["double", "tiny-nap", "add-one"])
59
+ expect(run.steps.every((s) => s.status === "done")).toBe(true)
60
+ expect(order).toEqual(["after-double"])
61
+ })
62
+
63
+ it("marks the run failed when a step throws", async () => {
64
+ clearWorkflows()
65
+ await registerWorkflows([
66
+ entry("boom", async (step: StepContext) => {
67
+ await step.run("explode", () => {
68
+ throw new Error("kaboom")
69
+ })
70
+ }),
71
+ ])
72
+ const { runId } = await startWorkflow("boom")
73
+ await waitFor(() => devInspectWorkflowRuns().find((r) => r.id === runId)?.status === "failed")
74
+ const run = devInspectWorkflowRuns().find((r) => r.id === runId)!
75
+ expect(run.error).toContain("kaboom")
76
+ expect(run.steps[0]!.status).toBe("failed")
77
+ })
78
+
79
+ it("dedupes starts with the same idempotency id", async () => {
80
+ clearWorkflows()
81
+ let calls = 0
82
+ await registerWorkflows([
83
+ entry("once", async (step: StepContext) => {
84
+ await step.run("count", () => {
85
+ calls++
86
+ })
87
+ }),
88
+ ])
89
+ const a = await startWorkflow("once", null, { id: "same-key" })
90
+ const b = await startWorkflow("once", null, { id: "same-key" })
91
+ expect(b.runId).toBe(a.runId)
92
+ await waitFor(() => devInspectWorkflowRuns().find((r) => r.id === a.runId)?.status === "done")
93
+ expect(calls).toBe(1)
94
+ })
95
+
96
+ it("throws on unknown workflow names", async () => {
97
+ clearWorkflows()
98
+ await registerWorkflows([])
99
+ await expect(startWorkflow("nope")).rejects.toThrow(/unknown workflow/)
100
+ })
101
+
102
+ it("throws on duplicate workflow names at registration", async () => {
103
+ clearWorkflows()
104
+ await expect(
105
+ registerWorkflows([entry("dup", async () => {}), { ...entry("dup", async () => {}), handler: "other.dup" }]),
106
+ ).rejects.toThrow(/duplicate workflow name/)
107
+ })
108
+
109
+ it("rejects oversized payloads", async () => {
110
+ clearWorkflows()
111
+ await registerWorkflows([entry("big", async () => {})])
112
+ await expect(startWorkflow("big", { blob: "x".repeat(70 * 1024) })).rejects.toThrow(/exceeds/)
113
+ })
114
+
115
+ it("lists registered workflows", async () => {
116
+ clearWorkflows()
117
+ await registerWorkflows([entry("wf-one", async () => {})])
118
+ expect(listWorkflows()).toEqual([{ name: "wf-one", handler: "test.wf-one" }])
119
+ })
120
+ })
121
+
122
+ describe("workflowServiceName", () => {
123
+ it("slug-scopes and underscores dashes (injective on the slug alphabet)", () => {
124
+ expect(workflowServiceName("my-cool-app")).toBe("wf_my_cool_app")
125
+ expect(workflowServiceName("plain")).toBe("wf_plain")
126
+ })
127
+ })
@@ -0,0 +1,438 @@
1
+ // Durable workflows for vibes apps.
2
+ //
3
+ // User-facing API:
4
+ // import { workflow, startWorkflow } from "@omg-dev/server"
5
+ //
6
+ // export const onboarding = workflow("onboarding", async (step, payload) => {
7
+ // const profile = await step.run("create-profile", async () => { ... })
8
+ // await step.sleep("welcome-delay", 24 * 60 * 60 * 1000)
9
+ // await step.run("send-welcome", async () => { ... })
10
+ // return { profileId: profile.id }
11
+ // })
12
+ //
13
+ // const { runId } = await startWorkflow("onboarding", { userId }, { id: `onb-${userId}` })
14
+ //
15
+ // The determinism contract taught to app authors is exactly one rule:
16
+ // SIDE EFFECTS GO INSIDE step.run(). Code between steps may re-execute on
17
+ // replay and must be cheap + idempotent-by-construction.
18
+ //
19
+ // Runtime modes (same selector as triggers.ts):
20
+ // - VIBES_MODE=dev: in-process engine. step.run executes immediately,
21
+ // step.sleep is a setTimeout. NO durability — a dev-server restart loses
22
+ // in-flight runs. Runs are recorded in a bounded ring surfaced via
23
+ // /_vibes/inspect/workflows for the dashboard's draft-mode Inspect.
24
+ // - prod: the Restate engine. Workflow fns are exposed as handlers of a
25
+ // slug-scoped Restate service mounted at POST /_vibes/workflow/* on this
26
+ // server; the self-hosted Restate server (control-plane box) journals
27
+ // every step/sleep and re-invokes through DeployProxy (which wakes a
28
+ // slept sandbox) until the run completes. step.run/step.sleep map to
29
+ // ctx.run/ctx.sleep — results are replayed from the journal, never
30
+ // re-executed. startWorkflow() POSTs to the in-VM agent's
31
+ // /_workflow/start, which forwards to the orchestrator → Restate
32
+ // ingress (apps never hold Restate addresses or credentials).
33
+ //
34
+ // The engine boundary is deliberately tiny (see WorkflowEngine in
35
+ // apps/infra/WORKFLOWS.md): a Temporal or own-sqld engine can replace
36
+ // Restate behind the same workflow()/step API without user-code changes.
37
+
38
+ import path from "node:path"
39
+ import fs from "node:fs"
40
+ import { ctxStore, type VibesCtx } from "./ctx.ts"
41
+
42
+ // ── Public API ───────────────────────────────────────────────────────────────
43
+
44
+ export interface StepContext {
45
+ /**
46
+ * Execute a side-effecting step durably. The result is journaled: on
47
+ * replay the recorded result is returned without re-executing `fn`.
48
+ * `name` must be a stable string (it keys the journal entry on some
49
+ * engines and labels the step everywhere).
50
+ */
51
+ run<T>(name: string, fn: () => T | Promise<T>): Promise<T>
52
+ /**
53
+ * Durable sleep. In prod the VM can be reaped while sleeping; the engine
54
+ * re-invokes (and wakes) the app when the timer fires. `name` labels the
55
+ * wait for observability.
56
+ */
57
+ sleep(name: string, ms: number): Promise<void>
58
+ }
59
+
60
+ export type WorkflowFn<P = any, R = unknown> = (
61
+ step: StepContext,
62
+ payload: P,
63
+ ) => Promise<R>
64
+
65
+ /**
66
+ * Declare a durable workflow. `name` must be a string literal — the
67
+ * build-time scanner persists it so the orchestrator can register the
68
+ * workflow endpoint with the engine. Returns the function unchanged so it
69
+ * can be invoked directly in tests.
70
+ */
71
+ export function workflow<P = any, R = unknown>(name: string, fn: WorkflowFn<P, R>): WorkflowFn<P, R> {
72
+ void name // registration happens at boot from .vibes/workflows.json
73
+ return fn
74
+ }
75
+
76
+ export interface StartWorkflowOptions {
77
+ /**
78
+ * Idempotency key. Two starts with the same id never create two runs —
79
+ * the second returns the first run's id. Omit for fire-every-time.
80
+ */
81
+ id?: string
82
+ }
83
+
84
+ /**
85
+ * Start a workflow run by name. Fire-and-forget: resolves with the run id
86
+ * as soon as the engine has durably accepted the start, not when the run
87
+ * completes. Payload is JSON-serialized, capped at 64 KB (same as emit()).
88
+ */
89
+ export async function startWorkflow(
90
+ name: string,
91
+ payload: unknown = null,
92
+ opts: StartWorkflowOptions = {},
93
+ ): Promise<{ runId: string }> {
94
+ if (!name || typeof name !== "string") {
95
+ throw new Error("startWorkflow: name required")
96
+ }
97
+ const payloadStr = JSON.stringify(payload ?? null)
98
+ if (payloadStr.length > MAX_WORKFLOW_PAYLOAD_BYTES) {
99
+ throw new Error(`startWorkflow("${name}"): payload exceeds ${MAX_WORKFLOW_PAYLOAD_BYTES} bytes`)
100
+ }
101
+ if (workflowMode() === "dev") {
102
+ return startInProcess(name, payload, opts)
103
+ }
104
+ return startViaAgent(name, payloadStr, opts)
105
+ }
106
+
107
+ // ── Registry ─────────────────────────────────────────────────────────────────
108
+
109
+ export interface WorkflowEntry {
110
+ /** Workflow name — the literal first arg of workflow(). */
111
+ name: string
112
+ /** Dispatch identifier "<file-basename>.<exportName>" (parity with triggers). */
113
+ handler: string
114
+ module: string
115
+ exportName: string
116
+ mod?: Record<string, unknown> // preloaded for prod bundles
117
+ }
118
+
119
+ interface ResolvedWorkflow {
120
+ entry: WorkflowEntry
121
+ fn: WorkflowFn
122
+ }
123
+
124
+ const workflowRegistry = new Map<string, ResolvedWorkflow>() // name → resolved
125
+
126
+ export function clearWorkflows(): void {
127
+ workflowRegistry.clear()
128
+ }
129
+
130
+ /** Load workflow entries from .vibes/workflows.json (dev boot path). */
131
+ export async function loadWorkflowsFromFile(root: string): Promise<WorkflowEntry[]> {
132
+ const p = path.join(root, ".vibes", "workflows.json")
133
+ if (!fs.existsSync(p)) return []
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8")) as WorkflowEntry[]
136
+ return Array.isArray(parsed) ? parsed : []
137
+ } catch (err) {
138
+ console.error("[vibes:workflows] failed to read workflows.json:", err)
139
+ return []
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Populate the registry. Resolves each workflow fn from the preloaded `mod`
145
+ * (prod bundle) or via dynamic import (dev). Idempotent — called at boot
146
+ * and on HMR reload.
147
+ */
148
+ export async function registerWorkflows(entries: WorkflowEntry[]): Promise<void> {
149
+ clearWorkflows()
150
+ for (const e of entries) {
151
+ let fn: any
152
+ if (e.mod) {
153
+ fn = e.mod[e.exportName]
154
+ } else {
155
+ try {
156
+ const mod = await import(e.module)
157
+ fn = mod[e.exportName]
158
+ } catch (err) {
159
+ console.error(`[vibes:workflows] import ${e.module}#${e.exportName} failed:`, err)
160
+ continue
161
+ }
162
+ }
163
+ if (typeof fn !== "function") {
164
+ console.error(`[vibes:workflows] ${e.module}#${e.exportName} is not a function — skipping`)
165
+ continue
166
+ }
167
+ if (workflowRegistry.has(e.name)) {
168
+ // Duplicate workflow names would silently shadow each other in the
169
+ // engine — fail loud (matches the repo's no-silent-fallback rule).
170
+ throw new Error(
171
+ `[vibes:workflows] duplicate workflow name "${e.name}" (${e.handler} vs ${workflowRegistry.get(e.name)!.entry.handler})`,
172
+ )
173
+ }
174
+ workflowRegistry.set(e.name, { entry: e, fn })
175
+ }
176
+ if (entries.length > 0) {
177
+ console.log(`[vibes:workflows] registered ${workflowRegistry.size} workflow(s) — mode=${workflowMode()}`)
178
+ }
179
+ }
180
+
181
+ export function listWorkflows(): Array<{ name: string; handler: string }> {
182
+ return Array.from(workflowRegistry.values()).map(({ entry }) => ({
183
+ name: entry.name,
184
+ handler: entry.handler,
185
+ }))
186
+ }
187
+
188
+ const MAX_WORKFLOW_PAYLOAD_BYTES = 64 * 1024
189
+
190
+ // ── Mode detection ───────────────────────────────────────────────────────────
191
+ // Separate copy of vibesMode() (triggers.ts keeps its own private) — same
192
+ // contract: VIBES_MODE=dev set by the vite-plugin, anything else is prod.
193
+
194
+ let _mode: "dev" | "prod" | null = null
195
+ function workflowMode(): "dev" | "prod" {
196
+ if (_mode) return _mode
197
+ const env = (typeof process !== "undefined" ? process.env?.VIBES_MODE : undefined) ?? ""
198
+ _mode = env === "dev" ? "dev" : "prod"
199
+ return _mode
200
+ }
201
+
202
+ // ── Dev engine ───────────────────────────────────────────────────────────────
203
+ // Immediate in-process execution with run/step bookkeeping for Inspect.
204
+ // Deliberately NOT durable: dev sandboxes are ephemeral and HMR reloads
205
+ // would invalidate any journal anyway. The contract (side effects in steps)
206
+ // is still exercised because the same fn runs unchanged in prod.
207
+
208
+ interface DevWorkflowRun {
209
+ id: string
210
+ name: string
211
+ status: "running" | "done" | "failed"
212
+ payload: unknown
213
+ result?: unknown
214
+ error?: string
215
+ steps: Array<{
216
+ name: string
217
+ kind: "run" | "sleep"
218
+ status: "running" | "done" | "failed"
219
+ startedAt: number
220
+ doneAt?: number
221
+ }>
222
+ createdAt: number
223
+ doneAt?: number
224
+ }
225
+
226
+ const DEV_RUN_RING = 200
227
+ const recentDevRuns: DevWorkflowRun[] = []
228
+ // Idempotency: id → runId for the dev engine.
229
+ const devIdempotency = new Map<string, string>()
230
+
231
+ function pushBoundedRun(run: DevWorkflowRun): void {
232
+ recentDevRuns.unshift(run)
233
+ if (recentDevRuns.length > DEV_RUN_RING) recentDevRuns.length = DEV_RUN_RING
234
+ }
235
+
236
+ function devId(): string {
237
+ return `run_dev_${Date.now()}_${Math.floor(Math.random() * 4096).toString(16)}`
238
+ }
239
+
240
+ function startInProcess(
241
+ name: string,
242
+ payload: unknown,
243
+ opts: StartWorkflowOptions,
244
+ ): { runId: string } {
245
+ const resolved = workflowRegistry.get(name)
246
+ if (!resolved) {
247
+ throw new Error(
248
+ `startWorkflow("${name}"): unknown workflow — declare it as ` +
249
+ `\`export const x = workflow("${name}", async (step, payload) => …)\` in functions/`,
250
+ )
251
+ }
252
+ if (opts.id) {
253
+ const existing = devIdempotency.get(opts.id)
254
+ if (existing) return { runId: existing }
255
+ }
256
+ const run: DevWorkflowRun = {
257
+ id: devId(),
258
+ name,
259
+ status: "running",
260
+ payload,
261
+ steps: [],
262
+ createdAt: Date.now(),
263
+ }
264
+ if (opts.id) devIdempotency.set(opts.id, run.id)
265
+ pushBoundedRun(run)
266
+
267
+ const step: StepContext = {
268
+ async run<T>(stepName: string, fn: () => T | Promise<T>): Promise<T> {
269
+ const s = { name: stepName, kind: "run" as const, status: "running" as const, startedAt: Date.now() }
270
+ run.steps.push(s)
271
+ try {
272
+ const out = await fn()
273
+ Object.assign(s, { status: "done", doneAt: Date.now() })
274
+ return out
275
+ } catch (err) {
276
+ Object.assign(s, { status: "failed", doneAt: Date.now() })
277
+ throw err
278
+ }
279
+ },
280
+ async sleep(stepName: string, ms: number): Promise<void> {
281
+ const s = { name: stepName, kind: "sleep" as const, status: "running" as const, startedAt: Date.now() }
282
+ run.steps.push(s)
283
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, ms)))
284
+ Object.assign(s, { status: "done", doneAt: Date.now() })
285
+ },
286
+ }
287
+
288
+ // Fire-and-forget like prod: startWorkflow resolves immediately; the run
289
+ // continues in the background under a system ctx (no user identity, same
290
+ // posture as trigger dispatch).
291
+ void ctxStore.run({ userId: null, system: true } as VibesCtx, async () => {
292
+ try {
293
+ const result = await resolved.fn(step, payload)
294
+ run.status = "done"
295
+ run.result = result
296
+ run.doneAt = Date.now()
297
+ } catch (err) {
298
+ run.status = "failed"
299
+ run.error = err instanceof Error ? err.message : String(err)
300
+ run.doneAt = Date.now()
301
+ console.error(`[vibes:workflows] dev run ${name} (${run.id}) failed:`, err)
302
+ }
303
+ })
304
+
305
+ return { runId: run.id }
306
+ }
307
+
308
+ /** Dev Inspect accessor — mirrors devInspectDeliveries() in triggers.ts. */
309
+ export function devInspectWorkflowRuns(): DevWorkflowRun[] {
310
+ return recentDevRuns
311
+ }
312
+
313
+ // ── Prod engine: Restate ─────────────────────────────────────────────────────
314
+ //
315
+ // The app side is a Restate SDK *fetch* endpoint (request-response mode —
316
+ // works over HTTP/1.1 through CF/DeployProxy, no h2c anywhere) serving one
317
+ // slug-scoped service whose handlers are the registered workflows. The
318
+ // Restate server signs every request with our control-plane identity key;
319
+ // unsigned/foreign requests are rejected, which matters because this is
320
+ // mounted on the app's PUBLIC url.
321
+ //
322
+ // Service naming: `wf_` + slug with `-` → `_`. Slugs are [a-z0-9-] so the
323
+ // mapping is injective; the orchestrator computes the same name when
324
+ // registering the deployment and when starting runs.
325
+
326
+ // Production public key of the control-plane Restate server (derived from
327
+ // /etc/restate/request-identity.pem — see apps/infra/WORKFLOWS.md). Non-
328
+ // secret. Override with VIBES_WORKFLOW_IDENTITY_KEYS (comma-separated) for
329
+ // self-host installs; set to "insecure" to disable verification entirely
330
+ // (local testing only — never in a deployed app).
331
+ const DEFAULT_IDENTITY_KEYS = ["publickeyv1_9w1Za94FAEiJ2LzArToAt7mCw6B8UnZYS4EQJkqJ1uZM"]
332
+
333
+ export function workflowServiceName(slug: string): string {
334
+ return `wf_${slug.replace(/-/g, "_")}`
335
+ }
336
+
337
+ let restateFetchHandler: ((req: Request) => Promise<Response>) | null = null
338
+
339
+ /**
340
+ * Build the Restate endpoint fetch handler for the registered workflows.
341
+ * Called once at createVibesServer boot in prod when the app declares
342
+ * workflows. Resolves the slug from VIBES_APP_SLUG (injected by the
343
+ * orchestrator's startDeployServer) — fails loud when missing, because a
344
+ * silently mis-named service would strand every run.
345
+ */
346
+ export async function buildWorkflowEndpoint(): Promise<void> {
347
+ if (workflowMode() === "dev") return
348
+ if (workflowRegistry.size === 0) return
349
+
350
+ const slug = process.env.VIBES_APP_SLUG ?? ""
351
+ if (!slug) {
352
+ throw new Error(
353
+ "[vibes:workflows] VIBES_APP_SLUG not set but this deploy declares workflows — " +
354
+ "orchestrator must inject it (startDeployServer). Refusing to boot with a mis-named service.",
355
+ )
356
+ }
357
+
358
+ const [{ service }, { createEndpointHandler }] = await Promise.all([
359
+ import("@restatedev/restate-sdk"),
360
+ import("@restatedev/restate-sdk/fetch"),
361
+ ])
362
+
363
+ const handlers: Record<string, (ctx: any, payload: unknown) => Promise<unknown>> = {}
364
+ for (const { entry, fn } of workflowRegistry.values()) {
365
+ handlers[entry.name] = (ctx: any, payload: unknown) =>
366
+ // Same system ctx posture as trigger dispatch — no user identity.
367
+ ctxStore.run({ userId: null, system: true } as VibesCtx, () =>
368
+ fn(makeRestateStepContext(ctx), payload),
369
+ )
370
+ }
371
+
372
+ const keysEnv = process.env.VIBES_WORKFLOW_IDENTITY_KEYS
373
+ const identityKeys = keysEnv === "insecure"
374
+ ? undefined
375
+ : keysEnv
376
+ ? keysEnv.split(",").map((s) => s.trim()).filter(Boolean)
377
+ : DEFAULT_IDENTITY_KEYS
378
+
379
+ restateFetchHandler = createEndpointHandler({
380
+ services: [service({ name: workflowServiceName(slug), handlers })],
381
+ ...(identityKeys ? { identityKeys } : {}),
382
+ })
383
+ console.log(
384
+ `[vibes:workflows] Restate endpoint ready — service=${workflowServiceName(slug)} ` +
385
+ `workflows=${workflowRegistry.size} identity=${identityKeys ? "verified" : "INSECURE"}`,
386
+ )
387
+ }
388
+
389
+ function makeRestateStepContext(ctx: any): StepContext {
390
+ return {
391
+ run<T>(name: string, fn: () => T | Promise<T>): Promise<T> {
392
+ return ctx.run(name, fn)
393
+ },
394
+ sleep(name: string, ms: number): Promise<void> {
395
+ return ctx.sleep(ms, name)
396
+ },
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Serve a request under /_vibes/workflow/*. Returns null when no endpoint
402
+ * is mounted (no workflows, or dev mode — dev runs never go through
403
+ * Restate). index.ts turns null into a 404.
404
+ */
405
+ export function handleWorkflowRequest(req: Request): Promise<Response> | null {
406
+ if (!restateFetchHandler) return null
407
+ return restateFetchHandler(req)
408
+ }
409
+
410
+ // ── startWorkflow prod path ──────────────────────────────────────────────────
411
+ // POST to the in-VM agent, which forwards to the orchestrator's
412
+ // /v1/workflows/start (svc-token authed) → Restate ingress. The agent route
413
+ // mirrors /_emit (triggers.ts emitToOrchestrator).
414
+
415
+ async function startViaAgent(
416
+ name: string,
417
+ payloadStr: string,
418
+ opts: StartWorkflowOptions,
419
+ ): Promise<{ runId: string }> {
420
+ const res = await fetch("http://localhost:8080/_workflow/start", {
421
+ method: "POST",
422
+ headers: { "Content-Type": "application/json" },
423
+ body: JSON.stringify({
424
+ workflow: name,
425
+ payload: JSON.parse(payloadStr),
426
+ ...(opts.id ? { idempotencyKey: opts.id } : {}),
427
+ }),
428
+ })
429
+ if (!res.ok) {
430
+ const text = await res.text().catch(() => "")
431
+ throw new Error(`startWorkflow("${name}") agent ${res.status}: ${text.slice(0, 200)}`)
432
+ }
433
+ const body = (await res.json()) as { runId?: string }
434
+ if (!body.runId) {
435
+ throw new Error(`startWorkflow("${name}"): agent returned no runId`)
436
+ }
437
+ return { runId: body.runId }
438
+ }