@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.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -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
|
+
})
|
package/src/workflows.ts
ADDED
|
@@ -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
|
+
}
|