@toist/aja 0.5.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 +69 -0
- package/migrations/001_initial.sql +111 -0
- package/package.json +27 -0
- package/src/cache-db.ts +17 -0
- package/src/cache.ts +67 -0
- package/src/config.ts +129 -0
- package/src/data-db.ts +21 -0
- package/src/db-handles.ts +70 -0
- package/src/hitl.ts +257 -0
- package/src/index.ts +34 -0
- package/src/instance.ts +64 -0
- package/src/kinds/control.ts +26 -0
- package/src/kinds/custom.ts +19 -0
- package/src/kinds/data.ts +30 -0
- package/src/kinds/db.ts +92 -0
- package/src/kinds/hitl.ts +56 -0
- package/src/kinds/http.ts +134 -0
- package/src/kinds/index.ts +66 -0
- package/src/kinds/runs.ts +130 -0
- package/src/kinds/transform.ts +123 -0
- package/src/kinds/types.ts +16 -0
- package/src/lock.ts +64 -0
- package/src/migrate.ts +204 -0
- package/src/pipeline.ts +601 -0
- package/src/resources.ts +350 -0
- package/src/runs.ts +53 -0
- package/src/runtime-db.ts +48 -0
- package/src/server.ts +537 -0
- package/src/startRunner.ts +87 -0
package/src/hitl.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
// Platform-side HITL (human-in-the-loop) primitives.
|
|
3
|
+
//
|
|
4
|
+
// `human.input` kinds call ctx.suspend(spec). The suspend implementation
|
|
5
|
+
// here decides between three cases:
|
|
6
|
+
//
|
|
7
|
+
// 1. An answered task already exists for this node in this run
|
|
8
|
+
// → return its response, kind continues normally.
|
|
9
|
+
// 2. An open task exists (re-suspension before resume completed)
|
|
10
|
+
// → throw HitlSuspend referencing that task.
|
|
11
|
+
// 3. No task yet
|
|
12
|
+
// → create a new task with a single-use response_token, throw HitlSuspend.
|
|
13
|
+
//
|
|
14
|
+
// runPipeline catches HitlSuspend and converts it into a SUSPENDED outcome.
|
|
15
|
+
// The HTTP layer persists node_outputs, marks runs.status='suspended', and
|
|
16
|
+
// returns the task descriptor to the caller.
|
|
17
|
+
//
|
|
18
|
+
// On resume, the HTTP layer hydrates node_outputs back into the executor's
|
|
19
|
+
// results map. Re-execution from the suspended node calls ctx.suspend again;
|
|
20
|
+
// case (1) above kicks in and the human.input node returns the response.
|
|
21
|
+
// Side effects in already-completed nodes do not re-run because the executor
|
|
22
|
+
// skips any node whose output is in the resume map.
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "node:crypto"
|
|
25
|
+
import type { Database } from "bun:sqlite"
|
|
26
|
+
import type { HitlSpec, ErrorReviewSpec } from "@toist/spec"
|
|
27
|
+
|
|
28
|
+
export type { HitlSpec, ErrorReviewSpec }
|
|
29
|
+
|
|
30
|
+
export interface TaskDescriptor {
|
|
31
|
+
id: number
|
|
32
|
+
runId: number
|
|
33
|
+
pipeline: string | null
|
|
34
|
+
nodeId: string
|
|
35
|
+
/** "human.input" for HITL pauses, "error_review" for failed-node suspends. */
|
|
36
|
+
kind: string
|
|
37
|
+
prompt: string
|
|
38
|
+
/** For human.input: the structured schema for the response form.
|
|
39
|
+
* For error_review: the structured error details (kind id, name) to display. */
|
|
40
|
+
schema: unknown
|
|
41
|
+
assignee: string | null
|
|
42
|
+
responseToken: string
|
|
43
|
+
status: "open" | "answered" | "expired" | "cancelled"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown by ctx.suspend() to signal "this run cannot proceed past this node
|
|
48
|
+
* without external input". runPipeline catches this and converts it to a
|
|
49
|
+
* SUSPENDED RunOutcome — it does not propagate to the HTTP layer as an error.
|
|
50
|
+
*/
|
|
51
|
+
export class HitlSuspend extends Error {
|
|
52
|
+
constructor(
|
|
53
|
+
public readonly taskId: number,
|
|
54
|
+
public readonly nodeId: string,
|
|
55
|
+
public readonly spec: HitlSpec,
|
|
56
|
+
) {
|
|
57
|
+
super(`HITL suspend at node "${nodeId}" (task ${taskId})`)
|
|
58
|
+
this.name = "HitlSuspend"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Thrown by the dispatcher when a node fails and its onError policy is
|
|
64
|
+
* "suspend". Mirrors HitlSuspend's shape so the dispatcher can treat both
|
|
65
|
+
* via a single suspend path. The persisted task carries kind='error_review';
|
|
66
|
+
* resume payload is `{ action: "retry" | "skip" | "abort", value? }`.
|
|
67
|
+
*/
|
|
68
|
+
export class ErrorReviewSuspend extends Error {
|
|
69
|
+
constructor(
|
|
70
|
+
public readonly taskId: number,
|
|
71
|
+
public readonly nodeId: string,
|
|
72
|
+
public readonly spec: ErrorReviewSpec,
|
|
73
|
+
) {
|
|
74
|
+
super(`error_review suspend at node "${nodeId}" (task ${taskId})`)
|
|
75
|
+
this.name = "ErrorReviewSuspend"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a suspend(spec) function bound to a specific run + node. Called
|
|
81
|
+
* fresh for each step in the executor; the closure captures runId / nodeId
|
|
82
|
+
* so the kind itself never has to think about identifiers.
|
|
83
|
+
*/
|
|
84
|
+
export function makeSuspend(
|
|
85
|
+
runtimeDb: Database,
|
|
86
|
+
runId: number,
|
|
87
|
+
nodeId: string,
|
|
88
|
+
): (spec: HitlSpec) => Promise<unknown> {
|
|
89
|
+
return async (spec: HitlSpec): Promise<unknown> => {
|
|
90
|
+
// Case 1: already answered — return the response, kind continues.
|
|
91
|
+
const answered = runtimeDb.prepare(
|
|
92
|
+
"SELECT response_json FROM tasks WHERE run_id = ? AND node_id = ? AND status = 'answered' ORDER BY id DESC LIMIT 1",
|
|
93
|
+
).get(runId, nodeId) as { response_json: string | null } | undefined
|
|
94
|
+
|
|
95
|
+
if (answered) {
|
|
96
|
+
return answered.response_json ? JSON.parse(answered.response_json) : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Case 2: an open task already exists — re-throw without creating a duplicate.
|
|
100
|
+
const open = runtimeDb.prepare(
|
|
101
|
+
"SELECT id FROM tasks WHERE run_id = ? AND node_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
|
|
102
|
+
).get(runId, nodeId) as { id: number } | undefined
|
|
103
|
+
|
|
104
|
+
if (open) {
|
|
105
|
+
throw new HitlSuspend(open.id, nodeId, spec)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Case 3: create new task with a single-use response token.
|
|
109
|
+
const token = randomBytes(16).toString("hex")
|
|
110
|
+
const inserted = runtimeDb.prepare(
|
|
111
|
+
`INSERT INTO tasks (run_id, node_id, kind, prompt, schema_json, assignee, response_token)
|
|
112
|
+
VALUES (?, ?, 'human.input', ?, ?, ?, ?)
|
|
113
|
+
RETURNING id`,
|
|
114
|
+
).get(
|
|
115
|
+
runId, nodeId, spec.prompt,
|
|
116
|
+
spec.schema !== undefined ? JSON.stringify(spec.schema) : null,
|
|
117
|
+
spec.assignee ?? null,
|
|
118
|
+
token,
|
|
119
|
+
) as { id: number }
|
|
120
|
+
|
|
121
|
+
throw new HitlSuspend(inserted.id, nodeId, spec)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a fresh `error_review` task for a failing node and return its
|
|
127
|
+
* id + token. Called by the dispatcher when a node throws and its onError
|
|
128
|
+
* policy is "suspend". Same pattern as makeSuspend's case-3 path: insert
|
|
129
|
+
* with a single-use response_token, then throw ErrorReviewSuspend so the
|
|
130
|
+
* dispatcher's suspend path takes over.
|
|
131
|
+
*
|
|
132
|
+
* The task's `prompt` carries the error message; structured details (kind,
|
|
133
|
+
* stack) live in `schema_json` so the UI can surface them without an
|
|
134
|
+
* additional column. Resume payload distinguishes by tasks.kind, not by
|
|
135
|
+
* column shape — keeps the existing tasks table unchanged.
|
|
136
|
+
*/
|
|
137
|
+
export function createErrorReviewTask(
|
|
138
|
+
runtimeDb: Database,
|
|
139
|
+
runId: number,
|
|
140
|
+
nodeId: string,
|
|
141
|
+
spec: ErrorReviewSpec,
|
|
142
|
+
): { id: number; token: string } {
|
|
143
|
+
const token = randomBytes(16).toString("hex")
|
|
144
|
+
const inserted = runtimeDb.prepare(
|
|
145
|
+
`INSERT INTO tasks (run_id, node_id, kind, prompt, schema_json, assignee, response_token)
|
|
146
|
+
VALUES (?, ?, 'error_review', ?, ?, ?, ?)
|
|
147
|
+
RETURNING id`,
|
|
148
|
+
).get(
|
|
149
|
+
runId, nodeId, spec.prompt,
|
|
150
|
+
spec.details !== undefined ? JSON.stringify(spec.details) : null,
|
|
151
|
+
spec.assignee ?? null,
|
|
152
|
+
token,
|
|
153
|
+
) as { id: number }
|
|
154
|
+
return { id: inserted.id, token }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Persist node outputs gathered before a suspension. Idempotent: re-running
|
|
159
|
+
* the same insertion is a no-op via INSERT OR REPLACE — useful when the
|
|
160
|
+
* executor re-runs upstream nodes during a resume.
|
|
161
|
+
*/
|
|
162
|
+
export function persistNodeOutputs(
|
|
163
|
+
runtimeDb: Database,
|
|
164
|
+
runId: number,
|
|
165
|
+
outputs: Record<string, unknown>,
|
|
166
|
+
): void {
|
|
167
|
+
const stmt = runtimeDb.prepare(
|
|
168
|
+
`INSERT OR REPLACE INTO node_outputs (run_id, node_id, output_json, finished_at)
|
|
169
|
+
VALUES (?, ?, ?, datetime('now'))`,
|
|
170
|
+
)
|
|
171
|
+
const tx = runtimeDb.transaction(() => {
|
|
172
|
+
for (const [nodeId, output] of Object.entries(outputs)) {
|
|
173
|
+
stmt.run(runId, nodeId, JSON.stringify(output ?? null))
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
tx()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Load all previously-persisted node outputs for a run. Returned as a
|
|
181
|
+
* { nodeId → output } map ready to seed the executor's results scope.
|
|
182
|
+
*/
|
|
183
|
+
export function loadNodeOutputs(
|
|
184
|
+
runtimeDb: Database,
|
|
185
|
+
runId: number,
|
|
186
|
+
): Record<string, unknown> {
|
|
187
|
+
const rows = runtimeDb.prepare(
|
|
188
|
+
"SELECT node_id, output_json FROM node_outputs WHERE run_id = ?",
|
|
189
|
+
).all(runId) as { node_id: string; output_json: string | null }[]
|
|
190
|
+
|
|
191
|
+
const out: Record<string, unknown> = {}
|
|
192
|
+
for (const row of rows) {
|
|
193
|
+
out[row.node_id] = row.output_json ? JSON.parse(row.output_json) : null
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch a single task by id. Joins runs to surface the pipeline id alongside
|
|
200
|
+
* the task — useful for cross-section navigation in the UI. Returns null if
|
|
201
|
+
* not found.
|
|
202
|
+
*/
|
|
203
|
+
export function getTask(runtimeDb: Database, taskId: number): TaskDescriptor | null {
|
|
204
|
+
const row = runtimeDb.prepare(
|
|
205
|
+
`SELECT t.id, t.run_id, t.node_id, t.kind, t.prompt, t.schema_json, t.assignee,
|
|
206
|
+
t.response_token, t.status, r.pipeline AS pipeline
|
|
207
|
+
FROM tasks t LEFT JOIN runs r ON t.run_id = r.id
|
|
208
|
+
WHERE t.id = ?`,
|
|
209
|
+
).get(taskId) as {
|
|
210
|
+
id: number; run_id: number; node_id: string; kind: string; prompt: string | null
|
|
211
|
+
schema_json: string | null; assignee: string | null
|
|
212
|
+
response_token: string; status: string; pipeline: string | null
|
|
213
|
+
} | undefined
|
|
214
|
+
|
|
215
|
+
if (!row) return null
|
|
216
|
+
return {
|
|
217
|
+
id: row.id,
|
|
218
|
+
runId: row.run_id,
|
|
219
|
+
pipeline: row.pipeline,
|
|
220
|
+
nodeId: row.node_id,
|
|
221
|
+
kind: row.kind,
|
|
222
|
+
prompt: row.prompt ?? "",
|
|
223
|
+
schema: row.schema_json ? JSON.parse(row.schema_json) : null,
|
|
224
|
+
assignee: row.assignee,
|
|
225
|
+
responseToken: row.response_token,
|
|
226
|
+
status: row.status as TaskDescriptor["status"],
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Record a response against an open task. Validates the token matches and
|
|
232
|
+
* the task is still open; refuses replay (token is single-use). Returns the
|
|
233
|
+
* updated task descriptor on success, throws on validation failure.
|
|
234
|
+
*/
|
|
235
|
+
export function answerTask(
|
|
236
|
+
runtimeDb: Database,
|
|
237
|
+
taskId: number,
|
|
238
|
+
token: string,
|
|
239
|
+
response: unknown,
|
|
240
|
+
respondedBy: string | null,
|
|
241
|
+
): TaskDescriptor {
|
|
242
|
+
const task = getTask(runtimeDb, taskId)
|
|
243
|
+
if (!task) throw new Error(`task ${taskId} not found`)
|
|
244
|
+
if (task.status !== "open") throw new Error(`task ${taskId} is ${task.status}, cannot answer`)
|
|
245
|
+
if (task.responseToken !== token) throw new Error(`task ${taskId}: invalid response token`)
|
|
246
|
+
|
|
247
|
+
runtimeDb.prepare(
|
|
248
|
+
`UPDATE tasks
|
|
249
|
+
SET status = 'answered',
|
|
250
|
+
response_json = ?,
|
|
251
|
+
responded_by = ?,
|
|
252
|
+
responded_at = datetime('now')
|
|
253
|
+
WHERE id = ?`,
|
|
254
|
+
).run(JSON.stringify(response ?? null), respondedBy, taskId)
|
|
255
|
+
|
|
256
|
+
return { ...task, status: "answered" }
|
|
257
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
// @toist/aja — public API entry per context/instance-spec.md §4.
|
|
3
|
+
//
|
|
4
|
+
// Hosts that consume this package get exactly the surface declared here.
|
|
5
|
+
// Anything not re-exported is internal and may change without notice.
|
|
6
|
+
//
|
|
7
|
+
// Bundled MCP, UI mounting, per-path overrides, custom logger injection,
|
|
8
|
+
// and the built-in kind set are all reachable through `startRunner`.
|
|
9
|
+
|
|
10
|
+
// Lifecycle entry — what hosts call to start an instance.
|
|
11
|
+
export { startRunner, type StartRunnerOptions, type RunnerHandle } from "./startRunner.ts"
|
|
12
|
+
|
|
13
|
+
// Kind registration — must be called before startRunner.
|
|
14
|
+
export { register, getKind, manifest } from "./kinds/index.ts"
|
|
15
|
+
|
|
16
|
+
// Type re-exports from @toist/spec, so a host that imports only
|
|
17
|
+
// @toist/aja doesn't need a second package on its dependency list when
|
|
18
|
+
// it only writes kinds (rather than authoring pipeline-format tooling).
|
|
19
|
+
export type {
|
|
20
|
+
NodeKind,
|
|
21
|
+
NodeKindManifest,
|
|
22
|
+
ParamDef,
|
|
23
|
+
PortDef,
|
|
24
|
+
ExecContext,
|
|
25
|
+
PlatformCtx,
|
|
26
|
+
Cache,
|
|
27
|
+
HitlSpec,
|
|
28
|
+
OnErrorPolicy,
|
|
29
|
+
ErrorReviewSpec,
|
|
30
|
+
RunStore,
|
|
31
|
+
SubRun,
|
|
32
|
+
SubRunOutcome,
|
|
33
|
+
ResourceTypeDef,
|
|
34
|
+
} from "@toist/spec"
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
// Lives at <rootDir>/instance.json (per context/instance-spec.md §6).
|
|
7
|
+
// Optional — when missing, sensible defaults are returned (useful for the
|
|
8
|
+
// in-monorepo template-runner where no scaffold has happened).
|
|
9
|
+
//
|
|
10
|
+
// Schema (current, may grow):
|
|
11
|
+
//
|
|
12
|
+
// {
|
|
13
|
+
// "platformVersion": "0.1.0", // recorded at scaffold time
|
|
14
|
+
// "instanceName": "Enio Runner", // human-readable label, optional
|
|
15
|
+
// "tier": "pilot", // free-form string, optional
|
|
16
|
+
// "teasers": [ // available-but-not-shipped pipelines
|
|
17
|
+
// {
|
|
18
|
+
// "id": "sales-kpi",
|
|
19
|
+
// "title": "Salesman KPI tracking",
|
|
20
|
+
// "description": "Weekly KPI rollup per salesman with conversion tracking.",
|
|
21
|
+
// "category": "analytics"
|
|
22
|
+
// }
|
|
23
|
+
// ]
|
|
24
|
+
// }
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
27
|
+
import { instanceFilePath } from "./config.ts"
|
|
28
|
+
|
|
29
|
+
export interface Teaser {
|
|
30
|
+
id: string
|
|
31
|
+
title: string
|
|
32
|
+
description?: string
|
|
33
|
+
category?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Instance {
|
|
37
|
+
platformVersion?: string
|
|
38
|
+
instanceName?: string
|
|
39
|
+
tier?: string
|
|
40
|
+
teasers: Teaser[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULTS: Instance = { teasers: [] }
|
|
44
|
+
|
|
45
|
+
// Re-read on every call. instance.json is tiny and changes rarely; the cost
|
|
46
|
+
// is negligible and avoids stale-cache surprises when the file is edited.
|
|
47
|
+
export function loadInstance(): Instance {
|
|
48
|
+
const path = instanceFilePath()
|
|
49
|
+
if (!existsSync(path)) return DEFAULTS
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const raw = readFileSync(path, "utf8")
|
|
53
|
+
const parsed = JSON.parse(raw) as Partial<Instance>
|
|
54
|
+
return {
|
|
55
|
+
platformVersion: parsed.platformVersion,
|
|
56
|
+
instanceName: parsed.instanceName,
|
|
57
|
+
tier: parsed.tier,
|
|
58
|
+
teasers: Array.isArray(parsed.teasers) ? parsed.teasers : [],
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn(`[instance] failed to read ${path}:`, (err as Error).message)
|
|
62
|
+
return DEFAULTS
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
import type { NodeKind } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export const trigger: NodeKind<Record<string, never>, Record<string, never>> = {
|
|
5
|
+
id: "trigger",
|
|
6
|
+
category: "control",
|
|
7
|
+
label: "Trigger",
|
|
8
|
+
description: "Pipeline entrypoint. Outputs the run payload — reference the run params with $params.X anywhere in the graph.",
|
|
9
|
+
icon: "Zap",
|
|
10
|
+
params: {},
|
|
11
|
+
inputs: {},
|
|
12
|
+
outputs: { payload: { type: "any" } },
|
|
13
|
+
run: (_ctx, _params, _input) => null,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const sink: NodeKind<Record<string, never>, { value: unknown }> = {
|
|
17
|
+
id: "sink",
|
|
18
|
+
category: "control",
|
|
19
|
+
label: "Output",
|
|
20
|
+
description: "Pipeline output. The value passed in becomes the run result.",
|
|
21
|
+
icon: "ArrowUpFromLine",
|
|
22
|
+
params: {},
|
|
23
|
+
inputs: { value: { type: "any", required: true } },
|
|
24
|
+
outputs: {},
|
|
25
|
+
run: (_ctx, _params, input) => input.value,
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
// Domain kinds — register your project-specific kinds here. This file is
|
|
3
|
+
// imported automatically when the kind registry initialises. It ships empty
|
|
4
|
+
// in a fresh `platform init` project; add your kinds and call `register(...)`.
|
|
5
|
+
//
|
|
6
|
+
// Example (eniopro):
|
|
7
|
+
//
|
|
8
|
+
// import { register } from "./index.ts"
|
|
9
|
+
// import { listBuildingsKind } from "./enio.list-buildings.ts"
|
|
10
|
+
// import { signalKind } from "./enio.signal.ts"
|
|
11
|
+
// import { rankedBuildingsKind } from "./score.ranked-buildings.ts"
|
|
12
|
+
//
|
|
13
|
+
// register(listBuildingsKind, signalKind, rankedBuildingsKind)
|
|
14
|
+
//
|
|
15
|
+
// Builtin kinds (trigger, sink, data.json, transform.*, db.*) are registered
|
|
16
|
+
// in ./index.ts. Custom kinds can shadow builtins by re-using their id, but
|
|
17
|
+
// you'll see a warning at startup.
|
|
18
|
+
|
|
19
|
+
export {}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
import type { NodeKind } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export const dataJson: NodeKind<{ value: unknown }, Record<string, never>> = {
|
|
5
|
+
id: "data.json",
|
|
6
|
+
category: "data",
|
|
7
|
+
label: "JSON",
|
|
8
|
+
description: "Emit a constant JSON value as the output. Useful as a source.",
|
|
9
|
+
icon: "Braces",
|
|
10
|
+
params: {
|
|
11
|
+
value: { type: "json", label: "Value", required: true, description: "Any JSON value (object, array, scalar)" },
|
|
12
|
+
},
|
|
13
|
+
inputs: {},
|
|
14
|
+
outputs: { value: { type: "any" } },
|
|
15
|
+
run: (_ctx, params) => params.value,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const dataMerge: NodeKind<{ overlay?: object }, { input: object }> = {
|
|
19
|
+
id: "data.merge",
|
|
20
|
+
category: "data",
|
|
21
|
+
label: "Merge",
|
|
22
|
+
description: "Shallow-merge runtime input with a constant overlay.",
|
|
23
|
+
icon: "Combine",
|
|
24
|
+
params: {
|
|
25
|
+
overlay: { type: "json", label: "Overlay", default: {} },
|
|
26
|
+
},
|
|
27
|
+
inputs: { input: { type: "object", required: true } },
|
|
28
|
+
outputs: { output: { type: "object" } },
|
|
29
|
+
run: (_ctx, params, input) => ({ ...input.input, ...(params.overlay ?? {}) }),
|
|
30
|
+
}
|
package/src/kinds/db.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
import type { NodeKind } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export const dbInsert: NodeKind<
|
|
5
|
+
{ table: string; mode?: "insert" | "upsert"; key?: string | string[] },
|
|
6
|
+
{ rows: unknown[] }
|
|
7
|
+
> = {
|
|
8
|
+
id: "db.insert",
|
|
9
|
+
category: "db",
|
|
10
|
+
label: "DB Insert",
|
|
11
|
+
description:
|
|
12
|
+
"Insert (or upsert) array items into a SQLite table. Columns are inferred from the first row. " +
|
|
13
|
+
"For mode=upsert you must specify `key` (column or column list) — a UNIQUE index is created on " +
|
|
14
|
+
"first run so subsequent runs replace matching rows instead of duplicating them.",
|
|
15
|
+
icon: "Database",
|
|
16
|
+
sideEffect: true,
|
|
17
|
+
params: {
|
|
18
|
+
table: { type: "string", label: "Table", required: true, placeholder: "salesman_kpi" },
|
|
19
|
+
mode: { type: "select", label: "Mode", default: "insert",
|
|
20
|
+
options: [
|
|
21
|
+
{ value: "insert", label: "INSERT (append)" },
|
|
22
|
+
{ value: "upsert", label: "UPSERT (replace by key)" },
|
|
23
|
+
] },
|
|
24
|
+
key: { type: "string", label: "Unique key", description: "Column name (or comma-separated list) for UPSERT mode. The kind creates a UNIQUE index on first run.", placeholder: "id" },
|
|
25
|
+
},
|
|
26
|
+
inputs: { rows: { type: "array", required: true } },
|
|
27
|
+
outputs: { result: { type: "object" } },
|
|
28
|
+
run: (ctx, params, input) => {
|
|
29
|
+
const rows = Array.isArray(input.rows) ? input.rows : []
|
|
30
|
+
if (rows.length === 0) return { inserted: 0, table: params.table }
|
|
31
|
+
|
|
32
|
+
const cols = Object.keys(rows[0] as Record<string, unknown>)
|
|
33
|
+
if (cols.length === 0) return { inserted: 0, table: params.table }
|
|
34
|
+
|
|
35
|
+
const mode = params.mode ?? "insert"
|
|
36
|
+
|
|
37
|
+
if (mode === "upsert" && !params.key) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`db.insert: mode "upsert" requires "key" param (column or column list). ` +
|
|
40
|
+
`Without a UNIQUE index every run would duplicate every row.`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const colDefs = cols.map((c) => `${c} TEXT`).join(", ")
|
|
45
|
+
ctx.db.exec(`CREATE TABLE IF NOT EXISTS ${params.table} (${colDefs})`)
|
|
46
|
+
|
|
47
|
+
if (mode === "upsert") {
|
|
48
|
+
const keyCols = Array.isArray(params.key) ? params.key : String(params.key).split(",").map(s => s.trim())
|
|
49
|
+
const idxName = `idx_${params.table}_${keyCols.join("_")}_unique`
|
|
50
|
+
try {
|
|
51
|
+
ctx.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${idxName} ON ${params.table} (${keyCols.join(", ")})`)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`db.insert: cannot create UNIQUE index on ${params.table}(${keyCols.join(", ")}) — ` +
|
|
55
|
+
`existing rows likely violate uniqueness. Drop the table or remove duplicates first. ` +
|
|
56
|
+
`Underlying error: ${(err as Error).message}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const placeholders = cols.map(() => "?").join(", ")
|
|
62
|
+
const verb = mode === "upsert" ? "INSERT OR REPLACE" : "INSERT"
|
|
63
|
+
const stmt = ctx.db.prepare(`${verb} INTO ${params.table} (${cols.join(", ")}) VALUES (${placeholders})`)
|
|
64
|
+
|
|
65
|
+
const txn = ctx.db.transaction((items: unknown[]) => {
|
|
66
|
+
for (const it of items) {
|
|
67
|
+
const r = it as Record<string, unknown>
|
|
68
|
+
stmt.run(...cols.map((c) => {
|
|
69
|
+
const v = r[c]
|
|
70
|
+
return typeof v === "object" && v !== null ? JSON.stringify(v) : (v as never)
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
txn(rows)
|
|
75
|
+
|
|
76
|
+
return { inserted: rows.length, table: params.table, mode }
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const dbQuery: NodeKind<{ sql: string }, Record<string, never>> = {
|
|
81
|
+
id: "db.query",
|
|
82
|
+
category: "db",
|
|
83
|
+
label: "DB Query",
|
|
84
|
+
description: "Run a SELECT query against the runner's SQLite DB.",
|
|
85
|
+
icon: "Search",
|
|
86
|
+
params: {
|
|
87
|
+
sql: { type: "string", label: "SQL", required: true, placeholder: "SELECT * FROM salesman_kpi ORDER BY score DESC LIMIT 10" },
|
|
88
|
+
},
|
|
89
|
+
inputs: {},
|
|
90
|
+
outputs: { rows: { type: "array" } },
|
|
91
|
+
run: (ctx, params) => ctx.db.prepare(params.sql).all(),
|
|
92
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 2121
|
|
2
|
+
// HITL kinds. The actual suspend/resume bookkeeping lives in ../hitl.ts on
|
|
3
|
+
// the platform side; this file only declares the kind contract.
|
|
4
|
+
|
|
5
|
+
import type { NodeKind } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
interface HumanInputParams {
|
|
8
|
+
prompt: string
|
|
9
|
+
assignee?: string
|
|
10
|
+
schema?: unknown
|
|
11
|
+
timeout?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const humanInput: NodeKind<HumanInputParams, Record<string, never>> = {
|
|
15
|
+
id: "human.input",
|
|
16
|
+
category: "control",
|
|
17
|
+
label: "Human input",
|
|
18
|
+
description: "Suspends the run until the assignee responds with input. The response becomes this node's output and downstream nodes can $results.<id>.<field> it.",
|
|
19
|
+
icon: "MessageSquareReply",
|
|
20
|
+
params: {
|
|
21
|
+
prompt: {
|
|
22
|
+
type: "string", required: true,
|
|
23
|
+
description: "Question shown to the assignee in the task queue.",
|
|
24
|
+
placeholder: "Approve campaign launch?",
|
|
25
|
+
},
|
|
26
|
+
assignee: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Who should respond. Format: user:<email> or agent:<id>. Empty = open to anyone.",
|
|
29
|
+
placeholder: "user:teemu@2121.fi",
|
|
30
|
+
},
|
|
31
|
+
schema: {
|
|
32
|
+
type: "json",
|
|
33
|
+
description: "JSON Schema describing the expected response shape. v1: descriptive only, not enforced.",
|
|
34
|
+
},
|
|
35
|
+
timeout: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "ISO 8601 duration before the task expires (e.g. \"P7D\"). v1: stored, not enforced.",
|
|
38
|
+
placeholder: "P7D",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
inputs: {},
|
|
42
|
+
outputs: {
|
|
43
|
+
response: {
|
|
44
|
+
type: "any",
|
|
45
|
+
description: "Whatever the assignee submitted as their response.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
run: async (ctx, params) => {
|
|
49
|
+
return await ctx.suspend({
|
|
50
|
+
prompt: params.prompt,
|
|
51
|
+
assignee: params.assignee,
|
|
52
|
+
schema: params.schema,
|
|
53
|
+
timeout: params.timeout,
|
|
54
|
+
})
|
|
55
|
+
},
|
|
56
|
+
}
|