@toist/aja 0.7.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 CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  All notable changes to `@toist/aja` are recorded here.
4
4
 
5
+ ## 0.8.0 — 2026-05-08
6
+
7
+ Major refactor: `@toist/aja` is now the SQLite host package, with the
8
+ execution kernel extracted to `@toist/core`.
9
+
10
+ **New:**
11
+
12
+ - `createSqliteRuntime({ rootDir, ... })` — durable runtime constructor.
13
+ Owns lock acquisition, migrations, db handle opening, store wiring,
14
+ registry projection, resource resolution. Returns a `ToistRuntime`
15
+ with `close()`.
16
+ - `mountApi(runtime, options): Hono` factory replaces module-level
17
+ state in `server.ts`. `startRunner` builds a runtime then calls
18
+ `mountApi`.
19
+ - `loadFilesystemResources(rootDir)` — reads `toist.yml`, performs
20
+ `${env:VAR}` and `${env:VAR:-default}` substitution, fails closed
21
+ at config-load time on missing required vars.
22
+ - `kindAllowlist` field in `instance.json` filters the registry
23
+ projection at construction time. Glob support (`agent.*`).
24
+ - `StartRunnerOptions.storage: "sqlite" | "memory"` flag.
25
+ - `StartRunnerOptions.runtime?: ToistRuntime` late-bound injection.
26
+ - `HostedRuntime` interface (extends `ToistRuntime` with optional
27
+ `close`, `adminDb`, `pipelinesDir`).
28
+ - `createFilesystemPipelineStore({ rootDir, registry, watch })` for
29
+ YAML pipeline loading + watching.
30
+ - `POST /api/runs` async ad-hoc spec-run endpoint.
31
+ - `GET /api/runs/:id/events` SSE event stream.
32
+ - `client.pipelines.runSpec(spec, payload)` and `client.runs.events(runId)`.
33
+ - `/manifest` response carries `version` + `schema` URL for stable
34
+ downstream consumption.
35
+
36
+ **Removed:**
37
+
38
+ - `db-handles.ts`, `config.ts` (process-global `setRootDir` etc.),
39
+ `runtime.ts` (`createProcessRuntime`). Replaced by explicit
40
+ `createSqliteRuntime`.
41
+ - Local `pipeline.ts`, `hitl.ts`, `runs.ts`, `kinds/{control,data,db,hitl,
42
+ http,runs,transform,types}.ts` — all moved to `@toist/core`.
43
+ `@toist/aja` re-exports the kernel surface.
44
+ - Filesystem `resources/*.yaml` loader (replaced by `toist.yml`).
45
+
46
+ **Renamed:**
47
+
48
+ - `instance.ts` → `instance-metadata.ts` (frees the name for a possible
49
+ future `@toist/instance` package).
50
+
51
+ **New dependency:** `@toist/core@0.8.0`.
52
+
5
53
  ## 0.7.1 — 2026-05-06
6
54
 
7
55
  Fix @toist/aja cross-package dependency versions not being bumped by release script
@@ -77,7 +125,7 @@ monorepo per Phase F W2 and shipped to the Gitea npm registry per W3.
77
125
 
78
126
  - Public API: `startRunner(options): Promise<RunnerHandle>`,
79
127
  `register(...kinds)`, `getKind(id)`, `manifest()`. See instance-spec at
80
- https://toist.in for the full contract.
128
+ https://toist.in/docs for the full contract.
81
129
  - HTTP server (Hono on Bun) — mounts API at `/api/*` and serves the
82
130
  bundled UI from `@toist/ui`'s `distDir` at `/`.
83
131
  - Pipeline dispatcher with HITL-aware suspendable executor (per-node
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # `@toist/aja`
2
+
3
+ Durable Toist host surface: SQLite runtime, HTTP runner, UI mount, filesystem helpers, and the typed runner client.
4
+
5
+ ```ts
6
+ import { startRunner } from "@toist/aja"
7
+
8
+ await startRunner({
9
+ rootDir: import.meta.dir,
10
+ port: 3000,
11
+ })
12
+ ```
13
+
14
+ ## Main exports
15
+
16
+ - `startRunner(options)`
17
+ - `createSqliteRuntime(options)`
18
+ - `createFilesystemPipelineStore({ rootDir, watch? })`
19
+ - `loadFilesystemResources(rootDir)`
20
+ - `createRunnerClient({ baseUrl })`
21
+ - convenience re-exports from `@toist/core`
22
+
23
+ ## Client quickstart
24
+
25
+ ```ts
26
+ import { createRunnerClient } from "@toist/aja"
27
+
28
+ const client = createRunnerClient({ baseUrl: "http://localhost:3000" })
29
+ const started = await client.pipelines.runSpec(spec, { name: "world" })
30
+ for await (const event of client.runs.events(started.id)) {
31
+ console.log(event.type)
32
+ }
33
+ ```
34
+
35
+ ## Runtime layout
36
+
37
+ With `startRunner({ rootDir, port })`, the runner:
38
+
39
+ - loads pipelines from `<rootDir>/pipelines`
40
+ - stores runtime state under `<rootDir>/data`
41
+ - reads resources from `<rootDir>/toist.yml`
42
+ - serves UI + API from one Bun process
43
+
44
+ ## `/manifest`
45
+
46
+ `GET /api/manifest` returns stable JSON for tooling:
47
+
48
+ ```json
49
+ {
50
+ "version": 1,
51
+ "schema": "https://toist.in/schemas/manifest-v1.json",
52
+ "kinds": []
53
+ }
54
+ ```
55
+
56
+ ## Resources
57
+
58
+ `loadFilesystemResources(rootDir)` reads `toist.yml` and resolves `${env:VAR}` and `${env:VAR:-fallback}` substitutions.
59
+
60
+ ## When to use `@toist/aja`
61
+
62
+ Use it when you want a durable single-tenant runner with HTTP APIs. For in-process execution without the server, import `@toist/core` directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toist/aja",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,15 +14,17 @@
14
14
  "files": [
15
15
  "src/",
16
16
  "migrations/",
17
- "CHANGELOG.md"
17
+ "CHANGELOG.md",
18
+ "README.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "dev": "bun --watch src/server.ts",
21
22
  "smoke:client": "bun test/client-smoke.ts"
22
23
  },
23
24
  "dependencies": {
24
- "@toist/spec": "0.7.1",
25
- "@toist/ui": "0.7.1",
25
+ "@toist/core": "0.8.0",
26
+ "@toist/spec": "0.8.0",
27
+ "@toist/ui": "0.8.0",
26
28
  "hono": "^4.7.7",
27
29
  "proper-lockfile": "^4.1.2",
28
30
  "yaml": "^2.8.4"
package/src/cache-db.ts CHANGED
@@ -1,17 +1,9 @@
1
1
  // 2121
2
- // Cache database — separate file from runtime.db so its drop-recreate
3
- // semantics (TTL makes content valueless) do not entangle runtime durability.
4
- //
5
- // Schema is owned and bootstrapped by makeCache() in cache.ts. No migrations
6
- // here: cache table can be dropped or recreated freely.
7
-
8
2
  import { Database } from "bun:sqlite"
9
3
  import { mkdirSync } from "node:fs"
10
4
  import { dirname } from "node:path"
11
- import { cacheDbPath } from "./config.ts"
12
5
 
13
- export function openCacheDb(): Database {
14
- const path = cacheDbPath()
6
+ export function openCacheDb(path: string): Database {
15
7
  mkdirSync(dirname(path), { recursive: true })
16
8
  return new Database(path, { create: true })
17
9
  }
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readFileSync } from "node:fs"
10
10
  import { dirname, isAbsolute, resolve } from "node:path"
11
11
  import YAML from "yaml"
12
- import { register, startRunner } from "./index.ts"
12
+ import { createSqliteRuntime, register, startRunner } from "./index.ts"
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Config schema
@@ -22,8 +22,8 @@ interface ToistConfig {
22
22
  root?: string
23
23
  /** Pipeline YAML directory. Default: <root>/pipelines */
24
24
  pipelines?: string
25
- /** Resource YAML directory. Default: <root>/resources */
26
- resources?: string
25
+ /** Resource path override (legacy). Default: <root>/resources */
26
+ resources?: string | Record<string, Record<string, unknown>>
27
27
  /** Data directory (SQLite files). Default: <root>/data */
28
28
  data?: string
29
29
  /** TypeScript kind files to import and register before starting. */
@@ -59,7 +59,7 @@ if (args.includes("--help") || args.includes("-h")) {
59
59
  console.log("Environment:")
60
60
  console.log(" TOIST_PORT / PORT Override port from config")
61
61
  console.log("")
62
- console.log("Full docs: https://toist.in")
62
+ console.log("Full docs: https://toist.in/docs")
63
63
  process.exit(0)
64
64
  }
65
65
 
@@ -113,7 +113,7 @@ const cfg = (YAML.parse(raw) ?? {}) as ToistConfig
113
113
  const root = cfg.root ? resolve(configDir, cfg.root) : configDir
114
114
 
115
115
  const pipelineDir = resolvePath(configDir, root, cfg.pipelines, "pipelines")
116
- const resourceDir = resolvePath(configDir, root, cfg.resources, "resources")
116
+ const resourceDir = resolvePath(configDir, root, typeof cfg.resources === "string" ? cfg.resources : undefined, "resources")
117
117
  const dataDir = resolvePath(configDir, root, cfg.data, "data")
118
118
  const port = Number(process.env.TOIST_PORT ?? process.env.PORT ?? cfg.port ?? 3000)
119
119
 
@@ -135,13 +135,19 @@ if (cfg.kinds && cfg.kinds.length > 0) {
135
135
  }
136
136
  }
137
137
 
138
+ void cfg.disableMcp
139
+
140
+ const runtime = await createSqliteRuntime({
141
+ rootDir: root,
142
+ pipelinesDir: pipelineDir,
143
+ resourcesDir: resourceDir,
144
+ dataDir,
145
+ })
146
+
138
147
  await startRunner({
139
148
  port,
140
- rootDir: root,
141
- pipelineDir,
142
- resourceDir,
143
- dataDir,
144
- disableUi: cfg.disableUi ?? false,
149
+ rootDir: root,
150
+ runtime,
151
+ disableUi: cfg.disableUi ?? false,
145
152
  disableWatch: cfg.disableWatch ?? false,
146
- disableMcp: cfg.disableMcp ?? false,
147
153
  })
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // that want to drive the runner over its HTTP API without hard-coding paths.
6
6
 
7
7
  import type { NodeKindManifest, ValidateResult } from "@toist/spec"
8
+ import type { ToistEvent } from "@toist/core"
8
9
 
9
10
  export interface RunnerClientOptions {
10
11
  /** Runner API base URL. Accepts either the API root
@@ -74,6 +75,12 @@ export type PipelineRunResponse =
74
75
  | { id: number; pipeline: string; status: "suspended"; suspendedAt: string; task: PipelineTaskRef; steps: StepResult[] }
75
76
  | { id: number; pipeline: string; status: "error"; error: string }
76
77
 
78
+ export interface AsyncRunStartResponse {
79
+ id: number
80
+ pipeline: string
81
+ status: "running"
82
+ }
83
+
77
84
  export interface RunListItem {
78
85
  id: number
79
86
  pipeline: string
@@ -161,12 +168,14 @@ export interface RunnerClient {
161
168
  create(yaml: string): Promise<PipelineSaveResult>
162
169
  update(id: string, yaml: string): Promise<PipelineSaveResult>
163
170
  run(id: string, payload?: Record<string, unknown>): Promise<PipelineRunResponse>
171
+ runSpec(spec: unknown, payload?: Record<string, unknown>): Promise<AsyncRunStartResponse>
164
172
  }
165
173
  runs: {
166
174
  list(opts?: { pipeline?: string; limit?: number }): Promise<RunListItem[]>
167
175
  get(id: number): Promise<RunListItem>
168
176
  nodes(id: number): Promise<RunNodeOutput[]>
169
177
  logs(id: number): Promise<RunLogLine[]>
178
+ events(id: number): AsyncIterable<ToistEvent>
170
179
  }
171
180
  tasks: {
172
181
  list(opts?: { status?: string; runId?: number; assignee?: string; limit?: number }): Promise<TaskListItem[]>
@@ -234,6 +243,10 @@ export function createRunnerClient(options: RunnerClientOptions): RunnerClient {
234
243
  method: "POST",
235
244
  body: json(payload),
236
245
  }),
246
+ runSpec: (spec, payload = {}) => request<AsyncRunStartResponse>("/runs", {
247
+ method: "POST",
248
+ body: json({ spec, payload }),
249
+ }),
237
250
  },
238
251
 
239
252
  runs: {
@@ -241,6 +254,7 @@ export function createRunnerClient(options: RunnerClientOptions): RunnerClient {
241
254
  get: (id) => request<RunListItem>(`/runs/${id}`),
242
255
  nodes: (id) => request<RunNodeOutput[]>(`/runs/${id}/nodes`),
243
256
  logs: (id) => request<RunLogLine[]>(`/runs/${id}/logs`),
257
+ events: (id) => streamEvents(f, `${base}/runs/${id}/events`, options.headers),
244
258
  },
245
259
 
246
260
  tasks: {
@@ -303,3 +317,65 @@ async function readJsonOrText(response: Response): Promise<unknown> {
303
317
  try { return JSON.parse(text) }
304
318
  catch { return text }
305
319
  }
320
+
321
+ function streamEvents(
322
+ f: typeof fetch,
323
+ url: string,
324
+ extraHeaders?: HeadersInit,
325
+ ): AsyncIterable<ToistEvent> {
326
+ return {
327
+ [Symbol.asyncIterator]() {
328
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
329
+ let buffer = ""
330
+ const decoder = new TextDecoder()
331
+ const queue: ToistEvent[] = []
332
+ let finished = false
333
+
334
+ async function ensureReader() {
335
+ if (reader) return reader
336
+ const headers = new Headers(extraHeaders)
337
+ headers.set("accept", "text/event-stream")
338
+ const response = await f(url, { headers })
339
+ if (!response.ok) {
340
+ const body = await readJsonOrText(response)
341
+ throw new RunnerHttpError(response.status, response.statusText, body)
342
+ }
343
+ if (!response.body) throw new Error("runner returned no SSE body")
344
+ reader = response.body.getReader()
345
+ return reader
346
+ }
347
+
348
+ async function pump(): Promise<void> {
349
+ const r = await ensureReader()
350
+ while (queue.length === 0 && !finished) {
351
+ const { value, done } = await r.read()
352
+ if (done) {
353
+ finished = true
354
+ return
355
+ }
356
+ buffer += decoder.decode(value, { stream: true })
357
+ let boundary = buffer.indexOf("\n\n")
358
+ while (boundary !== -1) {
359
+ const frame = buffer.slice(0, boundary)
360
+ buffer = buffer.slice(boundary + 2)
361
+ const data = frame
362
+ .split("\n")
363
+ .filter((line) => line.startsWith("data:"))
364
+ .map((line) => line.slice(5).trim())
365
+ .join("\n")
366
+ if (data) queue.push(JSON.parse(data) as ToistEvent)
367
+ boundary = buffer.indexOf("\n\n")
368
+ }
369
+ }
370
+ }
371
+
372
+ return {
373
+ async next(): Promise<IteratorResult<ToistEvent>> {
374
+ await pump()
375
+ if (queue.length > 0) return { value: queue.shift()!, done: false }
376
+ return { value: undefined as never, done: true }
377
+ },
378
+ }
379
+ },
380
+ }
381
+ }
package/src/data-db.ts CHANGED
@@ -1,21 +1,9 @@
1
1
  // 2121
2
- // Domain data store. The instance owns the schema entirely — every table here
3
- // is created by a pipeline (via db.insert) or by a domain migration the project
4
- // chooses to add. Platform never writes to this DB directly.
5
- //
6
- // Kinds receive this as `ctx.db`. Pipelines write to it (db.insert), read
7
- // from it (db.query), or any custom domain kind interacts with it as needed.
8
- //
9
- // Location: <dataDir>/data.db (per context/instance-spec.md §3 — the host's
10
- // schema-on-write domain DB lives in data/ alongside runtime.db and cache.db).
11
-
12
2
  import { Database } from "bun:sqlite"
13
3
  import { mkdirSync } from "node:fs"
14
4
  import { dirname } from "node:path"
15
- import { dataDbPath } from "./config.ts"
16
5
 
17
- export function openDataDb(): Database {
18
- const path = dataDbPath()
6
+ export function openDataDb(path: string): Database {
19
7
  mkdirSync(dirname(path), { recursive: true })
20
8
  return new Database(path, { create: true })
21
9
  }
package/src/index.ts CHANGED
@@ -9,6 +9,9 @@
9
9
 
10
10
  // Lifecycle entry — what hosts call to start an instance.
11
11
  export { startRunner, type StartRunnerOptions, type RunnerHandle } from "./startRunner.ts"
12
+ export { createSqliteRuntime, type CreateSqliteRuntimeOptions } from "./sqlite-runtime.ts"
13
+ export { loadFilesystemResources } from "./resources-fs.ts"
14
+ export { createFilesystemPipelineStore, type FilesystemPipelineStore } from "./pipeline-store.ts"
12
15
 
13
16
  // Typed HTTP client — what external hosts/tools use to drive an instance.
14
17
  export {
@@ -22,6 +25,7 @@ export {
22
25
  type StepResult,
23
26
  type PipelineTaskRef,
24
27
  type PipelineRunResponse,
28
+ type AsyncRunStartResponse,
25
29
  type RunListItem,
26
30
  type RunNodeOutput,
27
31
  type RunLogLine,
@@ -31,15 +35,44 @@ export {
31
35
  type ResourceRecord,
32
36
  } from "./client.ts"
33
37
 
34
- // Kind registration — must be called before startRunner.
35
- export { register, getKind, manifest } from "./kinds/index.ts"
38
+ // Kernel convenience re-exports.
39
+ export {
40
+ builtinKinds,
41
+ createRegistry,
42
+ createMemoryRuntime,
43
+ createEventStream,
44
+ runSpec,
45
+ register,
46
+ getKind,
47
+ manifest,
48
+ } from "@toist/core"
36
49
 
37
50
  // Type re-exports from @toist/spec, so a host that imports only
38
51
  // @toist/aja doesn't need a second package on its dependency list when
39
52
  // it only writes kinds (rather than authoring pipeline-format tooling).
53
+ export type {
54
+ ToistRuntime,
55
+ ResumableRuntime,
56
+ RunLedger,
57
+ TaskStore,
58
+ LogStore,
59
+ NodeOutputStore,
60
+ ResourceResolver,
61
+ KindRegistry,
62
+ RunCtx,
63
+ RunOutcome,
64
+ RunOptions,
65
+ ToistEvent,
66
+ EventSink,
67
+ RunSpecOptions,
68
+ RunSpecResult,
69
+ } from "@toist/core"
70
+
40
71
  export type {
41
72
  NodeKind,
42
73
  NodeKindManifest,
74
+ DataHandle,
75
+ DataStatement,
43
76
  ParamDef,
44
77
  PortDef,
45
78
  ExecContext,
@@ -0,0 +1,48 @@
1
+ // 2121
2
+ // Per-instance metadata: what kind of platform-instance this is (which
3
+ // customer, what tier, what teasers to surface), distinct from the runtime
4
+ // state in runtime.db.
5
+
6
+ import { existsSync, readFileSync } from "node:fs"
7
+ import { join } from "node:path"
8
+
9
+ export interface Teaser {
10
+ id: string
11
+ title: string
12
+ description?: string
13
+ category?: string
14
+ }
15
+
16
+ export interface Instance {
17
+ platformVersion?: string
18
+ instanceName?: string
19
+ tier?: string
20
+ kindAllowlist?: string[]
21
+ teasers: Teaser[]
22
+ }
23
+
24
+ const DEFAULTS: Instance = { teasers: [] }
25
+
26
+ // Re-read on every call. instance.json is tiny and changes rarely; the cost
27
+ // is negligible and avoids stale-cache surprises when the file is edited.
28
+ export function loadInstance(rootDir: string): Instance {
29
+ const path = join(rootDir, "instance.json")
30
+ if (!existsSync(path)) return DEFAULTS
31
+
32
+ try {
33
+ const raw = readFileSync(path, "utf8")
34
+ const parsed = JSON.parse(raw) as Partial<Instance>
35
+ return {
36
+ platformVersion: parsed.platformVersion,
37
+ instanceName: parsed.instanceName,
38
+ tier: parsed.tier,
39
+ kindAllowlist: Array.isArray(parsed.kindAllowlist)
40
+ ? parsed.kindAllowlist.filter((value): value is string => typeof value === "string")
41
+ : undefined,
42
+ teasers: Array.isArray(parsed.teasers) ? parsed.teasers : [],
43
+ }
44
+ } catch (err) {
45
+ console.warn(`[instance] failed to read ${path}:`, (err as Error).message)
46
+ return DEFAULTS
47
+ }
48
+ }
@@ -1,66 +1,28 @@
1
1
  // 2121
2
- import type { NodeKind, NodeKindManifest } from "./types.ts"
3
- import { dataJson, dataMerge } from "./data.ts"
4
- import { transformFilter, transformMap, transformSort, transformAggregate } from "./transform.ts"
5
- import { dbInsert, dbQuery } from "./db.ts"
6
- import { httpFetch, httpPost } from "./http.ts"
7
- import { trigger, sink } from "./control.ts"
8
- import { humanInput } from "./hitl.ts"
9
- import { runsLastOutput, runsNodeOutput, pipelineRun } from "./runs.ts"
2
+ // Aja-side kind registry shim.
3
+ //
4
+ // Builtins and registry implementation now live in @toist/core. Aja keeps
5
+ // this module so existing local imports and custom kind registration patterns
6
+ // continue to work during the package carve-out.
10
7
 
11
- const builtins: NodeKind<any, any>[] = [
12
- trigger,
13
- sink,
14
- dataJson,
15
- dataMerge,
16
- transformFilter,
17
- transformMap,
18
- transformSort,
19
- transformAggregate,
20
- dbInsert,
21
- dbQuery,
22
- httpFetch,
23
- httpPost,
24
- humanInput,
25
- runsLastOutput,
26
- runsNodeOutput,
27
- pipelineRun,
28
- ]
8
+ export {
9
+ builtinKinds,
10
+ createRegistry,
11
+ register,
12
+ getKind,
13
+ manifest,
14
+ } from "@toist/core"
29
15
 
30
- export const registry: Map<string, NodeKind<any, any>> = new Map(builtins.map((k) => [k.id, k]))
16
+ export type {
17
+ NodeKind,
18
+ NodeKindManifest,
19
+ DataHandle,
20
+ DataStatement,
21
+ ParamDef,
22
+ PortDef,
23
+ ExecContext,
24
+ PlatformCtx,
25
+ } from "@toist/spec"
31
26
 
32
- /**
33
- * Register one or more custom domain kinds. Domain projects edit
34
- * `runner/src/kinds/custom.ts` (created by `platform init`) and call
35
- * `register(...)` there with their own kinds. Builtin kinds and domain kinds
36
- * live in the same registry — `getKind(id)` resolves both transparently.
37
- *
38
- * Re-registering an existing id replaces the previous entry; useful for hot
39
- * iteration but warn loudly so accidental shadows are visible.
40
- */
41
- export function register(...kinds: NodeKind<any, any>[]): void {
42
- for (const k of kinds) {
43
- if (registry.has(k.id) && !builtins.find((b) => b.id === k.id)) {
44
- console.warn(`[kinds] re-registering "${k.id}" — previous registration replaced`)
45
- }
46
- registry.set(k.id, k)
47
- }
48
- }
49
-
50
- export function getKind(id: string): NodeKind<any, any> | undefined {
51
- return registry.get(id)
52
- }
53
-
54
- export function manifest(): NodeKindManifest[] {
55
- return [...registry.values()].map(({ run: _e, ...rest }) => rest)
56
- }
57
-
58
- export type { NodeKind, NodeKindManifest, ParamDef, PortDef, ExecContext, PlatformCtx } from "./types.ts"
59
-
60
- // Side-effect import: domain kinds register themselves when this module loads.
61
- // Dynamic import + top-level await defers custom.ts evaluation until AFTER
62
- // `registry` and `register` are initialised — a static `import "./custom.ts"`
63
- // gets hoisted ahead of the const init, putting `registry` in the temporal
64
- // dead zone when custom.ts calls register(). The dynamic form runs at
65
- // statement position, after init, breaking the cycle.
27
+ // Preserve the old side-effect registration hook for domain kinds.
66
28
  await import("./custom.ts")
package/src/lock.ts CHANGED
@@ -1,64 +1,38 @@
1
1
  // 2121
2
- // Single-leader runner lock. Prevents two runner processes from racing on
3
- // migrations, scheduled cron-wheel firings, or HITL resume dispatch.
4
- //
5
- // The lock guards the data/ directory using a sentinel lockfile at
6
- // data/.lock. proper-lockfile creates a holder directory that includes mtime;
7
- // stale locks (from a crashed previous process) are detected after `stale` ms
8
- // and overtaken automatically.
9
- //
10
- // Watch-mode reload tolerance: bun --watch SIGTERMs the old process before
11
- // spawning the new one. The cleanup handlers below release the lock on
12
- // SIGTERM/SIGINT/exit. Async lock acquisition with retries handles the brief
13
- // window where the old process has not yet released.
14
-
15
2
  import lockfile from "proper-lockfile"
16
3
  import { mkdirSync } from "node:fs"
17
- import { dataDir, lockfilePath } from "./config.ts"
18
4
 
19
- let release: (() => Promise<void>) | null = null
5
+ export interface AcquireRunnerLockOptions {
6
+ dir: string
7
+ lockfilePath: string
8
+ }
20
9
 
21
- export async function acquireRunnerLock(): Promise<void> {
22
- const dir = dataDir()
23
- const lockPath = lockfilePath()
24
- mkdirSync(dir, { recursive: true })
10
+ export async function acquireRunnerLock(opts: AcquireRunnerLockOptions): Promise<() => Promise<void>> {
11
+ mkdirSync(opts.dir, { recursive: true })
25
12
 
26
- try {
27
- release = await lockfile.lock(dir, {
28
- lockfilePath: lockPath,
29
- realpath: false,
30
- stale: 10_000,
31
- retries: { retries: 5, factor: 2, minTimeout: 100, maxTimeout: 1000 },
32
- })
33
- } catch (err) {
34
- const msg = err instanceof Error ? err.message : String(err)
35
- console.error(`[runner] failed to acquire ${lockPath}: ${msg}`)
36
- console.error(`[runner] another runner appears live. Stop it first; only one runner per project is allowed.`)
37
- process.exit(1)
38
- }
13
+ let release = await lockfile.lock(opts.dir, {
14
+ lockfilePath: opts.lockfilePath,
15
+ realpath: false,
16
+ stale: 10_000,
17
+ retries: { retries: 5, factor: 2, minTimeout: 100, maxTimeout: 1000 },
18
+ })
39
19
 
40
- const cleanup = (): void => {
41
- if (!release) return
42
- const r = release
43
- release = null
44
- r().catch(() => { /* best-effort */ })
20
+ let released = false
21
+ const cleanup = async () => {
22
+ if (released) return
23
+ released = true
24
+ const current = release
25
+ release = async () => {}
26
+ try {
27
+ await current()
28
+ } catch {
29
+ // best-effort cleanup on process exit / stop
30
+ }
45
31
  }
46
32
 
47
- process.on("exit", cleanup)
48
- process.on("SIGTERM", () => { cleanup(); process.exit(0) })
49
- process.on("SIGINT", () => { cleanup(); process.exit(0) })
50
- }
33
+ process.on("exit", () => { void cleanup() })
34
+ process.on("SIGTERM", () => { void cleanup() })
35
+ process.on("SIGINT", () => { void cleanup() })
51
36
 
52
- /** Release the runner lock explicitly. Used by `startRunner`'s graceful
53
- * `stop()` path. Idempotent: no-op if the lock is not held. The signal
54
- * cleanup handlers installed by acquireRunnerLock remain in place. */
55
- export async function releaseRunnerLock(): Promise<void> {
56
- if (!release) return
57
- const r = release
58
- release = null
59
- try {
60
- await r()
61
- } catch {
62
- // best-effort — surfacing this would mask the actual stop reason
63
- }
37
+ return cleanup
64
38
  }