@toist/aja 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to `@toist/aja` are recorded here.
4
4
 
5
+ ## 0.7.1 — 2026-05-06
6
+
7
+ Fix @toist/aja cross-package dependency versions not being bumped by release script
8
+
9
+ ## 0.7.0 — 2026-05-06
10
+
11
+ Add @toist/aja CLI (bunx @toist/aja --config toist.yml) and @toist/in interactive setup wizard (bunx @toist/in). Establishes aja=runner, in=adoption tool split. No forced directory layout — user chooses paths via wizard, config drives the runner.
12
+
13
+ ## Unreleased
14
+
15
+ ## 0.6.1 — 2026-05-05
16
+
17
+ Fix published @toist/aja dependency resolution for embedded runners and include the typed runner client/smoke-test surface.
18
+
19
+ - Added `createRunnerClient()` typed HTTP adapter, exported from
20
+ `@toist/aja` and `@toist/aja/client`, for driving runner instances
21
+ without hard-coding API paths.
22
+ - Added `smoke:client` script that starts an embedded runner, drives it via
23
+ the client, and verifies persisted node outputs.
24
+ - Changed published package dependencies on `@toist/spec` and `@toist/ui`
25
+ from workspace protocol to concrete `0.6.1` versions so external embeds
26
+ install transitive runtime packages correctly.
27
+ - Documented the v1 embedded-instance limit: one runner per process.
28
+
5
29
  ## 0.6.0 — 2026-05-05
6
30
 
7
31
  Lockstep version bump alongside `@toist/in@0.6.0`. No functional changes
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@toist/aja",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
+ "bin": {
8
+ "toist-aja": "./src/cli.ts"
9
+ },
7
10
  "exports": {
8
- ".": "./src/index.ts"
11
+ ".": "./src/index.ts",
12
+ "./client": "./src/client.ts"
9
13
  },
10
14
  "files": [
11
15
  "src/",
@@ -13,13 +17,15 @@
13
17
  "CHANGELOG.md"
14
18
  ],
15
19
  "scripts": {
16
- "dev": "bun --watch src/server.ts"
20
+ "dev": "bun --watch src/server.ts",
21
+ "smoke:client": "bun test/client-smoke.ts"
17
22
  },
18
23
  "dependencies": {
19
- "@toist/spec": "0.6.0",
20
- "@toist/ui": "0.6.0",
24
+ "@toist/spec": "0.7.1",
25
+ "@toist/ui": "0.7.1",
21
26
  "hono": "^4.7.7",
22
- "proper-lockfile": "^4.1.2"
27
+ "proper-lockfile": "^4.1.2",
28
+ "yaml": "^2.8.4"
23
29
  },
24
30
  "devDependencies": {
25
31
  "@types/proper-lockfile": "^4.1.4"
package/src/cli.ts ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ // 2121 toist
3
+ // @toist/aja CLI — start a Toist runner from a config file.
4
+ //
5
+ // Usage:
6
+ // bunx @toist/aja auto-discover toist.yml in cwd
7
+ // bunx @toist/aja --config <path> explicit config file
8
+
9
+ import { existsSync, readFileSync } from "node:fs"
10
+ import { dirname, isAbsolute, resolve } from "node:path"
11
+ import YAML from "yaml"
12
+ import { register, startRunner } from "./index.ts"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Config schema
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface ToistConfig {
19
+ /** TCP port. Env PORT or TOIST_PORT takes precedence. */
20
+ port?: number
21
+ /** Root directory for default path resolution. Defaults to config file dir. */
22
+ root?: string
23
+ /** Pipeline YAML directory. Default: <root>/pipelines */
24
+ pipelines?: string
25
+ /** Resource YAML directory. Default: <root>/resources */
26
+ resources?: string
27
+ /** Data directory (SQLite files). Default: <root>/data */
28
+ data?: string
29
+ /** TypeScript kind files to import and register before starting. */
30
+ kinds?: string[]
31
+ /** Disable the bundled UI. */
32
+ disableUi?: boolean
33
+ /** Disable filesystem watch on pipelines/resources. */
34
+ disableWatch?: boolean
35
+ /** Disable the MCP server. */
36
+ disableMcp?: boolean
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // CLI args
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const args = process.argv.slice(2)
44
+
45
+ if (args.includes("--help") || args.includes("-h")) {
46
+ console.log("Usage:")
47
+ console.log(" bunx @toist/aja auto-discover toist.yml in cwd")
48
+ console.log(" bunx @toist/aja --config <path> explicit config file")
49
+ console.log("")
50
+ console.log("Config file format (YAML):")
51
+ console.log(" port: 3000")
52
+ console.log(" root: . # base for all relative paths")
53
+ console.log(" pipelines: ./pipelines # override pipeline dir")
54
+ console.log(" resources: ./resources # override resource dir")
55
+ console.log(" data: ./data # override data dir")
56
+ console.log(" kinds: # TypeScript kind files to register")
57
+ console.log(" - ./src/kinds/index.ts")
58
+ console.log("")
59
+ console.log("Environment:")
60
+ console.log(" TOIST_PORT / PORT Override port from config")
61
+ console.log("")
62
+ console.log("Full docs: https://toist.in")
63
+ process.exit(0)
64
+ }
65
+
66
+ const configIdx = args.indexOf("--config")
67
+ const configArg = configIdx !== -1 ? args[configIdx + 1] : null
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Config discovery
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const CONFIG_NAMES = ["toist.yml", "toist.yaml"]
74
+
75
+ function findConfig(): string {
76
+ if (configArg) {
77
+ const p = resolve(process.cwd(), configArg)
78
+ if (!existsSync(p)) {
79
+ console.error(`Config file not found: ${p}`)
80
+ process.exit(1)
81
+ }
82
+ return p
83
+ }
84
+ for (const name of CONFIG_NAMES) {
85
+ const p = resolve(process.cwd(), name)
86
+ if (existsSync(p)) return p
87
+ }
88
+ console.error("No toist.yml found in current directory.")
89
+ console.error("")
90
+ console.error("Create one, or use: bunx @toist/aja --config <path>")
91
+ console.error("To scaffold a new Toist setup: bunx @toist/in")
92
+ process.exit(1)
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Path resolution — all paths relative to config file directory
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function resolvePath(configDir: string, root: string, p: string | undefined, defaultSub: string): string {
100
+ if (p) return isAbsolute(p) ? p : resolve(configDir, p)
101
+ return resolve(configDir, root, defaultSub)
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Main
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const configPath = findConfig()
109
+ const configDir = dirname(configPath)
110
+ const raw = readFileSync(configPath, "utf8")
111
+ const cfg = (YAML.parse(raw) ?? {}) as ToistConfig
112
+
113
+ const root = cfg.root ? resolve(configDir, cfg.root) : configDir
114
+
115
+ const pipelineDir = resolvePath(configDir, root, cfg.pipelines, "pipelines")
116
+ const resourceDir = resolvePath(configDir, root, cfg.resources, "resources")
117
+ const dataDir = resolvePath(configDir, root, cfg.data, "data")
118
+ const port = Number(process.env.TOIST_PORT ?? process.env.PORT ?? cfg.port ?? 3000)
119
+
120
+ // Register kinds from config — dynamic imports resolved relative to config dir.
121
+ if (cfg.kinds && cfg.kinds.length > 0) {
122
+ for (const kindPath of cfg.kinds) {
123
+ const abs = isAbsolute(kindPath) ? kindPath : resolve(configDir, kindPath)
124
+ if (!existsSync(abs)) {
125
+ console.error(`Kind file not found: ${abs}`)
126
+ process.exit(1)
127
+ }
128
+ const mod = await import(abs)
129
+ const kinds = Object.values(mod).filter(Boolean)
130
+ if (kinds.length === 0) {
131
+ console.warn(`[toist] warning: no exports found in kind file ${abs}`)
132
+ } else {
133
+ register(...(kinds as Parameters<typeof register>))
134
+ }
135
+ }
136
+ }
137
+
138
+ await startRunner({
139
+ port,
140
+ rootDir: root,
141
+ pipelineDir,
142
+ resourceDir,
143
+ dataDir,
144
+ disableUi: cfg.disableUi ?? false,
145
+ disableWatch: cfg.disableWatch ?? false,
146
+ disableMcp: cfg.disableMcp ?? false,
147
+ })
package/src/client.ts ADDED
@@ -0,0 +1,305 @@
1
+ // 2121
2
+ // Typed HTTP client for a running @toist/aja instance.
3
+ //
4
+ // This is the public de-facto adapter for hosts (Pi, tests, external tools)
5
+ // that want to drive the runner over its HTTP API without hard-coding paths.
6
+
7
+ import type { NodeKindManifest, ValidateResult } from "@toist/spec"
8
+
9
+ export interface RunnerClientOptions {
10
+ /** Runner API base URL. Accepts either the API root
11
+ * (`http://localhost:2132/api`) or the runner root
12
+ * (`http://localhost:2132`); the client normalizes to `/api`. */
13
+ baseUrl: string
14
+ /** Optional fetch implementation for tests or non-Bun runtimes. */
15
+ fetch?: typeof fetch
16
+ /** Extra headers sent with every request (e.g. auth). */
17
+ headers?: HeadersInit
18
+ }
19
+
20
+ export class RunnerHttpError extends Error {
21
+ constructor(
22
+ public readonly status: number,
23
+ public readonly statusText: string,
24
+ public readonly body: unknown,
25
+ ) {
26
+ super(`Runner HTTP ${status} ${statusText}`)
27
+ this.name = "RunnerHttpError"
28
+ }
29
+ }
30
+
31
+ export interface PipelineSummary {
32
+ id: string
33
+ label: string
34
+ description?: string
35
+ nodeCount: number
36
+ }
37
+
38
+ export interface PipelineSource {
39
+ id: string
40
+ yaml: string
41
+ }
42
+
43
+ export interface PipelineSaveResult {
44
+ id?: string
45
+ ok?: boolean
46
+ errors?: string[]
47
+ error?: string
48
+ [key: string]: unknown
49
+ }
50
+
51
+ export interface StepResult {
52
+ id: string
53
+ kind: string
54
+ label: string
55
+ status: "complete" | "failed"
56
+ params?: Record<string, unknown>
57
+ input?: Record<string, unknown>
58
+ output?: unknown
59
+ error?: string
60
+ duration_ms: number
61
+ }
62
+
63
+ export interface PipelineTaskRef {
64
+ id: number
65
+ kind: string
66
+ prompt: string
67
+ schema: unknown
68
+ assignee: string | null
69
+ responseToken: string
70
+ }
71
+
72
+ export type PipelineRunResponse =
73
+ | { id: number; pipeline: string; status: "done"; result: unknown; steps: StepResult[] }
74
+ | { id: number; pipeline: string; status: "suspended"; suspendedAt: string; task: PipelineTaskRef; steps: StepResult[] }
75
+ | { id: number; pipeline: string; status: "error"; error: string }
76
+
77
+ export interface RunListItem {
78
+ id: number
79
+ pipeline: string
80
+ status: string
81
+ payload: string | null
82
+ result: string | null
83
+ steps: string | null
84
+ error: string | null
85
+ started_at: string
86
+ finished_at?: string | null
87
+ current_node?: string | null
88
+ updated_at?: string | null
89
+ trigger?: string
90
+ }
91
+
92
+ export interface RunNodeOutput {
93
+ nodeId: string
94
+ output: unknown
95
+ startedAt: string
96
+ finishedAt: string
97
+ }
98
+
99
+ export interface RunLogLine {
100
+ id: number
101
+ ts: string
102
+ level: "info" | "warn" | "error" | string
103
+ msg: string | null
104
+ }
105
+
106
+ export interface TaskListItem {
107
+ id: number
108
+ run_id: number
109
+ node_id: string
110
+ kind: string
111
+ prompt: string | null
112
+ assignee: string | null
113
+ status: "open" | "answered" | "expired" | "cancelled"
114
+ created_at: string
115
+ responded_at: string | null
116
+ pipeline: string | null
117
+ }
118
+
119
+ export interface TaskFull {
120
+ id: number
121
+ runId: number
122
+ pipeline: string | null
123
+ nodeId: string
124
+ kind: string
125
+ prompt: string
126
+ schema: unknown
127
+ assignee: string | null
128
+ responseToken: string
129
+ status: "open" | "answered" | "expired" | "cancelled"
130
+ }
131
+
132
+ export interface ResourceTypeSummary {
133
+ name: string
134
+ description?: string
135
+ schema: Record<string, unknown>
136
+ }
137
+
138
+ export interface ResourceRecord {
139
+ id: number
140
+ name: string
141
+ type: string
142
+ fields: Record<string, unknown>
143
+ created_at: string
144
+ updated_at: string
145
+ }
146
+
147
+ export interface RunnerClient {
148
+ instance: {
149
+ root(): Promise<unknown>
150
+ get(): Promise<unknown>
151
+ }
152
+ kinds: {
153
+ list(): Promise<{ kinds: NodeKindManifest[] }>
154
+ invoke(id: string, body?: { params?: Record<string, unknown>; input?: Record<string, unknown>; confirm?: boolean }): Promise<unknown>
155
+ }
156
+ pipelines: {
157
+ list(): Promise<PipelineSummary[]>
158
+ get(id: string): Promise<unknown>
159
+ source(id: string): Promise<PipelineSource>
160
+ validate(input: { yaml: string } | { spec: unknown }): Promise<ValidateResult>
161
+ create(yaml: string): Promise<PipelineSaveResult>
162
+ update(id: string, yaml: string): Promise<PipelineSaveResult>
163
+ run(id: string, payload?: Record<string, unknown>): Promise<PipelineRunResponse>
164
+ }
165
+ runs: {
166
+ list(opts?: { pipeline?: string; limit?: number }): Promise<RunListItem[]>
167
+ get(id: number): Promise<RunListItem>
168
+ nodes(id: number): Promise<RunNodeOutput[]>
169
+ logs(id: number): Promise<RunLogLine[]>
170
+ }
171
+ tasks: {
172
+ list(opts?: { status?: string; runId?: number; assignee?: string; limit?: number }): Promise<TaskListItem[]>
173
+ get(id: number): Promise<TaskFull>
174
+ respond(runId: number, taskId: number, body: { token: string; response: unknown; respondedBy?: string }): Promise<PipelineRunResponse>
175
+ }
176
+ resources: {
177
+ types(): Promise<ResourceTypeSummary[]>
178
+ list(): Promise<ResourceRecord[]>
179
+ get(name: string): Promise<ResourceRecord>
180
+ upsert(name: string, type: string, fields: Record<string, unknown>): Promise<ResourceRecord>
181
+ put(name: string, body: { type?: string; fields?: Record<string, unknown> }): Promise<ResourceRecord>
182
+ delete(name: string): Promise<void>
183
+ }
184
+ }
185
+
186
+ export function createRunnerClient(options: RunnerClientOptions): RunnerClient {
187
+ const base = normalizeBaseUrl(options.baseUrl)
188
+ const f = options.fetch ?? fetch
189
+
190
+ async function request<T>(path: string, init: RequestInit = {}, opts: { allowErrorJson?: boolean } = {}): Promise<T> {
191
+ const headers = new Headers(options.headers)
192
+ if (init.body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json")
193
+ if (init.headers) new Headers(init.headers).forEach((v, k) => headers.set(k, v))
194
+
195
+ const r = await f(`${base}${path}`, { ...init, headers })
196
+ const body = await readJsonOrText(r)
197
+ if (!r.ok && !opts.allowErrorJson) throw new RunnerHttpError(r.status, r.statusText, body)
198
+ return body as T
199
+ }
200
+
201
+ const json = (value: unknown) => JSON.stringify(value ?? {})
202
+
203
+ return {
204
+ instance: {
205
+ root: () => request<unknown>("/"),
206
+ get: () => request<unknown>("/instance"),
207
+ },
208
+
209
+ kinds: {
210
+ list: () => request<{ kinds: NodeKindManifest[] }>("/manifest"),
211
+ invoke: (id, body = {}) => request<unknown>(`/kinds/${enc(id)}/invoke`, {
212
+ method: "POST",
213
+ body: json(body),
214
+ }),
215
+ },
216
+
217
+ pipelines: {
218
+ list: () => request<PipelineSummary[]>("/pipelines"),
219
+ get: (id) => request<unknown>(`/pipelines/${enc(id)}`),
220
+ source: (id) => request<PipelineSource>(`/pipelines/${enc(id)}/source`),
221
+ validate: (input) => request<ValidateResult>("/pipelines/validate", {
222
+ method: "POST",
223
+ body: json(input),
224
+ }, { allowErrorJson: true }),
225
+ create: (yaml) => request<PipelineSaveResult>("/pipelines", {
226
+ method: "POST",
227
+ body: json({ yaml }),
228
+ }),
229
+ update: (id, yaml) => request<PipelineSaveResult>(`/pipelines/${enc(id)}`, {
230
+ method: "PUT",
231
+ body: json({ yaml }),
232
+ }),
233
+ run: (id, payload = {}) => request<PipelineRunResponse>(`/pipelines/${enc(id)}/run`, {
234
+ method: "POST",
235
+ body: json(payload),
236
+ }),
237
+ },
238
+
239
+ runs: {
240
+ list: (opts = {}) => request<RunListItem[]>(`/runs${query({ pipeline: opts.pipeline, limit: opts.limit })}`),
241
+ get: (id) => request<RunListItem>(`/runs/${id}`),
242
+ nodes: (id) => request<RunNodeOutput[]>(`/runs/${id}/nodes`),
243
+ logs: (id) => request<RunLogLine[]>(`/runs/${id}/logs`),
244
+ },
245
+
246
+ tasks: {
247
+ list: (opts = {}) => request<TaskListItem[]>(`/tasks${query({
248
+ status: opts.status,
249
+ run_id: opts.runId,
250
+ assignee: opts.assignee,
251
+ limit: opts.limit,
252
+ })}`),
253
+ get: (id) => request<TaskFull>(`/tasks/${id}`),
254
+ respond: (runId, taskId, body) => request<PipelineRunResponse>(`/runs/${runId}/tasks/${taskId}/respond`, {
255
+ method: "POST",
256
+ body: json(body),
257
+ }),
258
+ },
259
+
260
+ resources: {
261
+ types: () => request<ResourceTypeSummary[]>("/resource-types"),
262
+ list: () => request<ResourceRecord[]>("/resources"),
263
+ get: (name) => request<ResourceRecord>(`/resources/${enc(name)}`),
264
+ upsert: (name, type, fields) => request<ResourceRecord>("/resources", {
265
+ method: "POST",
266
+ body: json({ name, type, fields }),
267
+ }),
268
+ put: (name, body) => request<ResourceRecord>(`/resources/${enc(name)}`, {
269
+ method: "PUT",
270
+ body: json(body),
271
+ }),
272
+ delete: async (name) => {
273
+ await request<unknown>(`/resources/${enc(name)}`, { method: "DELETE" })
274
+ },
275
+ },
276
+ }
277
+ }
278
+
279
+ function normalizeBaseUrl(input: string): string {
280
+ const trimmed = input.replace(/\/+$/, "")
281
+ return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`
282
+ }
283
+
284
+ function enc(value: string): string {
285
+ return encodeURIComponent(value)
286
+ }
287
+
288
+ function query(values: Record<string, string | number | undefined>): string {
289
+ const q = new URLSearchParams()
290
+ for (const [k, v] of Object.entries(values)) {
291
+ if (v !== undefined) q.set(k, String(v))
292
+ }
293
+ const s = q.toString()
294
+ return s ? `?${s}` : ""
295
+ }
296
+
297
+ async function readJsonOrText(response: Response): Promise<unknown> {
298
+ if (response.status === 204) return null
299
+ const text = await response.text()
300
+ if (!text) return null
301
+ const ct = response.headers.get("content-type") ?? ""
302
+ if (ct.includes("application/json")) return JSON.parse(text)
303
+ try { return JSON.parse(text) }
304
+ catch { return text }
305
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,27 @@
10
10
  // Lifecycle entry — what hosts call to start an instance.
11
11
  export { startRunner, type StartRunnerOptions, type RunnerHandle } from "./startRunner.ts"
12
12
 
13
+ // Typed HTTP client — what external hosts/tools use to drive an instance.
14
+ export {
15
+ createRunnerClient,
16
+ RunnerHttpError,
17
+ type RunnerClient,
18
+ type RunnerClientOptions,
19
+ type PipelineSummary,
20
+ type PipelineSource,
21
+ type PipelineSaveResult,
22
+ type StepResult,
23
+ type PipelineTaskRef,
24
+ type PipelineRunResponse,
25
+ type RunListItem,
26
+ type RunNodeOutput,
27
+ type RunLogLine,
28
+ type TaskListItem,
29
+ type TaskFull,
30
+ type ResourceTypeSummary,
31
+ type ResourceRecord,
32
+ } from "./client.ts"
33
+
13
34
  // Kind registration — must be called before startRunner.
14
35
  export { register, getKind, manifest } from "./kinds/index.ts"
15
36
 
package/src/pipeline.ts CHANGED
@@ -305,6 +305,10 @@ export async function runPipeline(
305
305
  if (o.result.kind === "complete") {
306
306
  results[winner.id] = o.result.output
307
307
  done.add(winner.id)
308
+ // Persist every completed node output, not only pre-suspend checkpoints.
309
+ // This keeps /runs/:id/nodes and runs.nodeOutput useful for normal done
310
+ // runs, while preserving the same resume-skip storage semantics.
311
+ persistNodeOutputs(runtimeDb(), baseCtx.runId, { [winner.id]: o.result.output })
308
312
  steps.push({
309
313
  id: winner.id, kind: node.kind, label, status: "complete",
310
314
  params: o.params, input: o.input, output: o.result.output,
package/src/server.ts CHANGED
@@ -351,7 +351,7 @@ apiApp.get("/tasks", (c) => {
351
351
  const limit = Number(c.req.query("limit") ?? 200)
352
352
 
353
353
  const where: string[] = []
354
- const args: unknown[] = []
354
+ const args: Array<string | number> = []
355
355
  if (status !== "all") { where.push("t.status = ?"); args.push(status) }
356
356
  if (assignee) { where.push("t.assignee = ?"); args.push(assignee) }
357
357
  if (runId) { where.push("t.run_id = ?"); args.push(Number(runId)) }
@@ -464,6 +464,19 @@ apiApp.get("/runs/:id", (c) => {
464
464
  return c.json(row)
465
465
  })
466
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
+
467
480
  apiApp.get("/runs/:id/logs", (c) => {
468
481
  const runId = Number(c.req.param("id"))
469
482
  const rows = runtimeDb().prepare(
@@ -473,7 +486,8 @@ apiApp.get("/runs/:id/logs", (c) => {
473
486
  })
474
487
 
475
488
  // ─── lifecycle ───────────────────────────────────────────────────────────────
476
- setInterval(() => cache.prune(), 5 * 60 * 1000)
489
+ const cachePruneTimer = setInterval(() => cache.prune(), 5 * 60 * 1000)
490
+ cachePruneTimer.unref?.()
477
491
 
478
492
  const PORT = Number(process.env.PORT ?? 2132)
479
493
 
@@ -61,6 +61,10 @@ export interface RunnerHandle {
61
61
  }
62
62
 
63
63
  export async function startRunner(options: StartRunnerOptions): Promise<RunnerHandle> {
64
+ // v1 embedded limit: one runner per process. config/db/registry/server
65
+ // modules keep process-global state; hosts that need multiple isolated
66
+ // runners should spawn separate processes until createRunnerInstance-style
67
+ // state encapsulation lands.
64
68
  setRootDir(options.rootDir)
65
69
  setDisableUi(options.disableUi ?? false)
66
70
  setDisableWatch(options.disableWatch ?? false)
@@ -74,11 +78,12 @@ export async function startRunner(options: StartRunnerOptions): Promise<RunnerHa
74
78
  const mod = await import("./server.ts")
75
79
  const fetch = mod.fetch as (req: Request) => Response | Promise<Response>
76
80
 
77
- const server: Server = Bun.serve({ port: options.port, fetch })
78
- console.log(`[runner] http://localhost:${server.port}`)
81
+ const server: Server<undefined> = Bun.serve({ port: options.port, fetch })
82
+ const port = server.port ?? options.port
83
+ console.log(`[runner] http://localhost:${port}`)
79
84
 
80
85
  return {
81
- port: server.port,
86
+ port,
82
87
  async stop() {
83
88
  server.stop()
84
89
  await closeDbs()