@toist/aja 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -1
- package/README.md +62 -0
- package/package.json +11 -5
- package/src/cache-db.ts +1 -9
- package/src/cli.ts +153 -0
- package/src/client.ts +76 -0
- package/src/data-db.ts +1 -13
- package/src/index.ts +35 -2
- package/src/instance-metadata.ts +48 -0
- package/src/kinds/index.ts +23 -61
- package/src/lock.ts +27 -53
- package/src/migrate.ts +3 -3
- package/src/pipeline-store.ts +31 -0
- package/src/resources-fs.ts +43 -0
- package/src/resources.ts +27 -190
- package/src/run-events.ts +42 -0
- package/src/runtime-db.ts +11 -30
- package/src/server.ts +506 -496
- package/src/sqlite-runtime.ts +135 -0
- package/src/startRunner.ts +56 -70
- package/src/stores/sqlite.ts +243 -0
- package/src/stores/types.ts +18 -0
- package/src/config.ts +0 -129
- package/src/db-handles.ts +0 -70
- package/src/hitl.ts +0 -257
- package/src/instance.ts +0 -64
- package/src/kinds/control.ts +0 -26
- package/src/kinds/data.ts +0 -30
- package/src/kinds/db.ts +0 -92
- package/src/kinds/hitl.ts +0 -56
- package/src/kinds/http.ts +0 -134
- package/src/kinds/runs.ts +0 -130
- package/src/kinds/transform.ts +0 -123
- package/src/kinds/types.ts +0 -16
- package/src/pipeline.ts +0 -605
- package/src/runs.ts +0 -53
package/src/server.ts
CHANGED
|
@@ -1,551 +1,561 @@
|
|
|
1
1
|
// 2121
|
|
2
|
+
import type { Database } from "bun:sqlite"
|
|
2
3
|
import { Hono } from "hono"
|
|
3
4
|
import { cors } from "hono/cors"
|
|
4
5
|
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
5
6
|
import { join } from "node:path"
|
|
6
|
-
import { runtimeDb, dataDb, cacheDb, initDbs } from "./db-handles.ts"
|
|
7
|
-
import { makeCache } from "./cache.ts"
|
|
8
|
-
import { loadAll, watchAll, getPipelines, getPipeline, runPipeline, validateSpec, publicSpec, triggerSubRun, makeLogger, type RunOutcome } from "./pipeline.ts"
|
|
9
|
-
import { parseYaml, type PlatformCtx } from "@toist/spec"
|
|
10
|
-
import { manifest, getKind, type ExecContext } from "./kinds/index.ts"
|
|
11
|
-
import { answerTask, getTask, loadNodeOutputs, persistNodeOutputs } from "./hitl.ts"
|
|
12
|
-
import { loadInstance } from "./instance.ts"
|
|
13
7
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
getPipeline,
|
|
9
|
+
getPipelines,
|
|
10
|
+
getKind,
|
|
11
|
+
loadAll,
|
|
12
|
+
makeLogger,
|
|
13
|
+
manifest,
|
|
14
|
+
publicSpec,
|
|
15
|
+
runPipeline,
|
|
16
|
+
validateSpec,
|
|
17
|
+
type RunCtx,
|
|
18
|
+
type RunOutcome,
|
|
19
|
+
type ToistEvent,
|
|
20
|
+
type ToistRuntime,
|
|
21
|
+
} from "@toist/core"
|
|
22
|
+
import { parseYaml, type ExecContext, type PipelineSpec } from "@toist/spec"
|
|
23
|
+
import { loadInstance } from "./instance-metadata.ts"
|
|
24
|
+
import {
|
|
25
|
+
deleteResource,
|
|
26
|
+
getResource,
|
|
27
|
+
listResources,
|
|
28
|
+
listResourceTypes,
|
|
29
|
+
patchResource,
|
|
30
|
+
upsertResource,
|
|
16
31
|
} from "./resources.ts"
|
|
17
|
-
import {
|
|
18
|
-
import { pipelinesDir, isUiDisabled, isWatchDisabled, getCorsOrigins } from "./config.ts"
|
|
32
|
+
import { createRunEventBroker, type RunEventBroker } from "./run-events.ts"
|
|
19
33
|
|
|
20
34
|
const VALID_PIPELINE_ID = /^[a-z0-9][a-z0-9-]*$/
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
await initDbs()
|
|
36
|
+
export interface MountApiOptions {
|
|
37
|
+
rootDir: string
|
|
38
|
+
pipelinesDir?: string
|
|
39
|
+
adminDb?: Database
|
|
40
|
+
disableUi?: boolean
|
|
41
|
+
corsOrigins?: string | string[]
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const cache = makeCache(cacheDb())
|
|
38
|
-
|
|
39
|
-
const PIPELINES_DIR = pipelinesDir()
|
|
40
|
-
loadAll(PIPELINES_DIR)
|
|
41
|
-
if (!isWatchDisabled()) {
|
|
42
|
-
watchAll(PIPELINES_DIR)
|
|
44
|
+
function resolveRuntimeKind(runtime: ToistRuntime, id: string) {
|
|
45
|
+
if (!runtime.registry) return getKind(id)
|
|
46
|
+
const kind = runtime.registry.get(id)
|
|
47
|
+
if (kind) return kind
|
|
48
|
+
return runtime.registry.exclusive ? undefined : getKind(id)
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return c.json({
|
|
49
|
-
name: inst.instanceName ?? process.env.PLATFORM_INSTANCE_NAME ?? "platform",
|
|
50
|
-
version: "0.1.0",
|
|
51
|
-
platformVersion: inst.platformVersion,
|
|
52
|
-
tier: inst.tier,
|
|
53
|
-
pipelines: getPipelines().length,
|
|
54
|
-
kinds: manifest().length,
|
|
55
|
-
})
|
|
56
|
-
})
|
|
51
|
+
function runtimeManifest(runtime: ToistRuntime) {
|
|
52
|
+
return runtime.registry ? runtime.registry.manifest() : manifest()
|
|
53
|
+
}
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
export async function mountApi(runtime: ToistRuntime, options: MountApiOptions): Promise<Hono> {
|
|
56
|
+
const apiApp = new Hono()
|
|
57
|
+
const pipelinesDir = options.pipelinesDir ?? join(options.rootDir, "pipelines")
|
|
58
|
+
const runEvents = new Map<number, RunEventBroker>()
|
|
59
|
+
const adHocRuns = new Map<number, { spec: PipelineSpec; payload: Record<string, unknown> }>()
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
apiApp.get("/", (c) => {
|
|
62
|
+
const inst = loadInstance(options.rootDir)
|
|
63
|
+
return c.json({
|
|
64
|
+
name: inst.instanceName ?? process.env.PLATFORM_INSTANCE_NAME ?? "platform",
|
|
65
|
+
version: "0.1.0",
|
|
66
|
+
platformVersion: inst.platformVersion,
|
|
67
|
+
tier: inst.tier,
|
|
68
|
+
pipelines: getPipelines().length,
|
|
69
|
+
kinds: runtimeManifest(runtime).length,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
64
72
|
|
|
65
|
-
apiApp.
|
|
66
|
-
const kind = getKind(c.req.param("id"))
|
|
67
|
-
if (!kind) return c.json({ error: "kind not found" }, 404)
|
|
73
|
+
apiApp.get("/instance", (c) => c.json(loadInstance(options.rootDir)))
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
+
apiApp.get("/manifest", (c) => c.json({
|
|
76
|
+
version: 1,
|
|
77
|
+
schema: "https://toist.in/schemas/manifest-v1.json",
|
|
78
|
+
kinds: runtimeManifest(runtime),
|
|
79
|
+
}))
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
message: `Kind "${kind.id}" mutates external state. Pass { confirm: true } to invoke ad-hoc.`,
|
|
80
|
-
}, 400)
|
|
81
|
-
}
|
|
81
|
+
apiApp.post("/kinds/:id/invoke", async (c) => {
|
|
82
|
+
const kind = resolveRuntimeKind(runtime, c.req.param("id"))
|
|
83
|
+
if (!kind) return c.json({ error: "kind not found" }, 404)
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
runs: makeRunStore(runtimeDb()),
|
|
90
|
-
subRun: async () => {
|
|
91
|
-
throw new Error("ctx.subRun is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
|
|
92
|
-
},
|
|
93
|
-
step: { nodeId: "<invoke>" },
|
|
94
|
-
suspend: async () => {
|
|
95
|
-
throw new Error("ctx.suspend is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
|
|
96
|
-
},
|
|
97
|
-
}
|
|
85
|
+
const body = await c.req.json().catch(() => ({}))
|
|
86
|
+
const { params = {}, input = {}, confirm = false } = body as {
|
|
87
|
+
params?: Record<string, unknown>
|
|
88
|
+
input?: Record<string, unknown>
|
|
89
|
+
confirm?: boolean
|
|
90
|
+
}
|
|
98
91
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
duration_ms: Math.round(performance.now() - t0),
|
|
106
|
-
})
|
|
107
|
-
} catch (err: unknown) {
|
|
108
|
-
return c.json({
|
|
109
|
-
kind: kind.id,
|
|
110
|
-
error: err instanceof Error ? err.message : String(err),
|
|
111
|
-
duration_ms: Math.round(performance.now() - t0),
|
|
112
|
-
}, 500)
|
|
113
|
-
}
|
|
114
|
-
})
|
|
92
|
+
if (kind.sideEffect && !confirm) {
|
|
93
|
+
return c.json({
|
|
94
|
+
error: "side_effect_requires_confirm",
|
|
95
|
+
message: `Kind "${kind.id}" mutates external state. Pass { confirm: true } to invoke ad-hoc.`,
|
|
96
|
+
}, 400)
|
|
97
|
+
}
|
|
115
98
|
|
|
116
|
-
|
|
99
|
+
const invokeCtx: ExecContext = {
|
|
100
|
+
runId: -1,
|
|
101
|
+
db: runtime.data,
|
|
102
|
+
cache: runtime.cache,
|
|
103
|
+
log: (level, msg) => console.log(`[invoke ${kind.id}] ${level}: ${msg}`),
|
|
104
|
+
resource: await runtime.resources.resolve(),
|
|
105
|
+
runs: runtime.runs,
|
|
106
|
+
subRun: async () => {
|
|
107
|
+
throw new Error("ctx.subRun is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
|
|
108
|
+
},
|
|
109
|
+
step: { nodeId: "<invoke>" },
|
|
110
|
+
suspend: async () => {
|
|
111
|
+
throw new Error("ctx.suspend is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
|
|
112
|
+
},
|
|
113
|
+
}
|
|
117
114
|
|
|
118
|
-
|
|
115
|
+
const t0 = performance.now()
|
|
116
|
+
try {
|
|
117
|
+
const output = await kind.run(invokeCtx, params, input)
|
|
118
|
+
return c.json({
|
|
119
|
+
kind: kind.id,
|
|
120
|
+
output,
|
|
121
|
+
duration_ms: Math.round(performance.now() - t0),
|
|
122
|
+
})
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
return c.json({
|
|
125
|
+
kind: kind.id,
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
duration_ms: Math.round(performance.now() - t0),
|
|
128
|
+
}, 500)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
119
131
|
|
|
120
|
-
apiApp.get("/
|
|
132
|
+
apiApp.get("/resource-types", (c) => c.json(listResourceTypes()))
|
|
121
133
|
|
|
122
|
-
apiApp.get("/resources
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})
|
|
134
|
+
apiApp.get("/resources", (c) => {
|
|
135
|
+
if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
|
|
136
|
+
return c.json(listResources(options.adminDb))
|
|
137
|
+
})
|
|
127
138
|
|
|
128
|
-
apiApp.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (!body.name || typeof body.name !== "string") return c.json({ error: "name required" }, 400)
|
|
133
|
-
if (!body.type || typeof body.type !== "string") return c.json({ error: "type required" }, 400)
|
|
134
|
-
const fields = body.fields ?? {}
|
|
135
|
-
const r = upsertResource(runtimeDb(), body.name, body.type, fields)
|
|
136
|
-
return c.json(r, 201)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
apiApp.put("/resources/:name", async (c) => {
|
|
140
|
-
const name = c.req.param("name")
|
|
141
|
-
const body = await c.req.json().catch(() => ({})) as {
|
|
142
|
-
type?: string; fields?: Record<string, unknown>
|
|
143
|
-
}
|
|
144
|
-
if (body.type !== undefined && body.fields !== undefined) {
|
|
145
|
-
// Full replacement
|
|
146
|
-
const r = upsertResource(runtimeDb(), name, body.type, body.fields)
|
|
147
|
-
return c.json(r)
|
|
148
|
-
}
|
|
149
|
-
if (body.fields !== undefined) {
|
|
150
|
-
// Patch fields only
|
|
151
|
-
const r = patchResource(runtimeDb(), name, body.fields)
|
|
139
|
+
apiApp.get("/resources/:name", (c) => {
|
|
140
|
+
if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
|
|
141
|
+
const r = getResource(options.adminDb, c.req.param("name"))
|
|
152
142
|
if (!r) return c.json({ error: "not found" }, 404)
|
|
153
143
|
return c.json(r)
|
|
154
|
-
}
|
|
155
|
-
return c.json({ error: "provide type+fields for replacement or fields for patch" }, 400)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
apiApp.delete("/resources/:name", (c) => {
|
|
159
|
-
const ok = deleteResource(runtimeDb(), c.req.param("name"))
|
|
160
|
-
if (!ok) return c.json({ error: "not found" }, 404)
|
|
161
|
-
return new Response(null, { status: 204 })
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
// ─── pipelines ───────────────────────────────────────────────────────────────
|
|
165
|
-
apiApp.get("/pipelines", (c) => {
|
|
166
|
-
const list = getPipelines().map((p) => ({
|
|
167
|
-
id: p.id,
|
|
168
|
-
label: p.label ?? p.id,
|
|
169
|
-
description: p.description,
|
|
170
|
-
nodeCount: p.nodes.length,
|
|
171
|
-
}))
|
|
172
|
-
return c.json(list)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
apiApp.get("/pipelines/:id", (c) => {
|
|
176
|
-
const spec = getPipeline(c.req.param("id"))
|
|
177
|
-
if (!spec) return c.json({ error: "not found" }, 404)
|
|
178
|
-
return c.json(publicSpec(spec))
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
// Raw YAML source for editing. Returns the file as written to disk (preserves
|
|
182
|
-
// comments and formatting), not the parsed spec.
|
|
183
|
-
apiApp.get("/pipelines/:id/source", (c) => {
|
|
184
|
-
const id = c.req.param("id")
|
|
185
|
-
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
|
|
186
|
-
const path = join(PIPELINES_DIR, `${id}.yaml`)
|
|
187
|
-
if (!existsSync(path)) return c.json({ error: "not found" }, 404)
|
|
188
|
-
return c.json({ id, yaml: readFileSync(path, "utf8") })
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
// Create a new pipeline file. The id comes from the YAML's `id:` field — no
|
|
192
|
-
// separate body.id, since two sources of truth caused silent overwrites.
|
|
193
|
-
// Refuses to overwrite an existing id.
|
|
194
|
-
apiApp.post("/pipelines", async (c) => {
|
|
195
|
-
const body = await c.req.json().catch(() => ({})) as { yaml?: string }
|
|
196
|
-
if (!body.yaml) return c.json({ error: "yaml required" }, 400)
|
|
197
|
-
|
|
198
|
-
let parsed: unknown
|
|
199
|
-
try { parsed = parseYaml(body.yaml) }
|
|
200
|
-
catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
|
|
201
|
-
|
|
202
|
-
const id = (parsed as { id?: string })?.id
|
|
203
|
-
if (!id) return c.json({ error: "yaml must contain a top-level `id:` field" }, 400)
|
|
204
|
-
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: `invalid id "${id}" — lowercase alphanum + hyphens, must start with alphanum` }, 400)
|
|
205
|
-
|
|
206
|
-
const path = join(PIPELINES_DIR, `${id}.yaml`)
|
|
207
|
-
if (existsSync(path)) return c.json({ error: `pipeline "${id}" already exists` }, 409)
|
|
208
|
-
|
|
209
|
-
const result = validateSpec(parsed)
|
|
210
|
-
if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
|
|
211
|
-
|
|
212
|
-
writeFileSync(path, body.yaml)
|
|
213
|
-
// Sync reload — don't rely on the fs watcher, since the client may navigate
|
|
214
|
-
// to the new pipeline immediately and would otherwise see a 404.
|
|
215
|
-
loadAll(PIPELINES_DIR)
|
|
216
|
-
return c.json({ id, ok: true }, 201)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// Update an existing pipeline file. Refuses to change the id (renames are a
|
|
220
|
-
// separate operation we don't yet support).
|
|
221
|
-
apiApp.put("/pipelines/:id", async (c) => {
|
|
222
|
-
const id = c.req.param("id")
|
|
223
|
-
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
|
|
224
|
-
|
|
225
|
-
const path = join(PIPELINES_DIR, `${id}.yaml`)
|
|
226
|
-
if (!existsSync(path)) return c.json({ error: "not found" }, 404)
|
|
227
|
-
|
|
228
|
-
const body = await c.req.json().catch(() => ({})) as { yaml?: string }
|
|
229
|
-
if (!body.yaml) return c.json({ error: "yaml required" }, 400)
|
|
230
|
-
|
|
231
|
-
let parsed: unknown
|
|
232
|
-
try { parsed = parseYaml(body.yaml) }
|
|
233
|
-
catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
|
|
234
|
-
|
|
235
|
-
const yamlId = (parsed as { id?: string })?.id
|
|
236
|
-
if (yamlId && yamlId !== id) {
|
|
237
|
-
return c.json({ error: `id mismatch: url says "${id}", yaml says "${yamlId}". Renames are not supported via edit.` }, 400)
|
|
238
|
-
}
|
|
144
|
+
})
|
|
239
145
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
apiApp.post("/pipelines/validate", async (c) => {
|
|
249
|
-
const body = await c.req.json().catch(() => ({})) as { yaml?: string; spec?: unknown }
|
|
250
|
-
let spec: unknown
|
|
251
|
-
if (typeof body.yaml === "string") {
|
|
252
|
-
try { spec = parseYaml(body.yaml) }
|
|
253
|
-
catch (err) {
|
|
254
|
-
return c.json({ ok: false, errors: [`YAML parse error: ${(err as Error).message}`] }, 400)
|
|
146
|
+
apiApp.post("/resources", async (c) => {
|
|
147
|
+
if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
|
|
148
|
+
const body = await c.req.json().catch(() => ({})) as {
|
|
149
|
+
name?: string
|
|
150
|
+
type?: string
|
|
151
|
+
fields?: Record<string, unknown>
|
|
255
152
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
153
|
+
if (!body.name || typeof body.name !== "string") return c.json({ error: "name required" }, 400)
|
|
154
|
+
if (!body.type || typeof body.type !== "string") return c.json({ error: "type required" }, 400)
|
|
155
|
+
const fields = body.fields ?? {}
|
|
156
|
+
const r = upsertResource(options.adminDb, body.name, body.type, fields)
|
|
157
|
+
return c.json(r, 201)
|
|
158
|
+
})
|
|
261
159
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
resource: buildResourceCtx(runtimeDb()),
|
|
281
|
-
runs: makeRunStore(runtimeDb()),
|
|
282
|
-
subRun: undefined as unknown as PlatformCtx["subRun"],
|
|
283
|
-
}
|
|
284
|
-
baseCtx.subRun = (pid, payload) => triggerSubRun(pid, payload, baseCtx)
|
|
285
|
-
return baseCtx
|
|
286
|
-
}
|
|
160
|
+
apiApp.put("/resources/:name", async (c) => {
|
|
161
|
+
if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
|
|
162
|
+
const name = c.req.param("name")
|
|
163
|
+
const body = await c.req.json().catch(() => ({})) as {
|
|
164
|
+
type?: string
|
|
165
|
+
fields?: Record<string, unknown>
|
|
166
|
+
}
|
|
167
|
+
if (body.type !== undefined && body.fields !== undefined) {
|
|
168
|
+
const r = upsertResource(options.adminDb, name, body.type, body.fields)
|
|
169
|
+
return c.json(r)
|
|
170
|
+
}
|
|
171
|
+
if (body.fields !== undefined) {
|
|
172
|
+
const r = patchResource(options.adminDb, name, body.fields)
|
|
173
|
+
if (!r) return c.json({ error: "not found" }, 404)
|
|
174
|
+
return c.json(r)
|
|
175
|
+
}
|
|
176
|
+
return c.json({ error: "provide type+fields for replacement or fields for patch" }, 400)
|
|
177
|
+
})
|
|
287
178
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
179
|
+
apiApp.delete("/resources/:name", (c) => {
|
|
180
|
+
if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
|
|
181
|
+
const ok = deleteResource(options.adminDb, c.req.param("name"))
|
|
182
|
+
if (!ok) return c.json({ error: "not found" }, 404)
|
|
183
|
+
return new Response(null, { status: 204 })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
apiApp.get("/pipelines", (c) => {
|
|
187
|
+
const list = getPipelines().map((p) => ({
|
|
188
|
+
id: p.id,
|
|
189
|
+
label: p.label ?? p.id,
|
|
190
|
+
description: p.description,
|
|
191
|
+
nodeCount: p.nodes.length,
|
|
192
|
+
}))
|
|
193
|
+
return c.json(list)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
apiApp.get("/pipelines/:id", (c) => {
|
|
197
|
+
const spec = getPipeline(c.req.param("id"))
|
|
198
|
+
if (!spec) return c.json({ error: "not found" }, 404)
|
|
199
|
+
return c.json(publicSpec(spec))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
apiApp.get("/pipelines/:id/source", (c) => {
|
|
203
|
+
const id = c.req.param("id")
|
|
204
|
+
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
|
|
205
|
+
const path = join(pipelinesDir, `${id}.yaml`)
|
|
206
|
+
if (!existsSync(path)) return c.json({ error: "not found" }, 404)
|
|
207
|
+
return c.json({ id, yaml: readFileSync(path, "utf8") })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
apiApp.post("/pipelines", async (c) => {
|
|
211
|
+
const body = await c.req.json().catch(() => ({})) as { yaml?: string }
|
|
212
|
+
if (!body.yaml) return c.json({ error: "yaml required" }, 400)
|
|
213
|
+
|
|
214
|
+
let parsed: unknown
|
|
215
|
+
try { parsed = parseYaml(body.yaml) }
|
|
216
|
+
catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
|
|
217
|
+
|
|
218
|
+
const id = (parsed as { id?: string })?.id
|
|
219
|
+
if (!id) return c.json({ error: "yaml must contain a top-level `id:` field" }, 400)
|
|
220
|
+
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: `invalid id "${id}" — lowercase alphanum + hyphens, must start with alphanum` }, 400)
|
|
221
|
+
|
|
222
|
+
const path = join(pipelinesDir, `${id}.yaml`)
|
|
223
|
+
if (existsSync(path)) return c.json({ error: `pipeline "${id}" already exists` }, 409)
|
|
224
|
+
|
|
225
|
+
const result = validateSpec(parsed, { registry: runtime.registry })
|
|
226
|
+
if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
|
|
227
|
+
|
|
228
|
+
writeFileSync(path, body.yaml)
|
|
229
|
+
loadAll(pipelinesDir, { registry: runtime.registry })
|
|
230
|
+
return c.json({ id, ok: true }, 201)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
apiApp.put("/pipelines/:id", async (c) => {
|
|
234
|
+
const id = c.req.param("id")
|
|
235
|
+
if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
|
|
236
|
+
|
|
237
|
+
const path = join(pipelinesDir, `${id}.yaml`)
|
|
238
|
+
if (!existsSync(path)) return c.json({ error: "not found" }, 404)
|
|
239
|
+
|
|
240
|
+
const body = await c.req.json().catch(() => ({})) as { yaml?: string }
|
|
241
|
+
if (!body.yaml) return c.json({ error: "yaml required" }, 400)
|
|
242
|
+
|
|
243
|
+
let parsed: unknown
|
|
244
|
+
try { parsed = parseYaml(body.yaml) }
|
|
245
|
+
catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
|
|
246
|
+
|
|
247
|
+
const yamlId = (parsed as { id?: string })?.id
|
|
248
|
+
if (yamlId && yamlId !== id) {
|
|
249
|
+
return c.json({ error: `id mismatch: url says "${id}", yaml says "${yamlId}". Renames are not supported via edit.` }, 400)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result = validateSpec(parsed, { registry: runtime.registry })
|
|
253
|
+
if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
|
|
301
254
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return { id
|
|
255
|
+
writeFileSync(path, body.yaml)
|
|
256
|
+
loadAll(pipelinesDir, { registry: runtime.registry })
|
|
257
|
+
return c.json({ id, ok: true })
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
apiApp.post("/pipelines/validate", async (c) => {
|
|
261
|
+
const body = await c.req.json().catch(() => ({})) as { yaml?: string; spec?: unknown }
|
|
262
|
+
let spec: unknown
|
|
263
|
+
if (typeof body.yaml === "string") {
|
|
264
|
+
try { spec = parseYaml(body.yaml) }
|
|
265
|
+
catch (err) {
|
|
266
|
+
return c.json({ ok: false, errors: [`YAML parse error: ${(err as Error).message}`] }, 400)
|
|
267
|
+
}
|
|
268
|
+
} else if (body.spec) {
|
|
269
|
+
spec = body.spec
|
|
270
|
+
} else {
|
|
271
|
+
return c.json({ ok: false, errors: ["Provide either `yaml` (string) or `spec` (object)"] }, 400)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const result = validateSpec(spec, { registry: runtime.registry })
|
|
275
|
+
return c.json({
|
|
276
|
+
ok: result.ok,
|
|
277
|
+
errors: result.errors,
|
|
278
|
+
warnings: result.warnings ?? [],
|
|
279
|
+
edges: result.edges ?? [],
|
|
280
|
+
}, result.ok ? 200 : 400)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
function getRunBroker(runId: number): RunEventBroker {
|
|
284
|
+
let broker = runEvents.get(runId)
|
|
285
|
+
if (!broker) {
|
|
286
|
+
broker = createRunEventBroker()
|
|
287
|
+
runEvents.set(runId, broker)
|
|
288
|
+
}
|
|
289
|
+
return broker
|
|
305
290
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
assignee: task.assignee,
|
|
316
|
-
responseToken: task.responseToken,
|
|
317
|
-
},
|
|
318
|
-
steps: outcome.steps,
|
|
291
|
+
|
|
292
|
+
async function buildBaseCtx(runId: number, emit?: (event: ToistEvent) => void | Promise<void>): Promise<RunCtx> {
|
|
293
|
+
return {
|
|
294
|
+
runId,
|
|
295
|
+
runtime,
|
|
296
|
+
log: makeLogger(runtime, runId),
|
|
297
|
+
resource: await runtime.resources.resolve(),
|
|
298
|
+
emit,
|
|
299
|
+
}
|
|
319
300
|
}
|
|
320
|
-
}
|
|
321
301
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
"INSERT INTO runs (pipeline, status, payload, trigger, updated_at) VALUES (?, 'running', ?, 'api', datetime('now')) RETURNING id",
|
|
330
|
-
).get(id, JSON.stringify(payload)) as { id: number }
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
const outcome = await runPipeline(spec, payload as Record<string, unknown>, buildBaseCtx(run.id))
|
|
334
|
-
persistOutcome(run.id, outcome)
|
|
335
|
-
return c.json(outcomeResponse(run.id, id, outcome))
|
|
336
|
-
} catch (err: unknown) {
|
|
337
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
338
|
-
runtimeDb().prepare(
|
|
339
|
-
"UPDATE runs SET status='error', error=?, finished_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
|
|
340
|
-
).run(message, run.id)
|
|
341
|
-
return c.json({ id: run.id, pipeline: id, status: "error", error: message }, 500)
|
|
302
|
+
async function persistOutcome(runId: number, outcome: RunOutcome): Promise<void> {
|
|
303
|
+
if (outcome.status === "done") {
|
|
304
|
+
await runtime.runs.markDone(runId, outcome.output, outcome.steps)
|
|
305
|
+
} else {
|
|
306
|
+
await runtime.outputs.putMany(runId, outcome.results)
|
|
307
|
+
await runtime.runs.markSuspended(runId, outcome.suspendedAt.nodeId, outcome.steps)
|
|
308
|
+
}
|
|
342
309
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
${whereSql}
|
|
366
|
-
ORDER BY t.id DESC LIMIT ?`
|
|
367
|
-
return c.json(runtimeDb().prepare(sql).all(...args))
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
apiApp.get("/tasks/:id", (c) => {
|
|
371
|
-
const task = getTask(runtimeDb(), Number(c.req.param("id")))
|
|
372
|
-
if (!task) return c.json({ error: "not found" }, 404)
|
|
373
|
-
return c.json(task)
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
apiApp.post("/runs/:runId/tasks/:taskId/respond", async (c) => {
|
|
377
|
-
const runId = Number(c.req.param("runId"))
|
|
378
|
-
const taskId = Number(c.req.param("taskId"))
|
|
379
|
-
const body = await c.req.json().catch(() => ({})) as {
|
|
380
|
-
token?: string
|
|
381
|
-
response?: unknown
|
|
382
|
-
respondedBy?: string
|
|
310
|
+
|
|
311
|
+
async function outcomeResponse(runId: number, pipelineId: string, outcome: RunOutcome) {
|
|
312
|
+
if (outcome.status === "done") {
|
|
313
|
+
return { id: runId, pipeline: pipelineId, status: "done", result: outcome.output, steps: outcome.steps }
|
|
314
|
+
}
|
|
315
|
+
const task = await runtime.tasks.get(outcome.suspendedAt.taskId)
|
|
316
|
+
if (!task) throw new Error(`task ${outcome.suspendedAt.taskId} not found`)
|
|
317
|
+
return {
|
|
318
|
+
id: runId,
|
|
319
|
+
pipeline: pipelineId,
|
|
320
|
+
status: "suspended",
|
|
321
|
+
suspendedAt: outcome.suspendedAt.nodeId,
|
|
322
|
+
task: {
|
|
323
|
+
id: task.id,
|
|
324
|
+
kind: task.kind,
|
|
325
|
+
prompt: task.prompt,
|
|
326
|
+
schema: task.schema,
|
|
327
|
+
assignee: task.assignee,
|
|
328
|
+
responseToken: task.responseToken,
|
|
329
|
+
},
|
|
330
|
+
steps: outcome.steps,
|
|
331
|
+
}
|
|
383
332
|
}
|
|
384
|
-
if (!body.token) return c.json({ error: "missing token" }, 400)
|
|
385
333
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
334
|
+
async function executeRun(
|
|
335
|
+
spec: PipelineSpec,
|
|
336
|
+
pipelineId: string,
|
|
337
|
+
payload: Record<string, unknown>,
|
|
338
|
+
runId: number,
|
|
339
|
+
options: { rememberAdHoc?: boolean; resumeOutputs?: Record<string, unknown> } = {},
|
|
340
|
+
) {
|
|
341
|
+
if (options.rememberAdHoc || adHocRuns.has(runId)) adHocRuns.set(runId, { spec, payload })
|
|
342
|
+
|
|
343
|
+
const broker = getRunBroker(runId)
|
|
344
|
+
const emit = async (event: ToistEvent) => { await broker.emit(event) }
|
|
345
|
+
await emit({ type: "run.started", runId, pipelineId })
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const outcome = await runPipeline(spec, payload, await buildBaseCtx(runId, emit), {
|
|
349
|
+
resumeOutputs: options.resumeOutputs,
|
|
350
|
+
})
|
|
351
|
+
await persistOutcome(runId, outcome)
|
|
352
|
+
|
|
353
|
+
if (outcome.status === "done") {
|
|
354
|
+
adHocRuns.delete(runId)
|
|
355
|
+
await emit({ type: "run.completed", runId, output: outcome.output })
|
|
356
|
+
} else {
|
|
357
|
+
await emit({
|
|
358
|
+
type: "run.suspended",
|
|
359
|
+
runId,
|
|
360
|
+
nodeId: outcome.suspendedAt.nodeId,
|
|
361
|
+
taskId: outcome.suspendedAt.taskId,
|
|
362
|
+
taskKind: outcome.suspendedAt.cause.kind,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
389
365
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
366
|
+
return await outcomeResponse(runId, pipelineId, outcome)
|
|
367
|
+
} catch (err: unknown) {
|
|
368
|
+
adHocRuns.delete(runId)
|
|
369
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
370
|
+
await runtime.runs.markError(runId, message)
|
|
371
|
+
await emit({ type: "run.error", runId, error: message })
|
|
372
|
+
return { id: runId, pipeline: pipelineId, status: "error", error: message }
|
|
373
|
+
} finally {
|
|
374
|
+
broker.finish()
|
|
375
|
+
}
|
|
394
376
|
}
|
|
395
377
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
378
|
+
apiApp.post("/pipelines/:id/run", async (c) => {
|
|
379
|
+
const id = c.req.param("id")
|
|
380
|
+
const spec = getPipeline(id)
|
|
381
|
+
if (!spec) return c.json({ error: "not found" }, 404)
|
|
382
|
+
|
|
383
|
+
const payload = await c.req.json().catch(() => ({})) as Record<string, unknown>
|
|
384
|
+
const runId = await runtime.runs.create({ pipeline: id, payload, trigger: "api" })
|
|
385
|
+
const response = await executeRun(spec, id, payload, runId)
|
|
386
|
+
return c.json(response, response.status === "error" ? 500 : 200)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
apiApp.post("/runs", async (c) => {
|
|
390
|
+
const body = await c.req.json().catch(() => ({})) as {
|
|
391
|
+
spec?: unknown
|
|
392
|
+
payload?: Record<string, unknown>
|
|
393
|
+
}
|
|
394
|
+
if (!body.spec) return c.json({ error: "spec required" }, 400)
|
|
395
|
+
|
|
396
|
+
const result = validateSpec(body.spec, { registry: runtime.registry })
|
|
397
|
+
if (!result.ok || !result.parsed) {
|
|
398
|
+
return c.json({ ok: false, errors: result.errors, warnings: result.warnings ?? [] }, 400)
|
|
399
|
+
}
|
|
399
400
|
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
const payload = body.payload ?? {}
|
|
402
|
+
const runId = await runtime.runs.create({ pipeline: result.parsed.id, payload, trigger: "api" })
|
|
403
|
+
void executeRun(result.parsed, result.parsed.id, payload, runId, { rememberAdHoc: true })
|
|
404
|
+
return c.json({ id: runId, pipeline: result.parsed.id, status: "running" }, 202)
|
|
405
|
+
})
|
|
402
406
|
|
|
403
|
-
|
|
407
|
+
apiApp.get("/tasks", async (c) => {
|
|
408
|
+
const status = c.req.query("status") ?? "open"
|
|
409
|
+
const assignee = c.req.query("assignee")
|
|
410
|
+
const runId = c.req.query("run_id")
|
|
411
|
+
const limit = Number(c.req.query("limit") ?? 200)
|
|
412
|
+
return c.json(await runtime.tasks.list({
|
|
413
|
+
status,
|
|
414
|
+
assignee,
|
|
415
|
+
runId: runId ? Number(runId) : undefined,
|
|
416
|
+
limit,
|
|
417
|
+
}))
|
|
418
|
+
})
|
|
404
419
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const value = (body.response as { value?: unknown } | null)?.value
|
|
420
|
+
apiApp.get("/tasks/:id", async (c) => {
|
|
421
|
+
const task = await runtime.tasks.get(Number(c.req.param("id")))
|
|
422
|
+
if (!task) return c.json({ error: "not found" }, 404)
|
|
423
|
+
return c.json(task)
|
|
424
|
+
})
|
|
411
425
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
apiApp.post("/runs/:runId/tasks/:taskId/respond", async (c) => {
|
|
427
|
+
const runId = Number(c.req.param("runId"))
|
|
428
|
+
const taskId = Number(c.req.param("taskId"))
|
|
429
|
+
const body = await c.req.json().catch(() => ({})) as {
|
|
430
|
+
token?: string
|
|
431
|
+
response?: unknown
|
|
432
|
+
respondedBy?: string
|
|
417
433
|
}
|
|
434
|
+
if (!body.token) return c.json({ error: "missing token" }, 400)
|
|
418
435
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}, 400)
|
|
436
|
+
const taskBefore = await runtime.tasks.get(taskId)
|
|
437
|
+
if (!taskBefore) return c.json({ error: "task not found" }, 404)
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await runtime.tasks.answer(taskId, body.token, body.response, body.respondedBy ?? null)
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
|
|
427
443
|
}
|
|
428
|
-
// retry falls through — the failed node has no node_outputs entry, so
|
|
429
|
-
// re-entering the dispatcher will execute it again.
|
|
430
|
-
}
|
|
431
444
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
runtimeDb().prepare(
|
|
435
|
-
"UPDATE runs SET status='running', trigger='resumed', current_node=NULL, updated_at=datetime('now') WHERE id=?",
|
|
436
|
-
).run(runId)
|
|
437
|
-
|
|
438
|
-
try {
|
|
439
|
-
const outcome = await runPipeline(spec, payload, buildBaseCtx(runId), { resumeOutputs })
|
|
440
|
-
persistOutcome(runId, outcome)
|
|
441
|
-
return c.json(outcomeResponse(runId, runRow.pipeline, outcome))
|
|
442
|
-
} catch (err: unknown) {
|
|
443
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
444
|
-
runtimeDb().prepare(
|
|
445
|
-
"UPDATE runs SET status='error', error=?, finished_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
|
|
446
|
-
).run(message, runId)
|
|
447
|
-
return c.json({ id: runId, pipeline: runRow.pipeline, status: "error", error: message }, 500)
|
|
448
|
-
}
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
// ─── runs (runtime ledger) ───────────────────────────────────────────────────
|
|
452
|
-
apiApp.get("/runs", (c) => {
|
|
453
|
-
const limit = Number(c.req.query("limit") ?? 50)
|
|
454
|
-
const pipelineFilter = c.req.query("pipeline")
|
|
455
|
-
const rows = pipelineFilter
|
|
456
|
-
? runtimeDb().prepare("SELECT * FROM runs WHERE pipeline = ? ORDER BY id DESC LIMIT ?").all(pipelineFilter, limit)
|
|
457
|
-
: runtimeDb().prepare("SELECT * FROM runs ORDER BY id DESC LIMIT ?").all(limit)
|
|
458
|
-
return c.json(rows)
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
apiApp.get("/runs/:id", (c) => {
|
|
462
|
-
const row = runtimeDb().prepare("SELECT * FROM runs WHERE id=?").get(Number(c.req.param("id")))
|
|
463
|
-
if (!row) return c.json({ error: "not found" }, 404)
|
|
464
|
-
return c.json(row)
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
apiApp.get("/runs/:id/nodes", (c) => {
|
|
468
|
-
const runId = Number(c.req.param("id"))
|
|
469
|
-
const rows = runtimeDb().prepare(
|
|
470
|
-
"SELECT node_id, output_json, started_at, finished_at FROM node_outputs WHERE run_id = ? ORDER BY started_at ASC",
|
|
471
|
-
).all(runId) as Array<{ node_id: string; output_json: string | null; started_at: string; finished_at: string }>
|
|
472
|
-
return c.json(rows.map((r) => ({
|
|
473
|
-
nodeId: r.node_id,
|
|
474
|
-
output: r.output_json != null ? JSON.parse(r.output_json) as unknown : null,
|
|
475
|
-
startedAt: r.started_at,
|
|
476
|
-
finishedAt: r.finished_at,
|
|
477
|
-
})))
|
|
478
|
-
})
|
|
479
|
-
|
|
480
|
-
apiApp.get("/runs/:id/logs", (c) => {
|
|
481
|
-
const runId = Number(c.req.param("id"))
|
|
482
|
-
const rows = runtimeDb().prepare(
|
|
483
|
-
"SELECT id, ts, level, msg FROM logs WHERE run_id = ? ORDER BY id ASC",
|
|
484
|
-
).all(runId)
|
|
485
|
-
return c.json(rows)
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
// ─── lifecycle ───────────────────────────────────────────────────────────────
|
|
489
|
-
const cachePruneTimer = setInterval(() => cache.prune(), 5 * 60 * 1000)
|
|
490
|
-
cachePruneTimer.unref?.()
|
|
491
|
-
|
|
492
|
-
const PORT = Number(process.env.PORT ?? 2132)
|
|
493
|
-
|
|
494
|
-
// Bun's auto-launch path: only emit the port-log when index.ts is the entry
|
|
495
|
-
// (i.e. legacy dev mode `bun src/index.ts`). When startRunner.ts imports this
|
|
496
|
-
// module to extract the Hono app, it manages its own Bun.serve() and logs
|
|
497
|
-
// against options.port, so this log would be wrong.
|
|
498
|
-
if (import.meta.main) {
|
|
499
|
-
console.log(`[runner] http://localhost:${PORT}`)
|
|
500
|
-
}
|
|
445
|
+
const runRow = await runtime.runs.get(runId)
|
|
446
|
+
if (!runRow) return c.json({ error: "run not found" }, 404)
|
|
501
447
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
// Setting headers explicitly is correct and safe regardless of host.
|
|
524
|
-
const respond = (file: ReturnType<typeof Bun.file>) =>
|
|
525
|
-
new Response(file, {
|
|
526
|
-
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
527
|
-
})
|
|
528
|
-
app.get("/*", async (c) => {
|
|
529
|
-
const url = new URL(c.req.url).pathname
|
|
530
|
-
const tryPath = url === "/" ? "/index.html" : url
|
|
531
|
-
const file = Bun.file(join(distDir, tryPath))
|
|
532
|
-
if (await file.exists()) return respond(file)
|
|
533
|
-
// SPA fallback for unknown non-asset routes (no extension)
|
|
534
|
-
if (!tryPath.includes(".")) {
|
|
535
|
-
return respond(Bun.file(indexPath))
|
|
448
|
+
const adHoc = adHocRuns.get(runId)
|
|
449
|
+
const spec = adHoc?.spec ?? getPipeline(runRow.pipeline)
|
|
450
|
+
if (!spec) return c.json({ error: `pipeline "${runRow.pipeline}" no longer loaded` }, 500)
|
|
451
|
+
|
|
452
|
+
const payload = adHoc?.payload ?? (runRow.payload ? JSON.parse(runRow.payload) : {})
|
|
453
|
+
|
|
454
|
+
if (taskBefore.kind === "error_review") {
|
|
455
|
+
const action = (body.response as { action?: string } | null)?.action
|
|
456
|
+
const value = (body.response as { value?: unknown } | null)?.value
|
|
457
|
+
|
|
458
|
+
if (action === "abort") {
|
|
459
|
+
await runtime.runs.markError(runId, `aborted via error_review task ${taskId}`)
|
|
460
|
+
return c.json({ id: runId, pipeline: runRow.pipeline, status: "error", error: "aborted by reviewer" })
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (action === "skip") {
|
|
464
|
+
await runtime.outputs.putMany(runId, { [taskBefore.nodeId]: value ?? null })
|
|
465
|
+
} else if (action !== "retry") {
|
|
466
|
+
return c.json({
|
|
467
|
+
error: `error_review response must include action: "retry" | "skip" | "abort" (got ${JSON.stringify(action)})`,
|
|
468
|
+
}, 400)
|
|
536
469
|
}
|
|
537
|
-
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const resumeOutputs = await runtime.outputs.list(runId)
|
|
473
|
+
await runtime.runs.markRunning(runId, { trigger: "resumed", clearCurrentNode: true })
|
|
474
|
+
|
|
475
|
+
const response = await executeRun(spec, runRow.pipeline, payload, runId, {
|
|
476
|
+
rememberAdHoc: !!adHoc,
|
|
477
|
+
resumeOutputs,
|
|
538
478
|
})
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
)
|
|
544
|
-
|
|
545
|
-
}
|
|
479
|
+
return c.json(response, response.status === "error" ? 500 : 200)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
apiApp.get("/runs", async (c) => {
|
|
483
|
+
const limit = Number(c.req.query("limit") ?? 50)
|
|
484
|
+
const pipeline = c.req.query("pipeline")
|
|
485
|
+
return c.json(await runtime.runs.list({ pipeline, limit }))
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
apiApp.get("/runs/:id", async (c) => {
|
|
489
|
+
const row = await runtime.runs.get(Number(c.req.param("id")))
|
|
490
|
+
if (!row) return c.json({ error: "not found" }, 404)
|
|
491
|
+
return c.json(row)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
apiApp.get("/runs/:id/events", async (c) => {
|
|
495
|
+
const runId = Number(c.req.param("id"))
|
|
496
|
+
const broker = runEvents.get(runId)
|
|
497
|
+
if (!broker) return c.json({ error: "event stream not found" }, 404)
|
|
498
|
+
|
|
499
|
+
const encoder = new TextEncoder()
|
|
500
|
+
const stream = new ReadableStream({
|
|
501
|
+
async start(controller) {
|
|
502
|
+
try {
|
|
503
|
+
for await (const event of broker.subscribe()) {
|
|
504
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
|
505
|
+
}
|
|
506
|
+
} finally {
|
|
507
|
+
controller.close()
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return new Response(stream, {
|
|
513
|
+
headers: {
|
|
514
|
+
"Content-Type": "text/event-stream",
|
|
515
|
+
"Cache-Control": "no-cache",
|
|
516
|
+
Connection: "keep-alive",
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
})
|
|
546
520
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
521
|
+
apiApp.get("/runs/:id/nodes", async (c) => {
|
|
522
|
+
const runId = Number(c.req.param("id"))
|
|
523
|
+
return c.json(await runtime.outputs.listOrdered(runId))
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
apiApp.get("/runs/:id/logs", async (c) => {
|
|
527
|
+
const runId = Number(c.req.param("id"))
|
|
528
|
+
return c.json(await runtime.logs.list(runId))
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const app = new Hono()
|
|
532
|
+
app.use("*", options.corsOrigins !== undefined ? cors({ origin: options.corsOrigins }) : cors())
|
|
533
|
+
app.route("/api", apiApp)
|
|
534
|
+
|
|
535
|
+
if (!options.disableUi) {
|
|
536
|
+
const { distDir } = await import("@toist/ui")
|
|
537
|
+
const indexPath = join(distDir, "index.html")
|
|
538
|
+
if (!existsSync(indexPath)) {
|
|
539
|
+
console.warn(
|
|
540
|
+
`[runner] UI dist not found at ${distDir}; skipping static mount. ` +
|
|
541
|
+
"Run `bun run build` in @toist/ui to populate it.",
|
|
542
|
+
)
|
|
543
|
+
} else {
|
|
544
|
+
const respond = (file: ReturnType<typeof Bun.file>) =>
|
|
545
|
+
new Response(file, {
|
|
546
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
app.get("/*", async (c) => {
|
|
550
|
+
const url = new URL(c.req.url).pathname
|
|
551
|
+
const tryPath = url === "/" ? "/index.html" : url
|
|
552
|
+
const file = Bun.file(join(distDir, tryPath))
|
|
553
|
+
if (await file.exists()) return respond(file)
|
|
554
|
+
if (!tryPath.includes(".")) return respond(Bun.file(indexPath))
|
|
555
|
+
return c.notFound()
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
}
|
|
550
559
|
|
|
551
|
-
|
|
560
|
+
return app
|
|
561
|
+
}
|