@toist/aja 0.6.0 → 0.6.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,22 @@
2
2
 
3
3
  All notable changes to `@toist/aja` are recorded here.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.6.1 — 2026-05-05
8
+
9
+ Fix published @toist/aja dependency resolution for embedded runners and include the typed runner client/smoke-test surface.
10
+
11
+ - Added `createRunnerClient()` typed HTTP adapter, exported from
12
+ `@toist/aja` and `@toist/aja/client`, for driving runner instances
13
+ without hard-coding API paths.
14
+ - Added `smoke:client` script that starts an embedded runner, drives it via
15
+ the client, and verifies persisted node outputs.
16
+ - Changed published package dependencies on `@toist/spec` and `@toist/ui`
17
+ from workspace protocol to concrete `0.6.1` versions so external embeds
18
+ install transitive runtime packages correctly.
19
+ - Documented the v1 embedded-instance limit: one runner per process.
20
+
5
21
  ## 0.6.0 — 2026-05-05
6
22
 
7
23
  Lockstep version bump alongside `@toist/in@0.6.0`. No functional changes
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@toist/aja",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
- ".": "./src/index.ts"
8
+ ".": "./src/index.ts",
9
+ "./client": "./src/client.ts"
9
10
  },
10
11
  "files": [
11
12
  "src/",
@@ -13,11 +14,12 @@
13
14
  "CHANGELOG.md"
14
15
  ],
15
16
  "scripts": {
16
- "dev": "bun --watch src/server.ts"
17
+ "dev": "bun --watch src/server.ts",
18
+ "smoke:client": "bun test/client-smoke.ts"
17
19
  },
18
20
  "dependencies": {
19
- "@toist/spec": "0.6.0",
20
- "@toist/ui": "0.6.0",
21
+ "@toist/spec": "0.6.1",
22
+ "@toist/ui": "0.6.1",
21
23
  "hono": "^4.7.7",
22
24
  "proper-lockfile": "^4.1.2"
23
25
  },
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()