@flowget/ai 0.1.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/dist/prompt.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Prompt assembly — the grounding + persona + request.
3
+ *
4
+ * The **system prompt** is a stable prefix: persona, the customer's
5
+ * node catalog, and a compact reference to the workflow-graph shape.
6
+ * Being stable across a request's repair turns (and across requests
7
+ * with the same catalog) makes it the natural **prompt-caching
8
+ * prefix** — providers that cache by leading-token prefix amortize it.
9
+ *
10
+ * The **user prompt** carries the variable part: the command, the
11
+ * optional `currentGraph` being edited, and the optional client-carried
12
+ * `context`.
13
+ *
14
+ * Grounding note: the AUTHORITATIVE emission schema rides on the
15
+ * `propose_workflow` tool's parameters (the model fills it directly).
16
+ * The schema echoed here is trimmed (verbose `markdownDescription`
17
+ * copy stripped) so the model understands the graph model without
18
+ * paying for the full annotated schema twice.
19
+ */
20
+ import { loadWorkflowSchema } from "./authoring-schema.js";
21
+ // NOTE: the decline / already-satisfied rules below shape model
22
+ // behavior on edge commands (e.g. "clear the canvas") and are only
23
+ // observable with a real LLM — the deterministic mock adapter in the
24
+ // test suite scripts its own turns, so these are verified in the
25
+ // founder's playground, not by unit tests.
26
+ export const DEFAULT_PERSONA = `You are Flowget's workflow-authoring assistant. You turn a user's natural-language request into a valid Flowget workflow graph, using ONLY the nodes in the provided catalog.
27
+
28
+ Rules:
29
+ - Author the smallest correct workflow that satisfies the request — do not add nodes the request did not ask for.
30
+ - A runnable workflow needs at least one node whose category is "trigger".
31
+ - Use only node "type" values that appear in the catalog (a node's "type" equals a catalog entry's "id"). Never invent a node type.
32
+ - Wire nodes with edges. An edge's "source"/"target" are node ids. For branch routing, set an edge "sourceHandle" to one of the source node's declared handle ids.
33
+ - To use an upstream node's output in a downstream node's config, write a template: {{ steps.<nodeId>.output.<key> }} for a node's output or {{ trigger.<key> }} for the trigger payload. Only reference node ids that exist in the graph.
34
+ - Node "position" is optional — omit it; the builder lays the graph out.
35
+ - Most config fields hold a scalar, but some are COMPOUND objects. A field of type "schedule" takes: { "spec": { "cron": "0 8 * * *", "timezone": "UTC" }, "overlap": "skip" }. The cron (or "intervalSeconds": <number> instead of cron) and "timezone" nest UNDER "spec"; "spec" is required with exactly one of cron or intervalSeconds; "overlap" is optional (defaults "skip"). Do NOT emit a flat { "cron", "timezone" }.
36
+ - When the request is ambiguous or under-specified, DO NOT guess. Reply in plain text with ONE concise clarifying question instead of calling the tool.
37
+ - If the request is NOT an authorable runnable workflow — e.g. "clear/discard/empty the canvas", "delete everything", "start over", or anything that would require an empty or non-runnable graph — do NOT call propose_workflow. Reply in plain text explaining that you author runnable workflows and that clearing the canvas is a manual action (delete the nodes directly in the builder). NEVER echo the current graph unchanged as a no-op proposal.
38
+ - If a current graph is provided and it ALREADY satisfies the request, reply in plain text saying the workflow already does that, instead of proposing an unchanged graph.
39
+ - When (and only when) you have a complete, valid workflow to author or change, call the "propose_workflow" tool exactly once.`;
40
+ export function buildSystemPrompt(input) {
41
+ const persona = input.persona ?? DEFAULT_PERSONA;
42
+ const catalog = JSON.stringify(input.catalog, null, 2);
43
+ const schema = JSON.stringify(stripVerbose(loadWorkflowSchema()), null, 2);
44
+ return [
45
+ persona,
46
+ "## Node catalog (the only node types you may use)",
47
+ "```json",
48
+ catalog,
49
+ "```",
50
+ "## Workflow graph shape",
51
+ "```json",
52
+ schema,
53
+ "```",
54
+ ].join("\n\n");
55
+ }
56
+ /**
57
+ * The EXACT default system prompt `author` sends for a given catalog
58
+ * — {@link DEFAULT_PERSONA} + the catalog + the workflow-graph schema
59
+ * (loaded from `@flowget/types`, the single source of truth). This is
60
+ * the un-overridden `buildSystemPrompt({ catalog })` the author loop
61
+ * uses when `config.persona` is unset (see `runToolLoop` in
62
+ * `author.ts`).
63
+ *
64
+ * A thin, intent-revealing public entry point for consumers that need
65
+ * the real prompt without reaching into the author loop or replicating
66
+ * the persona — e.g. an out-of-process evaluation harness that measures
67
+ * the raw model output the library's own prompt elicits. The schema is
68
+ * loaded internally (never injected) so the prompt can never drift from
69
+ * the shipped `@flowget/types` schema.
70
+ */
71
+ export function getDefaultSystemPrompt(catalog) {
72
+ return buildSystemPrompt({ catalog });
73
+ }
74
+ export function buildUserPrompt(request) {
75
+ const parts = [`## Command\n${request.command}`];
76
+ if (request.currentGraph !== undefined) {
77
+ parts.push("## Current graph (edit this — preserve nodes the command does not touch)", "```json", safeStringify(request.currentGraph), "```");
78
+ }
79
+ if (request.context !== undefined) {
80
+ parts.push("## Additional context (client-provided)", "```json", safeStringify(request.context), "```");
81
+ }
82
+ return parts.join("\n\n");
83
+ }
84
+ export function buildInitialMessages(request, system) {
85
+ return [
86
+ { role: "system", content: buildSystemPrompt(system) },
87
+ { role: "user", content: buildUserPrompt(request) },
88
+ ];
89
+ }
90
+ function safeStringify(value) {
91
+ try {
92
+ return JSON.stringify(value, null, 2) ?? String(value);
93
+ }
94
+ catch {
95
+ return String(value);
96
+ }
97
+ }
98
+ /** Recursively drop `markdownDescription` keys (the biggest token sink). */
99
+ function stripVerbose(value) {
100
+ if (Array.isArray(value))
101
+ return value.map(stripVerbose);
102
+ if (typeof value === "object" && value !== null) {
103
+ const out = {};
104
+ for (const [key, inner] of Object.entries(value)) {
105
+ if (key === "markdownDescription")
106
+ continue;
107
+ out[key] = stripVerbose(inner);
108
+ }
109
+ return out;
110
+ }
111
+ return value;
112
+ }
package/dist/ssrf.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Construct-time URL validator used by `openaiAdapter({ baseURL })`
3
+ * and any BYO adapter that accepts a URL from caller config.
4
+ *
5
+ * Ported verbatim (behavior-preserving) from `@flowget/engine`'s
6
+ * `_shared/ssrf-guard.ts` — a cross-repo-locked security primitive.
7
+ * Reusing the audited logic (and its `ipaddr.js` classifier) is a
8
+ * deliberate decision over a hand-rolled re-implementation.
9
+ *
10
+ * **Static check, not runtime.** Runs once at the call site that
11
+ * constructs the adapter, before the URL is captured in closure. It
12
+ * catches the common config-leak shape (loopback, private IPv4 ranges,
13
+ * cloud metadata IP, IPv6 loopback/link-local/ULA, IPv4-mapped AND
14
+ * IPv4-compatible IPv6 wrapping a private IPv4, `file://`, etc.). It
15
+ * does NOT defend against DNS rebinding (a public hostname that
16
+ * resolves to a private IP at request time) or open redirects — those
17
+ * belong to the HTTP transport / network policy.
18
+ *
19
+ * **Scope**: `http:` / `https:` only. Other schemes are rejected.
20
+ */
21
+ export type AssertSafeUrlOptions = {
22
+ /**
23
+ * Hostnames that bypass the private-range / loopback checks. Use
24
+ * only when the caller knowingly points at a private mirror (e.g. an
25
+ * internal LLM proxy at `llm.corp.internal`). Case-insensitive full
26
+ * hostname match — no wildcards.
27
+ */
28
+ readonly allowHosts?: ReadonlyArray<string>;
29
+ };
30
+ export declare class UnsafeUrlError extends Error {
31
+ name: string;
32
+ }
33
+ export declare function assertSafeUrl(url: string, opts?: AssertSafeUrlOptions): void;
package/dist/ssrf.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Construct-time URL validator used by `openaiAdapter({ baseURL })`
3
+ * and any BYO adapter that accepts a URL from caller config.
4
+ *
5
+ * Ported verbatim (behavior-preserving) from `@flowget/engine`'s
6
+ * `_shared/ssrf-guard.ts` — a cross-repo-locked security primitive.
7
+ * Reusing the audited logic (and its `ipaddr.js` classifier) is a
8
+ * deliberate decision over a hand-rolled re-implementation.
9
+ *
10
+ * **Static check, not runtime.** Runs once at the call site that
11
+ * constructs the adapter, before the URL is captured in closure. It
12
+ * catches the common config-leak shape (loopback, private IPv4 ranges,
13
+ * cloud metadata IP, IPv6 loopback/link-local/ULA, IPv4-mapped AND
14
+ * IPv4-compatible IPv6 wrapping a private IPv4, `file://`, etc.). It
15
+ * does NOT defend against DNS rebinding (a public hostname that
16
+ * resolves to a private IP at request time) or open redirects — those
17
+ * belong to the HTTP transport / network policy.
18
+ *
19
+ * **Scope**: `http:` / `https:` only. Other schemes are rejected.
20
+ */
21
+ import ipaddr from "ipaddr.js";
22
+ /**
23
+ * `http:` is intentionally permitted alongside `https:`. Self-hosted
24
+ * tenants and dev workflows (Ollama on loopback, an LLM mirror inside
25
+ * a VPN) genuinely need plaintext HTTP. Operators carrying an `apiKey`
26
+ * over `http:` accept the MITM exposure — this guard does not enforce
27
+ * TLS. Other schemes stay rejected outright.
28
+ */
29
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
30
+ /** Hostnames that always indicate loopback/local even if not an IP. */
31
+ const BLOCKED_HOSTNAMES = new Set([
32
+ "localhost",
33
+ "ip6-localhost",
34
+ "ip6-loopback",
35
+ ]);
36
+ export class UnsafeUrlError extends Error {
37
+ name = "UnsafeUrlError";
38
+ }
39
+ export function assertSafeUrl(url, opts = {}) {
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(url);
43
+ }
44
+ catch {
45
+ throw new UnsafeUrlError(`Refusing unparseable URL passed to an adapter factory: ${truncate(url)}`);
46
+ }
47
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
48
+ throw new UnsafeUrlError(`Refusing non-HTTP(S) URL passed to an adapter factory: ${parsed.protocol}// (only http: / https: are allowed)`);
49
+ }
50
+ const hostname = parsed.hostname.toLowerCase();
51
+ const ipCandidate = hostname.startsWith("[") && hostname.endsWith("]")
52
+ ? hostname.slice(1, -1)
53
+ : hostname;
54
+ const allowList = new Set((opts.allowHosts ?? []).map((h) => h.toLowerCase()));
55
+ if (allowList.has(hostname) || allowList.has(ipCandidate))
56
+ return;
57
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
58
+ throw new UnsafeUrlError(`Refusing loopback hostname passed to an adapter factory: ${hostname}`);
59
+ }
60
+ if (!ipaddr.isValid(ipCandidate)) {
61
+ // Not an IP — treat as a regular hostname and allow.
62
+ return;
63
+ }
64
+ const ip = ipaddr.parse(ipCandidate);
65
+ if (ip.kind() === "ipv4") {
66
+ assertIPv4NotPrivateOrReserved(ip, hostname);
67
+ return;
68
+ }
69
+ const v6 = ip;
70
+ const v6Range = v6.range();
71
+ if (v6Range === "loopback" ||
72
+ v6Range === "unspecified" ||
73
+ v6Range === "linkLocal" ||
74
+ v6Range === "uniqueLocal") {
75
+ throw new UnsafeUrlError(`Refusing private/reserved IPv6 (${v6Range}) passed to an adapter factory: ${ipCandidate}`);
76
+ }
77
+ const embedded = embeddedIPv4(v6);
78
+ if (embedded) {
79
+ assertIPv4NotPrivateOrReserved(embedded, `${ipCandidate} (IPv4 inside IPv6)`);
80
+ }
81
+ }
82
+ function assertIPv4NotPrivateOrReserved(ip, display) {
83
+ const range = ip.range();
84
+ if (range === "unspecified" ||
85
+ range === "broadcast" ||
86
+ range === "multicast" ||
87
+ range === "linkLocal" ||
88
+ range === "loopback" ||
89
+ range === "carrierGradeNat" ||
90
+ range === "private" ||
91
+ range === "reserved") {
92
+ throw new UnsafeUrlError(`Refusing private/reserved IPv4 (${range}) passed to an adapter factory: ${display}`);
93
+ }
94
+ }
95
+ function embeddedIPv4(v6) {
96
+ const p = v6.parts;
97
+ const topClear = p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0;
98
+ if (!topClear)
99
+ return null;
100
+ const isMapped = p[5] === 0xffff;
101
+ const isCompatLike = p[5] === 0;
102
+ if (!isMapped && !isCompatLike)
103
+ return null;
104
+ const lo = p[6] ?? 0;
105
+ const hi = p[7] ?? 0;
106
+ const octets = [
107
+ (lo >> 8) & 0xff,
108
+ lo & 0xff,
109
+ (hi >> 8) & 0xff,
110
+ hi & 0xff,
111
+ ];
112
+ return new ipaddr.IPv4(octets);
113
+ }
114
+ function truncate(s) {
115
+ return s.length > 80 ? s.slice(0, 77) + "..." : s;
116
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Toolsets — actor-scoped, model-invoked tools that run in-process
3
+ * (host-side) against the customer's data. The marquee capability.
4
+ *
5
+ * A tool is a plain async function — no Temporal-activity ceremony.
6
+ * The model calls it mid-loop; `author` dispatches it with the trusted
7
+ * `actor` and feeds the result back to the model. The tool's RETURN
8
+ * value is a domain result (including "not found"); a THROW is an infra
9
+ * fault that aborts the request. That distinction is the whole
10
+ * contract: model-recoverable outcomes are return values, operational
11
+ * failures are exceptions.
12
+ *
13
+ * Reference example: `dbToolset` in `examples/db-toolset.ts` resolves a
14
+ * user from a customer DB, scoped by the actor's tenant.
15
+ */
16
+ import type { Actor } from "@flowget/types";
17
+ import type { AiToolCall, AiToolDefinition } from "./adapter.js";
18
+ /** Runtime context handed to every tool — carries the trusted actor. */
19
+ export type ToolContext = {
20
+ actor?: Actor;
21
+ };
22
+ export type Tool = {
23
+ /** Tool name (letters, digits, `_`, `-`). Namespaced on the wire. */
24
+ name: string;
25
+ description: string;
26
+ /** JSON Schema for the tool's arguments object. */
27
+ parameters: Record<string, unknown>;
28
+ /**
29
+ * Run the tool. Return a domain result (serialized back to the
30
+ * model). Throw only on an infra fault — a throw aborts the request.
31
+ */
32
+ run: (input: Record<string, unknown>, context: ToolContext) => Promise<unknown> | unknown;
33
+ };
34
+ export type Toolset = {
35
+ /**
36
+ * Optional namespace. When set, wire names become
37
+ * `${namespace}__${tool.name}` so tools from different toolsets never
38
+ * collide.
39
+ */
40
+ namespace?: string;
41
+ tools: Tool[];
42
+ };
43
+ /**
44
+ * Flattens toolsets into a wire-name-addressable registry, enforcing
45
+ * name validity, uniqueness, and non-collision with reserved tool
46
+ * names (e.g. the emit tool).
47
+ */
48
+ export declare class ToolRegistry {
49
+ private readonly entries;
50
+ private readonly defs;
51
+ constructor(toolsets: readonly Toolset[], reservedNames?: readonly string[]);
52
+ /** Tool definitions to advertise to the model. */
53
+ definitions(): AiToolDefinition[];
54
+ has(wireName: string): boolean;
55
+ /** Dispatch a model tool call; returns the serialized tool result. */
56
+ dispatch(call: AiToolCall, context: ToolContext): Promise<string>;
57
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Toolsets — actor-scoped, model-invoked tools that run in-process
3
+ * (host-side) against the customer's data. The marquee capability.
4
+ *
5
+ * A tool is a plain async function — no Temporal-activity ceremony.
6
+ * The model calls it mid-loop; `author` dispatches it with the trusted
7
+ * `actor` and feeds the result back to the model. The tool's RETURN
8
+ * value is a domain result (including "not found"); a THROW is an infra
9
+ * fault that aborts the request. That distinction is the whole
10
+ * contract: model-recoverable outcomes are return values, operational
11
+ * failures are exceptions.
12
+ *
13
+ * Reference example: `dbToolset` in `examples/db-toolset.ts` resolves a
14
+ * user from a customer DB, scoped by the actor's tenant.
15
+ */
16
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
17
+ /**
18
+ * Flattens toolsets into a wire-name-addressable registry, enforcing
19
+ * name validity, uniqueness, and non-collision with reserved tool
20
+ * names (e.g. the emit tool).
21
+ */
22
+ export class ToolRegistry {
23
+ entries = new Map();
24
+ defs = [];
25
+ constructor(toolsets, reservedNames = []) {
26
+ const reserved = new Set(reservedNames);
27
+ for (const toolset of toolsets) {
28
+ for (const tool of toolset.tools) {
29
+ const wireName = toolset.namespace !== undefined
30
+ ? `${toolset.namespace}__${tool.name}`
31
+ : tool.name;
32
+ if (!TOOL_NAME_PATTERN.test(wireName)) {
33
+ throw new Error(`Invalid tool name "${wireName}" — names (and namespaces) may use letters, digits, "_" and "-" only, up to 64 chars.`);
34
+ }
35
+ if (reserved.has(wireName)) {
36
+ throw new Error(`Tool name "${wireName}" collides with a reserved tool.`);
37
+ }
38
+ if (this.entries.has(wireName)) {
39
+ throw new Error(`Duplicate tool name "${wireName}".`);
40
+ }
41
+ this.entries.set(wireName, tool);
42
+ this.defs.push({
43
+ name: wireName,
44
+ description: tool.description,
45
+ parameters: tool.parameters,
46
+ });
47
+ }
48
+ }
49
+ }
50
+ /** Tool definitions to advertise to the model. */
51
+ definitions() {
52
+ return [...this.defs];
53
+ }
54
+ has(wireName) {
55
+ return this.entries.has(wireName);
56
+ }
57
+ /** Dispatch a model tool call; returns the serialized tool result. */
58
+ async dispatch(call, context) {
59
+ const tool = this.entries.get(call.name);
60
+ if (tool === undefined) {
61
+ throw new Error(`Unknown tool "${call.name}".`);
62
+ }
63
+ const result = await tool.run(call.arguments, context);
64
+ return serializeToolResult(result);
65
+ }
66
+ }
67
+ function serializeToolResult(result) {
68
+ if (typeof result === "string")
69
+ return result;
70
+ try {
71
+ return JSON.stringify(result ?? null);
72
+ }
73
+ catch {
74
+ return String(result);
75
+ }
76
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The default workflow validator — the guard that keeps un-runnable
3
+ * graphs from ever reaching a proposal.
4
+ *
5
+ * Checks, all cheap and dependency-free:
6
+ * 1. **Schema conformance** — AJV against the position-optional
7
+ * authoring schema (structural shape of nodes + edges).
8
+ * 2. **Unique node ids** — duplicate ids make edge/template routing
9
+ * ambiguous.
10
+ * 3. **Catalog cross-reference** — every `node.type` must be a node
11
+ * id present in the loaded catalog.
12
+ * 4. **Edge endpoints exist** — every edge `source`/`target` must
13
+ * reference a node in the graph (no dangling edges).
14
+ * 5. **Trigger presence** — the graph must contain at least one node
15
+ * whose catalog category is `"trigger"` (the engine's runnability
16
+ * gate). Enforced only when the catalog actually ships a trigger
17
+ * node, so a trigger-less catalog can't make authoring impossible.
18
+ * 6. **Dangling template-ref** — every `{{ steps.<id>.… }}` must
19
+ * reference a node id that exists in the graph.
20
+ * 7. **Compound field shape (schedule)** — catalog-driven: for any
21
+ * field a node declares with `type: "schedule"`, its emitted
22
+ * `data[key]` must be the canonical
23
+ * `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`
24
+ * shape (not a flat `{ cron, timezone }`). The catalog carries the
25
+ * field's editor config but NOT its `node.data` shape, so without
26
+ * this the model guesses wrong for compound fields. This is the
27
+ * schedule-field BRIDGE — a general "catalog conveys node.data
28
+ * shapes for every compound field" contract is tracked separately.
29
+ *
30
+ * A failing result carries human-readable error strings; the author
31
+ * loop feeds them back to the model as a bounded repair prompt.
32
+ *
33
+ * Swappable: `config.validator` accepts a factory `(catalog) =>
34
+ * WorkflowValidator`. `createWorkflowValidator` is the default.
35
+ */
36
+ import type { CatalogNode } from "./catalog.js";
37
+ export type ValidationResult = {
38
+ ok: true;
39
+ } | {
40
+ ok: false;
41
+ errors: string[];
42
+ };
43
+ /** Validate an untrusted candidate graph (whatever the model emitted). */
44
+ export type WorkflowValidator = (graph: unknown) => ValidationResult;
45
+ /** Builds a validator bound to a specific catalog. */
46
+ export type WorkflowValidatorFactory = (catalog: readonly CatalogNode[]) => WorkflowValidator;
47
+ /**
48
+ * The default validator factory. Public note: compound-field VALUE
49
+ * validation currently covers only `type: "schedule"` fields — other
50
+ * compound field kinds are shape-checked by the JSON schema but not yet
51
+ * value-validated against their catalog declaration (a general
52
+ * compound-field contract is tracked separately).
53
+ */
54
+ export declare const createWorkflowValidator: WorkflowValidatorFactory;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The default workflow validator — the guard that keeps un-runnable
3
+ * graphs from ever reaching a proposal.
4
+ *
5
+ * Checks, all cheap and dependency-free:
6
+ * 1. **Schema conformance** — AJV against the position-optional
7
+ * authoring schema (structural shape of nodes + edges).
8
+ * 2. **Unique node ids** — duplicate ids make edge/template routing
9
+ * ambiguous.
10
+ * 3. **Catalog cross-reference** — every `node.type` must be a node
11
+ * id present in the loaded catalog.
12
+ * 4. **Edge endpoints exist** — every edge `source`/`target` must
13
+ * reference a node in the graph (no dangling edges).
14
+ * 5. **Trigger presence** — the graph must contain at least one node
15
+ * whose catalog category is `"trigger"` (the engine's runnability
16
+ * gate). Enforced only when the catalog actually ships a trigger
17
+ * node, so a trigger-less catalog can't make authoring impossible.
18
+ * 6. **Dangling template-ref** — every `{{ steps.<id>.… }}` must
19
+ * reference a node id that exists in the graph.
20
+ * 7. **Compound field shape (schedule)** — catalog-driven: for any
21
+ * field a node declares with `type: "schedule"`, its emitted
22
+ * `data[key]` must be the canonical
23
+ * `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`
24
+ * shape (not a flat `{ cron, timezone }`). The catalog carries the
25
+ * field's editor config but NOT its `node.data` shape, so without
26
+ * this the model guesses wrong for compound fields. This is the
27
+ * schedule-field BRIDGE — a general "catalog conveys node.data
28
+ * shapes for every compound field" contract is tracked separately.
29
+ *
30
+ * A failing result carries human-readable error strings; the author
31
+ * loop feeds them back to the model as a bounded repair prompt.
32
+ *
33
+ * Swappable: `config.validator` accepts a factory `(catalog) =>
34
+ * WorkflowValidator`. `createWorkflowValidator` is the default.
35
+ */
36
+ import Ajv2020 from "ajv/dist/2020.js";
37
+ import { buildAuthoringSchema } from "./authoring-schema.js";
38
+ import { isRecord } from "./guards.js";
39
+ /**
40
+ * The default validator factory. Public note: compound-field VALUE
41
+ * validation currently covers only `type: "schedule"` fields — other
42
+ * compound field kinds are shape-checked by the JSON schema but not yet
43
+ * value-validated against their catalog declaration (a general
44
+ * compound-field contract is tracked separately).
45
+ */
46
+ export const createWorkflowValidator = (catalog) => {
47
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
48
+ const validateSchema = ajv.compile(buildAuthoringSchema());
49
+ const knownTypes = new Set(catalog.map((node) => node.id));
50
+ const triggerTypes = new Set(catalog.filter((node) => node.category === "trigger").map((node) => node.id));
51
+ // node type id → keys of its `type: "schedule"` fields, read from the
52
+ // catalog (never hardcoded to a node id). `fields` is typed on
53
+ // `CatalogNode` (`BuilderHints.fields?: ConfigField[]`).
54
+ const scheduleFieldsByType = new Map();
55
+ for (const entry of catalog) {
56
+ if (entry.fields === undefined)
57
+ continue;
58
+ const keys = entry.fields
59
+ .filter((field) => field.type === "schedule")
60
+ .map((field) => field.key);
61
+ if (keys.length > 0)
62
+ scheduleFieldsByType.set(entry.id, keys);
63
+ }
64
+ return (graph) => {
65
+ const errors = [];
66
+ if (!validateSchema(graph)) {
67
+ for (const err of validateSchema.errors ?? []) {
68
+ const where = err.instancePath.length > 0 ? err.instancePath : "/";
69
+ errors.push(`schema: ${where} ${err.message ?? "is invalid"}`);
70
+ }
71
+ }
72
+ // Semantic checks run whenever the graph is structurally a
73
+ // node-bearing object, even if AJV flagged other issues — the more
74
+ // errors the repair prompt sees at once, the fewer round trips.
75
+ if (isRecord(graph) && Array.isArray(graph["nodes"])) {
76
+ const nodes = graph["nodes"];
77
+ // Pre-pass: collect the COMPLETE set of node ids (and detect
78
+ // duplicates) BEFORE any reference check. `nodes[]` order is not
79
+ // topological — a node may reference (via `{{ steps.X }}` or an
80
+ // edge) another node defined later in the array — so the id set
81
+ // must be fully populated first, or a valid forward reference is
82
+ // wrongly flagged as dangling.
83
+ const nodeIds = new Set();
84
+ const duplicateIds = new Set();
85
+ for (const node of nodes) {
86
+ if (isRecord(node) && typeof node["id"] === "string") {
87
+ if (nodeIds.has(node["id"]))
88
+ duplicateIds.add(node["id"]);
89
+ nodeIds.add(node["id"]);
90
+ }
91
+ }
92
+ for (const dup of duplicateIds) {
93
+ errors.push(`duplicate node id "${dup}" — node ids must be unique`);
94
+ }
95
+ // Second pass: catalog cross-ref, trigger presence, and dangling
96
+ // template refs — all against the fully-populated id set.
97
+ let hasTrigger = false;
98
+ for (const node of nodes) {
99
+ if (!isRecord(node))
100
+ continue;
101
+ const id = typeof node["id"] === "string" ? node["id"] : "?";
102
+ const type = node["type"];
103
+ if (typeof type === "string") {
104
+ if (!knownTypes.has(type)) {
105
+ errors.push(`unknown node type "${type}" (node "${id}") — not in the catalog`);
106
+ }
107
+ if (triggerTypes.has(type))
108
+ hasTrigger = true;
109
+ const scheduleKeys = scheduleFieldsByType.get(type);
110
+ if (scheduleKeys !== undefined && isRecord(node["data"])) {
111
+ const data = node["data"];
112
+ for (const key of scheduleKeys) {
113
+ if (!(key in data))
114
+ continue; // shape-check only when emitted
115
+ const problem = validateScheduleShape(data[key]);
116
+ if (problem !== null) {
117
+ errors.push(`schedule field "${key}" on node "${id}" ${problem}`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ collectDanglingRefs(node["data"], nodeIds, id, errors);
123
+ }
124
+ if (Array.isArray(graph["edges"])) {
125
+ for (const edge of graph["edges"]) {
126
+ if (!isRecord(edge))
127
+ continue;
128
+ const edgeId = typeof edge["id"] === "string" ? edge["id"] : "?";
129
+ for (const end of ["source", "target"]) {
130
+ const ref = edge[end];
131
+ if (typeof ref === "string" && !nodeIds.has(ref)) {
132
+ errors.push(`edge "${edgeId}" ${end} "${ref}" references no node in the graph`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // The engine requires >=1 trigger node to run a workflow. Only
138
+ // enforce when the catalog ships a trigger — a trigger-less
139
+ // catalog can't produce one, so blocking would be un-authorable.
140
+ if (triggerTypes.size > 0 && !hasTrigger) {
141
+ errors.push('the workflow has no trigger node — it needs at least one node whose category is "trigger" to be runnable');
142
+ }
143
+ }
144
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
145
+ };
146
+ };
147
+ /**
148
+ * Validate an emitted `type: "schedule"` field value. Returns a repair
149
+ * message that NAMES the expected shape (so the bounded repair loop
150
+ * converges), or null when the shape is canonical:
151
+ * `{ spec: { cron | intervalSeconds, timezone? }, overlap? }`.
152
+ */
153
+ function validateScheduleShape(value) {
154
+ if (!isRecord(value)) {
155
+ return "must be an object `{ spec: { cron|intervalSeconds, timezone? }, overlap? }`";
156
+ }
157
+ const spec = value["spec"];
158
+ if (!isRecord(spec)) {
159
+ if ("cron" in value || "intervalSeconds" in value || "timezone" in value) {
160
+ return "must nest cron/interval under `spec`: `{ spec: { cron|intervalSeconds, timezone? }, overlap? }` — you emitted a flat `{ cron, timezone }`";
161
+ }
162
+ return "must have a `spec` object: `{ spec: { cron|intervalSeconds, timezone? }, overlap? }`";
163
+ }
164
+ const hasCron = typeof spec["cron"] === "string";
165
+ const hasInterval = typeof spec["intervalSeconds"] === "number";
166
+ if (hasCron === hasInterval) {
167
+ return "`spec` must have exactly one of `cron` (string) or `intervalSeconds` (number)";
168
+ }
169
+ return null;
170
+ }
171
+ function collectDanglingRefs(value, nodeIds, nodeId, out) {
172
+ // Fresh regex per traversal — no shared `lastIndex` across calls.
173
+ const stepsRef = /\{\{\s*steps\.([A-Za-z0-9_-]+)/g;
174
+ walk(value);
175
+ function walk(inner) {
176
+ if (typeof inner === "string") {
177
+ let match;
178
+ while ((match = stepsRef.exec(inner)) !== null) {
179
+ const referenced = match[1];
180
+ if (referenced !== undefined && !nodeIds.has(referenced)) {
181
+ out.push(`dangling template ref "{{ steps.${referenced}.… }}" in node "${nodeId}" — no node has id "${referenced}"`);
182
+ }
183
+ }
184
+ return;
185
+ }
186
+ if (Array.isArray(inner)) {
187
+ for (const item of inner)
188
+ walk(item);
189
+ return;
190
+ }
191
+ if (isRecord(inner)) {
192
+ for (const item of Object.values(inner))
193
+ walk(item);
194
+ }
195
+ }
196
+ }