@sequenceholdings/studio-cli 0.1.9

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.
Files changed (74) hide show
  1. package/README.md +258 -0
  2. package/dist/artifact/delegate.d.ts +25 -0
  3. package/dist/artifact/delegate.js +263 -0
  4. package/dist/atlas-client.d.ts +44 -0
  5. package/dist/atlas-client.js +173 -0
  6. package/dist/auth-cmds/commands.d.ts +15 -0
  7. package/dist/auth-cmds/commands.js +249 -0
  8. package/dist/auth.d.ts +26 -0
  9. package/dist/auth.js +171 -0
  10. package/dist/bin.d.ts +2 -0
  11. package/dist/bin.js +8 -0
  12. package/dist/cli-errors.d.ts +5 -0
  13. package/dist/cli-errors.js +78 -0
  14. package/dist/config.d.ts +44 -0
  15. package/dist/config.js +103 -0
  16. package/dist/env-flags.d.ts +8 -0
  17. package/dist/env-flags.js +47 -0
  18. package/dist/functions/bundle.d.ts +30 -0
  19. package/dist/functions/bundle.js +137 -0
  20. package/dist/functions/commands.d.ts +86 -0
  21. package/dist/functions/commands.js +999 -0
  22. package/dist/functions/egress-preview.d.ts +32 -0
  23. package/dist/functions/egress-preview.js +54 -0
  24. package/dist/functions/lockfile-origin.d.ts +16 -0
  25. package/dist/functions/lockfile-origin.js +45 -0
  26. package/dist/functions/manifest.d.ts +89 -0
  27. package/dist/functions/manifest.js +586 -0
  28. package/dist/functions/secret-reconcile.d.ts +79 -0
  29. package/dist/functions/secret-reconcile.js +86 -0
  30. package/dist/main.d.ts +14 -0
  31. package/dist/main.js +129 -0
  32. package/dist/orm/delegate.d.ts +8 -0
  33. package/dist/orm/delegate.js +61 -0
  34. package/dist/pat-hints.d.ts +17 -0
  35. package/dist/pat-hints.js +28 -0
  36. package/dist/preview.d.ts +89 -0
  37. package/dist/preview.js +291 -0
  38. package/dist/process/agent-loader.d.ts +24 -0
  39. package/dist/process/agent-loader.js +57 -0
  40. package/dist/process/build.d.ts +14 -0
  41. package/dist/process/build.js +368 -0
  42. package/dist/process/codegen.d.ts +18 -0
  43. package/dist/process/codegen.js +270 -0
  44. package/dist/process/commands.d.ts +47 -0
  45. package/dist/process/commands.js +786 -0
  46. package/dist/process/discover.d.ts +32 -0
  47. package/dist/process/discover.js +131 -0
  48. package/dist/process/lint.d.ts +39 -0
  49. package/dist/process/lint.js +485 -0
  50. package/dist/process/local-bundle.d.ts +17 -0
  51. package/dist/process/local-bundle.js +65 -0
  52. package/dist/process/plan-diff.d.ts +82 -0
  53. package/dist/process/plan-diff.js +333 -0
  54. package/dist/process/resolve-process-pin.d.ts +11 -0
  55. package/dist/process/resolve-process-pin.js +63 -0
  56. package/dist/process/simulate.d.ts +50 -0
  57. package/dist/process/simulate.js +328 -0
  58. package/dist/prompt.d.ts +35 -0
  59. package/dist/prompt.js +65 -0
  60. package/dist/repos/commands.d.ts +49 -0
  61. package/dist/repos/commands.js +548 -0
  62. package/dist/repos/git-clone.d.ts +10 -0
  63. package/dist/repos/git-clone.js +49 -0
  64. package/dist/secrets/commands.d.ts +24 -0
  65. package/dist/secrets/commands.js +704 -0
  66. package/dist/templates/process/example-process/process.ts +43 -0
  67. package/dist/templates/process/package.json +23 -0
  68. package/dist/templates/process/pnpm-workspace.yaml +21 -0
  69. package/dist/templates/process/tsconfig.json +17 -0
  70. package/package.json +78 -0
  71. package/templates/process/example-process/process.ts +43 -0
  72. package/templates/process/package.json +23 -0
  73. package/templates/process/pnpm-workspace.yaml +21 -0
  74. package/templates/process/tsconfig.json +17 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Find and load process definitions from disk. seq-studio runs from
3
+ * inside an external process repo, so the default discovery strategy is
4
+ * "walk the cwd for any folder containing a `process.ts` and import it."
5
+ * It relies on `LATTICE_PROCESSES_ROOT` (env override) or a flat cwd
6
+ * walk so it works in any external repo without hardcoded monorepo paths.
7
+ *
8
+ * The published binary runs under plain Node, which can't import `.ts`
9
+ * files directly (ERR_UNKNOWN_FILE_EXTENSION on Node 20). We use the
10
+ * `tsx` programmatic API (`tsImport`) to transpile + load process.ts on
11
+ * the fly — same loader the user would get from `npx tsx`, with no
12
+ * `node --loader` flag or precompile step required.
13
+ */
14
+ import type { ProcessDefinition } from '@sequenceholdings/lattice/define';
15
+ export interface LoadedProcess {
16
+ file: string;
17
+ process: ProcessDefinition;
18
+ }
19
+ /**
20
+ * Resolve the root directory to scan for `<id>/process.ts` folders.
21
+ *
22
+ * Precedence:
23
+ * 1. `LATTICE_PROCESSES_ROOT` env var (absolute or relative to cwd)
24
+ * 2. The current working directory (the typical case in an external repo)
25
+ */
26
+ export declare function findProcessRoot(): Promise<string>;
27
+ /**
28
+ * Load every `<dir>/process.ts` under the configured root. Each module
29
+ * must `export default defineProcess(...)`. Process ids must be unique
30
+ * across the set — duplicates throw.
31
+ */
32
+ export declare function loadProcessDefinitions(): Promise<readonly LoadedProcess[]>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Find and load process definitions from disk. seq-studio runs from
3
+ * inside an external process repo, so the default discovery strategy is
4
+ * "walk the cwd for any folder containing a `process.ts` and import it."
5
+ * It relies on `LATTICE_PROCESSES_ROOT` (env override) or a flat cwd
6
+ * walk so it works in any external repo without hardcoded monorepo paths.
7
+ *
8
+ * The published binary runs under plain Node, which can't import `.ts`
9
+ * files directly (ERR_UNKNOWN_FILE_EXTENSION on Node 20). We use the
10
+ * `tsx` programmatic API (`tsImport`) to transpile + load process.ts on
11
+ * the fly — same loader the user would get from `npx tsx`, with no
12
+ * `node --loader` flag or precompile step required.
13
+ */
14
+ import { readdir, stat } from 'node:fs/promises';
15
+ import { join, relative, resolve } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { tsImport } from 'tsx/esm/api';
18
+ async function isDir(path) {
19
+ try {
20
+ const st = await stat(path);
21
+ return st.isDirectory();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function fileExists(path) {
28
+ try {
29
+ await stat(path);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Resolve the root directory to scan for `<id>/process.ts` folders.
38
+ *
39
+ * Precedence:
40
+ * 1. `LATTICE_PROCESSES_ROOT` env var (absolute or relative to cwd)
41
+ * 2. The current working directory (the typical case in an external repo)
42
+ */
43
+ export async function findProcessRoot() {
44
+ const envRoot = process.env['LATTICE_PROCESSES_ROOT']?.trim();
45
+ if (envRoot) {
46
+ const resolved = resolve(envRoot);
47
+ if (!(await isDir(resolved))) {
48
+ throw new Error(`LATTICE_PROCESSES_ROOT is not a directory: ${resolved}`);
49
+ }
50
+ return resolved;
51
+ }
52
+ return process.cwd();
53
+ }
54
+ /**
55
+ * Load every `<dir>/process.ts` under the configured root. Each module
56
+ * must `export default defineProcess(...)`. Process ids must be unique
57
+ * across the set — duplicates throw.
58
+ */
59
+ export async function loadProcessDefinitions() {
60
+ const root = await findProcessRoot();
61
+ const entries = await readdir(root);
62
+ const found = [];
63
+ const seenIds = new Set();
64
+ // Group dirs (e.g. `crm-email/`) have no `process.ts` of their own but
65
+ // contain `<group>/<id>/process.ts`. Discovery is one level deep, so they're
66
+ // silently skipped from this root — warn so a dev doesn't believe they
67
+ // linted/applied everything (run from inside the group dir, or set
68
+ // LATTICE_PROCESSES_ROOT to it).
69
+ const skippedGroupDirs = [];
70
+ for (const entry of entries) {
71
+ const procDir = join(root, entry);
72
+ if (!(await isDir(procDir)))
73
+ continue;
74
+ const candidate = join(procDir, 'process.ts');
75
+ if (!(await fileExists(candidate))) {
76
+ if (await dirContainsNestedProcess(procDir))
77
+ skippedGroupDirs.push(entry);
78
+ continue;
79
+ }
80
+ // tsImport returns a Promise<Module> and handles `.ts` + `.tsx` +
81
+ // ESM/CJS interop. The second arg is the parent URL for relative
82
+ // resolution; we use this module's own URL so the user's
83
+ // process.ts can `import '@sequenceholdings/lattice'` against the
84
+ // package they installed in their own node_modules.
85
+ const mod = (await tsImport(pathToFileURL(candidate).href, import.meta.url));
86
+ const def = mod.default;
87
+ if (!def || typeof def !== 'object' || !('nodes' in def)) {
88
+ throw new Error(`${relative(process.cwd(), candidate)}: default export must be a ProcessDefinition (from defineProcess)`);
89
+ }
90
+ const processDef = def;
91
+ if (seenIds.has(processDef.id)) {
92
+ throw new Error(`duplicate process id "${processDef.id}" — found in ${relative(process.cwd(), candidate)} and an earlier file`);
93
+ }
94
+ seenIds.add(processDef.id);
95
+ found.push({ file: candidate, process: processDef });
96
+ }
97
+ if (skippedGroupDirs.length > 0) {
98
+ console.warn(`[seq-studio] Skipped ${skippedGroupDirs.length} group dir(s) with nested ` +
99
+ `process trees (discovery is one level deep): ${skippedGroupDirs.join(', ')}. ` +
100
+ `Run from inside the group dir, or set LATTICE_PROCESSES_ROOT, to lint/apply them.`);
101
+ }
102
+ if (found.length === 0) {
103
+ throw new Error(`No lattice process definitions found under ${root}. ` +
104
+ `Each process lives in a subfolder with a "process.ts" file that ` +
105
+ `exports defineProcess(...). Set LATTICE_PROCESSES_ROOT to ` +
106
+ `point at a different directory.`);
107
+ }
108
+ return found;
109
+ }
110
+ /**
111
+ * True if `dir` is a group dir: it has no `process.ts` of its own but at least
112
+ * one immediate child `<dir>/<sub>/process.ts`. Used only to warn that such
113
+ * trees are skipped by the one-level-deep walk.
114
+ */
115
+ async function dirContainsNestedProcess(dir) {
116
+ let children;
117
+ try {
118
+ children = await readdir(dir);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ for (const child of children) {
124
+ const childDir = join(dir, child);
125
+ if (!(await isDir(childDir)))
126
+ continue;
127
+ if (await fileExists(join(childDir, 'process.ts')))
128
+ return true;
129
+ }
130
+ return false;
131
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Production-grade lint for v2 process definitions — the single source
3
+ * of truth for process lint checks.
4
+ *
5
+ * - Graph integrity (already enforced by defineProcess at import time)
6
+ * - Reachability (BFS from start_node_id; orphans are warnings)
7
+ * - Agent output contract — load the agent definition, walk its
8
+ * JSON Schema, confirm edge_id enum matches outgoing_edges
9
+ * - Human timeout edge — if timeout is set and outgoing_edges.length > 0
10
+ * but on_timeout_edge_id is unset, warn
11
+ */
12
+ import type { ProcessDefinition } from '@sequenceholdings/lattice/define';
13
+ export interface LintIssue {
14
+ process_id: string;
15
+ node_id?: string;
16
+ message: string;
17
+ }
18
+ export interface LintResult {
19
+ errors: readonly LintIssue[];
20
+ warnings: readonly LintIssue[];
21
+ }
22
+ export interface LintInput {
23
+ defs: readonly {
24
+ file: string;
25
+ process: ProcessDefinition;
26
+ }[];
27
+ /**
28
+ * Optional async hook to validate an agent node against its real
29
+ * agent-builder definition. The CLI wires this to
30
+ * `GET /api/agents/agents/:id`; for unit tests or offline use, leave
31
+ * undefined and a warning is emitted instead of failing the build.
32
+ */
33
+ loadAgentSchemaEdgeIdEnum?: (agentId: string) => Promise<string[] | null | 'not-found'>;
34
+ }
35
+ export declare function lintProcesses(input: LintInput): Promise<LintResult>;
36
+ export declare function formatLintResult(result: LintResult): {
37
+ text: string;
38
+ hasErrors: boolean;
39
+ };
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Production-grade lint for v2 process definitions — the single source
3
+ * of truth for process lint checks.
4
+ *
5
+ * - Graph integrity (already enforced by defineProcess at import time)
6
+ * - Reachability (BFS from start_node_id; orphans are warnings)
7
+ * - Agent output contract — load the agent definition, walk its
8
+ * JSON Schema, confirm edge_id enum matches outgoing_edges
9
+ * - Human timeout edge — if timeout is set and outgoing_edges.length > 0
10
+ * but on_timeout_edge_id is unset, warn
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { parallelSubNodes, validateEmailReminders } from '@sequenceholdings/lattice/define';
14
+ export async function lintProcesses(input) {
15
+ const errors = [];
16
+ const warnings = [];
17
+ for (const { file, process: p } of input.defs) {
18
+ const nodeIds = new Set(p.nodes.map((n) => n.id));
19
+ if (!nodeIds.has(p.start_node_id)) {
20
+ errors.push({
21
+ process_id: p.id,
22
+ message: `start_node_id "${p.start_node_id}" is not in nodes`,
23
+ });
24
+ continue;
25
+ }
26
+ for (const node of p.nodes) {
27
+ const seen = new Set();
28
+ for (const edge of node.outgoing_edges) {
29
+ if (seen.has(edge.id)) {
30
+ errors.push({
31
+ process_id: p.id,
32
+ node_id: node.id,
33
+ message: `duplicate outgoing edge id "${edge.id}"`,
34
+ });
35
+ }
36
+ seen.add(edge.id);
37
+ if (!nodeIds.has(edge.to)) {
38
+ errors.push({
39
+ process_id: p.id,
40
+ node_id: node.id,
41
+ message: `outgoing edge "${edge.id}" targets non-existent node "${edge.to}"`,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ const reachable = new Set([p.start_node_id]);
47
+ const queue = [p.start_node_id];
48
+ while (queue.length > 0) {
49
+ const id = queue.shift();
50
+ const node = p.nodes.find((n) => n.id === id);
51
+ if (!node)
52
+ continue;
53
+ for (const edge of node.outgoing_edges) {
54
+ if (!reachable.has(edge.to)) {
55
+ reachable.add(edge.to);
56
+ queue.push(edge.to);
57
+ }
58
+ }
59
+ }
60
+ for (const node of p.nodes) {
61
+ if (!reachable.has(node.id)) {
62
+ warnings.push({
63
+ process_id: p.id,
64
+ node_id: node.id,
65
+ message: `node "${node.id}" is unreachable from start_node_id "${p.start_node_id}"`,
66
+ });
67
+ }
68
+ }
69
+ for (const node of p.nodes) {
70
+ await lintNodeContract(p.id, node, errors, warnings, input.loadAgentSchemaEdgeIdEnum);
71
+ }
72
+ // Closure-scope check: mappers / fn / join / fan_out / output are
73
+ // serialized via `.toString()` and run in a vm with NOTHING from the
74
+ // module in scope. A reference to an imported binding therefore throws
75
+ // ReferenceError at runtime (the classic "helper refs are not in bundle
76
+ // scope" bug). Cross-check each serialized function against the module's
77
+ // imports. Warn (not error) — high-signal but heuristic, and we don't want
78
+ // to break an existing process on a regex edge case.
79
+ const importedNames = readImportedNames(file);
80
+ if (importedNames.size > 0) {
81
+ for (const node of p.nodes) {
82
+ lintSandboxClosures(p.id, node, importedNames, warnings);
83
+ }
84
+ }
85
+ }
86
+ return { errors, warnings };
87
+ }
88
+ /**
89
+ * Module-scope binding names introduced by `import` statements in the process
90
+ * file. These are exactly the names a serialized (vm-executed) function must
91
+ * NOT reference. Best-effort regex parse; returns empty when the file can't be
92
+ * read (e.g. unit tests passing a synthetic path) so the check simply no-ops.
93
+ */
94
+ function readImportedNames(file) {
95
+ let source;
96
+ try {
97
+ source = readFileSync(file, 'utf8');
98
+ }
99
+ catch {
100
+ return new Set();
101
+ }
102
+ const names = new Set();
103
+ const importRe = /import\s+(type\s+)?([^'";]+?)\s+from\s*['"][^'"]+['"]/g;
104
+ let m;
105
+ while ((m = importRe.exec(source)) !== null) {
106
+ if (m[1])
107
+ continue; // `import type ...` is erased at runtime — never a free-var bug
108
+ const clause = m[2].trim();
109
+ const named = clause.match(/\{([^}]*)\}/);
110
+ if (named) {
111
+ for (const raw of named[1].split(',')) {
112
+ const part = raw.trim();
113
+ if (!part || part.startsWith('type '))
114
+ continue;
115
+ const asParts = part.split(/\s+as\s+/);
116
+ const local = (asParts[1] ?? asParts[0]).trim();
117
+ if (local)
118
+ names.add(local);
119
+ }
120
+ }
121
+ const ns = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
122
+ if (ns)
123
+ names.add(ns[1]);
124
+ // default import: the bare leading identifier before any `,`/`{`/`*`
125
+ const def = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/);
126
+ if (def)
127
+ names.add(def[1]);
128
+ }
129
+ return names;
130
+ }
131
+ /** Serialized (vm-executed) function sources declared on a node. */
132
+ function nodeSerializedFns(node) {
133
+ const out = [];
134
+ const add = (field, fn) => {
135
+ if (typeof fn === 'function')
136
+ out.push({ field, source: fn.toString() });
137
+ };
138
+ switch (node.kind) {
139
+ case 'automation':
140
+ // Native automation handlers run in-process from the Atlas image — their
141
+ // body is NOT serialized, so only the (serialized) input mapper is checked.
142
+ add('input', node.input);
143
+ break;
144
+ case 'agent':
145
+ add('input', node.input);
146
+ break;
147
+ case 'human': {
148
+ const human = node;
149
+ add('input', human.input);
150
+ if (typeof human.artifact_refs === 'function')
151
+ add('artifact_refs', human.artifact_refs);
152
+ if (typeof human.advance_allowed === 'function')
153
+ add('advance_allowed', human.advance_allowed);
154
+ if (typeof human.dueDate === 'function')
155
+ add('dueDate', human.dueDate);
156
+ break;
157
+ }
158
+ case 'parallel': {
159
+ const parallel = node;
160
+ add('join', parallel.join);
161
+ add('input', parallel.input);
162
+ if ('fan_out' in parallel)
163
+ add('fan_out', parallel.fan_out);
164
+ break;
165
+ }
166
+ case 'subprocess': {
167
+ const subprocess = node;
168
+ add('input', subprocess.input);
169
+ add('output', subprocess.output);
170
+ break;
171
+ }
172
+ case 'managed_function':
173
+ // The input mapper is serialized to `input_mapper_source` and runs in
174
+ // the same vm as every other mapper, so it's subject to the identical
175
+ // free-variable / imported-helper hazard.
176
+ add('input', node.input);
177
+ break;
178
+ }
179
+ return out;
180
+ }
181
+ /** True if `name` is referenced as a free identifier in `source` (not a
182
+ * property access, not a substring, not locally re-declared/shadowed). */
183
+ function referencesFreeName(source, name) {
184
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
185
+ if (!new RegExp(`(?<![.\\w$])${esc}(?![\\w$])`).test(source))
186
+ return false;
187
+ // Locally declared inside the function → it shadows the import, not a bug.
188
+ if (new RegExp(`\\b(?:const|let|var|function|class)\\s+${esc}\\b`).test(source))
189
+ return false;
190
+ return true;
191
+ }
192
+ function lintSandboxClosures(processId, node, importedNames, warnings) {
193
+ for (const { field, source } of nodeSerializedFns(node)) {
194
+ for (const name of importedNames) {
195
+ if (referencesFreeName(source, name)) {
196
+ warnings.push({
197
+ process_id: processId,
198
+ node_id: node.id,
199
+ message: `${field} references imported "${name}", which is NOT in scope when the function runs in the lattice vm — inline the value/helper inside the function (serialized via .toString(), module imports are not bundled)`,
200
+ });
201
+ }
202
+ }
203
+ }
204
+ if (node.kind === 'parallel') {
205
+ for (const { node: sub } of parallelSubNodes(node)) {
206
+ lintSandboxClosures(processId, sub, importedNames, warnings);
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Per-node output-contract lint. For parallel nodes this recurses into every
212
+ * sub-node (so a sub-node automation/agent/human is held to the same
213
+ * edge_id-contract checks as a top-level node) and lints the `join` reducer's
214
+ * own edge_id returns against the parallel node's outgoing_edges.
215
+ */
216
+ async function lintNodeContract(processId, node, errors, warnings, loadAgentSchemaEdgeIdEnum) {
217
+ switch (node.kind) {
218
+ case 'agent':
219
+ await lintAgent(processId, node, errors, warnings, loadAgentSchemaEdgeIdEnum);
220
+ break;
221
+ case 'human':
222
+ lintHuman(processId, node, errors, warnings);
223
+ break;
224
+ case 'parallel': {
225
+ const parallel = node;
226
+ lintParallelJoin(processId, parallel, errors, warnings);
227
+ for (const { node: sub } of parallelSubNodes(parallel)) {
228
+ await lintNodeContract(processId, sub, errors, warnings, loadAgentSchemaEdgeIdEnum);
229
+ }
230
+ break;
231
+ }
232
+ case 'subprocess': {
233
+ const subprocess = node;
234
+ lintSubprocessOutput(processId, subprocess, errors, warnings);
235
+ break;
236
+ }
237
+ case 'managed_function': {
238
+ lintManagedFunctionNode(processId, node, errors);
239
+ break;
240
+ }
241
+ case 'automation': {
242
+ lintRouteRulesEdges(processId, node, errors);
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ /**
248
+ * A node whose config declares `routeRules` (content-conditional routing —
249
+ * see `service-event-form-validate`) must declare EVERY rule's `edge_id` as an
250
+ * outgoing edge. This is the only place the hole can be closed: the native fn
251
+ * can't see the graph from its execution context, and the engine's edge router
252
+ * AUTO-TAKES a sole outgoing edge while silently discarding the returned
253
+ * edge_id — so a rule targeting a forgotten edge would route every submission
254
+ * down `ok` with zero signal. (With the edge declared, the node has ≥2 edges
255
+ * and the router's unknown-edge check is already fail-closed at runtime.)
256
+ * Generic by config shape, so any future function adopting the routeRules
257
+ * convention is covered.
258
+ */
259
+ function lintRouteRulesEdges(processId, node, errors) {
260
+ const config = node.config;
261
+ const rules = config?.routeRules;
262
+ if (!Array.isArray(rules) || rules.length === 0)
263
+ return;
264
+ const edgeIds = new Set(node.outgoing_edges.map((e) => e.id));
265
+ // The fn returns 'ok' when no rule matches (hardcoded fallback in
266
+ // service-event-form-validate) — a routeRules node without an ok edge would
267
+ // pass the per-rule check below and then fail the FIRST non-matching
268
+ // submission at runtime (InvalidEdgeIdError). Catch it at lint time.
269
+ if (!edgeIds.has('ok')) {
270
+ errors.push({
271
+ process_id: processId,
272
+ node_id: node.id,
273
+ message: 'a node with routeRules must also declare the "ok" outgoing edge — it is the ' +
274
+ 'fallback the function returns when no rule matches',
275
+ });
276
+ }
277
+ for (const rule of rules) {
278
+ const edgeId = rule?.edge_id;
279
+ if (typeof edgeId !== 'string')
280
+ continue; // shape errors are the fn's fail-closed job
281
+ if (!edgeIds.has(edgeId)) {
282
+ errors.push({
283
+ process_id: processId,
284
+ node_id: node.id,
285
+ message: `routeRules targets edge "${edgeId}" which is not a declared outgoing edge — ` +
286
+ 'with a sole outgoing edge the engine ignores the returned edge_id and every ' +
287
+ 'submission silently routes down it; declare the edge on the node',
288
+ });
289
+ }
290
+ }
291
+ }
292
+ function lintManagedFunctionNode(processId, node, errors) {
293
+ const edgeIds = new Set(node.outgoing_edges.map((e) => e.id));
294
+ for (const required of ['success', 'error']) {
295
+ if (!edgeIds.has(required)) {
296
+ errors.push({
297
+ process_id: processId,
298
+ node_id: node.id,
299
+ message: `managed_function node must declare outgoing edge "${required}"`,
300
+ });
301
+ }
302
+ }
303
+ if (!node.function?.trim()) {
304
+ errors.push({
305
+ process_id: processId,
306
+ node_id: node.id,
307
+ message: 'managed_function node must declare a non-empty `function` ref',
308
+ });
309
+ }
310
+ }
311
+ function lintSubprocessOutput(processId, node, errors, warnings) {
312
+ const outgoingIds = new Set(node.outgoing_edges.map((e) => e.id));
313
+ const source = node.output.toString();
314
+ let match;
315
+ EDGE_ID_LITERAL_RE.lastIndex = 0;
316
+ const found = new Set();
317
+ while ((match = EDGE_ID_LITERAL_RE.exec(source)) !== null) {
318
+ found.add(match[1]);
319
+ }
320
+ for (const literal of found) {
321
+ if (!outgoingIds.has(literal)) {
322
+ errors.push({
323
+ process_id: processId,
324
+ node_id: node.id,
325
+ message: `subprocess output returns edge_id "${literal}" which is not in outgoing_edges [${[...outgoingIds].join(', ')}]`,
326
+ });
327
+ }
328
+ }
329
+ if (node.outgoing_edges.length > 0 && found.size === 0) {
330
+ warnings.push({
331
+ process_id: processId,
332
+ node_id: node.id,
333
+ message: `subprocess node has outgoing edges but no literal edge_id found in output mapper — confirm it selects edge_id correctly`,
334
+ });
335
+ }
336
+ }
337
+ const EDGE_ID_LITERAL_RE = /\bedge_id\s*:\s*['"`]([^'"`]+)['"`]/g;
338
+ async function lintAgent(processId, node, errors, warnings, loadAgentSchemaEdgeIdEnum) {
339
+ const allEdgeIds = node.outgoing_edges.map((e) => e.id);
340
+ // The reset edge (taken out-of-band by the reset intervention, never chosen
341
+ // by the agent) must exist on the node but is excluded from the agent's
342
+ // edge_id contract — same way a human node's on_timeout_edge_id is an
343
+ // orchestration-only edge. Validate membership, then verify the agent's
344
+ // edge_id enum against the REAL (non-reset) edges only.
345
+ if (node.on_reset_edge_id) {
346
+ if (!allEdgeIds.includes(node.on_reset_edge_id)) {
347
+ errors.push({
348
+ process_id: processId,
349
+ node_id: node.id,
350
+ message: `on_reset_edge_id "${node.on_reset_edge_id}" is not in outgoing_edges`,
351
+ });
352
+ }
353
+ }
354
+ const outgoingIds = allEdgeIds.filter((id) => id !== node.on_reset_edge_id);
355
+ if (!loadAgentSchemaEdgeIdEnum) {
356
+ if (outgoingIds.length > 1) {
357
+ warnings.push({
358
+ process_id: processId,
359
+ node_id: node.id,
360
+ message: `agent node has ${outgoingIds.length} outgoing edges; agent output schema vs outgoing_edges contract not verified (no agent registry loader available)`,
361
+ });
362
+ }
363
+ return;
364
+ }
365
+ let enumValues;
366
+ try {
367
+ enumValues = await loadAgentSchemaEdgeIdEnum(node.agent.id);
368
+ }
369
+ catch (err) {
370
+ warnings.push({
371
+ process_id: processId,
372
+ node_id: node.id,
373
+ message: `could not load agent definition "${node.agent.id}" to verify output contract: ${err instanceof Error ? err.message : String(err)} (run with DB access to enable full check)`,
374
+ });
375
+ return;
376
+ }
377
+ if (enumValues === 'not-found') {
378
+ errors.push({
379
+ process_id: processId,
380
+ node_id: node.id,
381
+ message: `agent "${node.agent.id}" not found in the target env — sync/seed the agent registry before applying`,
382
+ });
383
+ return;
384
+ }
385
+ if (outgoingIds.length === 0)
386
+ return;
387
+ if (enumValues === null) {
388
+ // No edge_id enum is the generic-agent contract: one agent serving nodes
389
+ // with different edge sets declares edge_id as a plain string, the valid
390
+ // set rides the node's input mapper (prompt), and the runner validates
391
+ // the returned edge_id against the node's outgoing_edges at runtime. An
392
+ // agent that DOES declare an enum keeps the exact-match check below.
393
+ return;
394
+ }
395
+ const missing = outgoingIds.filter((id) => !enumValues.includes(id));
396
+ const extra = enumValues.filter((id) => !outgoingIds.includes(id));
397
+ if (missing.length > 0 || extra.length > 0) {
398
+ errors.push({
399
+ process_id: processId,
400
+ node_id: node.id,
401
+ message: `agent "${node.agent.id}" edge_id enum mismatch — missing: [${missing.join(', ')}], extra: [${extra.join(', ')}]; expected exactly: [${outgoingIds.join(', ')}]`,
402
+ });
403
+ }
404
+ }
405
+ /**
406
+ * Lint a parallel node's `join` reducer the way we lint automation fns:
407
+ * scan the serialized source for literal `edge_id: '…'` returns and confirm
408
+ * each is in the parallel node's own outgoing_edges. The reducer chooses the
409
+ * parallel node's exit edge, so a typo there is a routing bug the orchestrator
410
+ * would only surface at run time.
411
+ */
412
+ function lintParallelJoin(processId, node, errors, warnings) {
413
+ const join = node.join;
414
+ const source = typeof join === 'function' ? join.toString() : '';
415
+ const outgoingIds = new Set(node.outgoing_edges.map((e) => e.id));
416
+ const literals = new Set();
417
+ for (const match of source.matchAll(EDGE_ID_LITERAL_RE)) {
418
+ if (match[1])
419
+ literals.add(match[1]);
420
+ }
421
+ for (const literal of literals) {
422
+ if (!outgoingIds.has(literal)) {
423
+ errors.push({
424
+ process_id: processId,
425
+ node_id: node.id,
426
+ message: `parallel join returns edge_id "${literal}" which is not in outgoing_edges [${[...outgoingIds].join(', ')}]`,
427
+ });
428
+ }
429
+ }
430
+ if (node.outgoing_edges.length > 1 && literals.size === 0) {
431
+ warnings.push({
432
+ process_id: processId,
433
+ node_id: node.id,
434
+ message: `parallel node has ${node.outgoing_edges.length} outgoing edges but no literal edge_id returns found in join source — confirm join selects edge_id correctly`,
435
+ });
436
+ }
437
+ }
438
+ function lintHuman(processId, node, errors, warnings) {
439
+ // Email reminders that can never fire (offset before the task is ready, or
440
+ // after it times out / past the cap) are author bugs the runner silently
441
+ // skips — catch them here. Only the object form carries reminders.
442
+ const email = node.emailNotification;
443
+ if (email && typeof email === 'object') {
444
+ const hasDueDate = typeof node.dueDate === 'function';
445
+ for (const issue of validateEmailReminders(email, node.timeout, hasDueDate)) {
446
+ errors.push({
447
+ process_id: processId,
448
+ node_id: node.id,
449
+ message: issue.index === undefined
450
+ ? `emailNotification.${issue.message}`
451
+ : `emailNotification.reminders[${issue.index}]: ${issue.message}`,
452
+ });
453
+ }
454
+ }
455
+ if (node.on_timeout_edge_id) {
456
+ const ok = node.outgoing_edges.some((e) => e.id === node.on_timeout_edge_id);
457
+ if (!ok) {
458
+ errors.push({
459
+ process_id: processId,
460
+ node_id: node.id,
461
+ message: `on_timeout_edge_id "${node.on_timeout_edge_id}" is not in outgoing_edges`,
462
+ });
463
+ }
464
+ return;
465
+ }
466
+ if (node.timeout && node.outgoing_edges.length > 0) {
467
+ warnings.push({
468
+ process_id: processId,
469
+ node_id: node.id,
470
+ message: `human node has timeout=${node.timeout} but no on_timeout_edge_id — run will fail with MissingEdgeIdError when the wait token times out`,
471
+ });
472
+ }
473
+ }
474
+ export function formatLintResult(result) {
475
+ const lines = [];
476
+ for (const e of result.errors) {
477
+ const where = e.node_id ? `${e.process_id}.${e.node_id}` : e.process_id;
478
+ lines.push(`ERROR [${where}] ${e.message}`);
479
+ }
480
+ for (const w of result.warnings) {
481
+ const where = w.node_id ? `${w.process_id}.${w.node_id}` : w.process_id;
482
+ lines.push(`WARN [${where}] ${w.message}`);
483
+ }
484
+ return { text: lines.join('\n'), hasErrors: result.errors.length > 0 };
485
+ }