@decocms/runtime 1.2.11 → 1.2.13

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.
@@ -0,0 +1,770 @@
1
+ import type { Step } from "@decocms/bindings/workflow";
2
+ import { z, type ZodTypeAny } from "zod";
3
+ import { proxyConnectionForId } from "./bindings.ts";
4
+ import { MCPClient } from "./mcp.ts";
5
+
6
+ /**
7
+ * Declarative workflow definition for MCP servers.
8
+ * Workflows declared here are automatically synced to the mesh
9
+ * as workflow_collection entries during ON_MCP_CONFIGURATION, and a
10
+ * trigger tool is automatically generated for each one.
11
+ */
12
+ export interface WorkflowDefinition {
13
+ title: string;
14
+ description?: string;
15
+ /**
16
+ * Virtual MCP ID that will execute this workflow's steps.
17
+ * Used as the default for the generated trigger tool.
18
+ * Can be overridden at call time via the tool's `virtual_mcp_id` input.
19
+ */
20
+ virtual_mcp_id?: string;
21
+ steps: Step[];
22
+ /**
23
+ * Override the auto-generated tool ID for the workflow trigger tool.
24
+ * Defaults to START_WORKFLOW_<TITLE_SLUG> (e.g. START_WORKFLOW_FETCH_USERS).
25
+ */
26
+ toolId?: string;
27
+ }
28
+
29
+ interface WorkflowCollectionItem {
30
+ id: string;
31
+ title: string;
32
+ description: string | null;
33
+ virtual_mcp_id: string | null;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ interface DefaultVirtualMCPItem {
39
+ id: string;
40
+ title: string;
41
+ }
42
+
43
+ /**
44
+ * Hand-rolled client interface for the workflow collection tools exposed by
45
+ * the mesh's /mcp/self endpoint.
46
+ *
47
+ * TODO: Replace with a generated client derived from WorkflowBinding in
48
+ * @decocms/bindings/workflow once that binding covers write operations
49
+ * (CREATE, UPDATE, DELETE) and COLLECTION_WORKFLOW_EXECUTION_CREATE.
50
+ * Until then, any rename of a tool or field on the server side requires a
51
+ * matching change here — the bindings system was designed to prevent exactly
52
+ * this class of silent drift.
53
+ */
54
+ interface MeshWorkflowClient {
55
+ COLLECTION_WORKFLOW_LIST: (input: {
56
+ limit?: number;
57
+ offset?: number;
58
+ }) => Promise<{
59
+ items: WorkflowCollectionItem[];
60
+ totalCount: number;
61
+ hasMore: boolean;
62
+ }>;
63
+ COLLECTION_WORKFLOW_CREATE: (input: {
64
+ data: {
65
+ id: string;
66
+ title: string;
67
+ description?: string;
68
+ virtual_mcp_id?: string;
69
+ steps: Step[];
70
+ };
71
+ }) => Promise<{ item: WorkflowCollectionItem }>;
72
+ COLLECTION_WORKFLOW_UPDATE: (input: {
73
+ id: string;
74
+ data: {
75
+ title?: string;
76
+ description?: string;
77
+ virtual_mcp_id?: string;
78
+ steps?: Step[];
79
+ };
80
+ }) => Promise<{ success: boolean; error?: string }>;
81
+ COLLECTION_WORKFLOW_DELETE: (input: {
82
+ id: string;
83
+ }) => Promise<{ success: boolean; error?: string }>;
84
+ COLLECTION_WORKFLOW_EXECUTION_CREATE: (input: {
85
+ workflow_collection_id: string;
86
+ virtual_mcp_id?: string;
87
+ input?: Record<string, unknown>;
88
+ start_at_epoch_ms?: number;
89
+ }) => Promise<{ item: { id: string } }>;
90
+ COLLECTION_VIRTUAL_MCP_LIST: (input: {
91
+ where?: {
92
+ operator: "and";
93
+ conditions: Array<{ field: string[]; operator: string; value: unknown }>;
94
+ };
95
+ limit?: number;
96
+ offset?: number;
97
+ }) => Promise<{
98
+ items: DefaultVirtualMCPItem[];
99
+ totalCount: number;
100
+ hasMore: boolean;
101
+ }>;
102
+ COLLECTION_VIRTUAL_MCP_CREATE: (input: {
103
+ data: {
104
+ title: string;
105
+ connections: Array<{
106
+ connection_id: string;
107
+ selected_tools: null;
108
+ }>;
109
+ };
110
+ }) => Promise<{ item: DefaultVirtualMCPItem }>;
111
+ }
112
+
113
+ function slugify(title: string): string {
114
+ return title
115
+ .toLowerCase()
116
+ .trim()
117
+ .replace(/[^a-z0-9]+/g, "-")
118
+ .replace(/^-|-$/g, "");
119
+ }
120
+
121
+ function workflowId(connectionId: string, title: string): string {
122
+ return `${connectionId}::${slugify(title)}`;
123
+ }
124
+
125
+ /**
126
+ * Derives the auto-generated trigger tool ID for a workflow.
127
+ * "Fetch Users and Process" → "START_WORKFLOW_FETCH_USERS_AND_PROCESS"
128
+ */
129
+ export function workflowToolId(title: string): string {
130
+ return `START_WORKFLOW_${slugify(title).toUpperCase().replace(/-/g, "_")}`;
131
+ }
132
+
133
+ function createMeshSelfClient(
134
+ meshUrl: string,
135
+ token?: string,
136
+ ): MeshWorkflowClient {
137
+ const connection = proxyConnectionForId("self", { meshUrl, token });
138
+ return MCPClient.forConnection(connection) as unknown as MeshWorkflowClient;
139
+ }
140
+
141
+ // I7: Per-connectionId mutex — chains incoming syncs so operations never interleave.
142
+ const syncInFlight = new Map<string, Promise<void>>();
143
+
144
+ // I4: Fingerprint of the last successfully synced declared set, keyed by connectionId.
145
+ // Capped at MAX_FINGERPRINT_CACHE entries to prevent unbounded growth in environments
146
+ // with rotating connection IDs. Oldest entry is evicted when the cap is reached.
147
+ // Callers that own connection lifecycle should call Workflow.clearFingerprint() on teardown.
148
+ const MAX_FINGERPRINT_CACHE = 500;
149
+ const workflowFingerprints = new Map<string, string>();
150
+
151
+ function setFingerprint(connectionId: string, fingerprint: string) {
152
+ if (
153
+ !workflowFingerprints.has(connectionId) &&
154
+ workflowFingerprints.size >= MAX_FINGERPRINT_CACHE
155
+ ) {
156
+ const firstKey = workflowFingerprints.keys().next().value;
157
+ if (firstKey !== undefined) workflowFingerprints.delete(firstKey);
158
+ }
159
+ workflowFingerprints.set(connectionId, fingerprint);
160
+ }
161
+
162
+ // Derives the title for the auto-generated default Virtual MCP.
163
+ // Embedding the connectionId makes each VMCP identifiable in the UI and
164
+ // uniquely addressable in LIST lookups without relying on the connection_id
165
+ // filter alone (which would match any VMCP that includes this connection).
166
+ function defaultVmcpTitle(connectionId: string): string {
167
+ return `Workflows Agent (${connectionId})`;
168
+ }
169
+
170
+ // Cache of connectionId → auto-created default Virtual MCP ID.
171
+ // Capped at the same size as the fingerprint cache.
172
+ const defaultVmcpByConnection = new Map<string, string>();
173
+
174
+ function setDefaultVmcp(connectionId: string, vmcpId: string) {
175
+ if (
176
+ !defaultVmcpByConnection.has(connectionId) &&
177
+ defaultVmcpByConnection.size >= MAX_FINGERPRINT_CACHE
178
+ ) {
179
+ const firstKey = defaultVmcpByConnection.keys().next().value;
180
+ if (firstKey !== undefined) defaultVmcpByConnection.delete(firstKey);
181
+ }
182
+ defaultVmcpByConnection.set(connectionId, vmcpId);
183
+ }
184
+
185
+ /**
186
+ * Returns the ID of the "Workflows Agent" Virtual MCP for a connection,
187
+ * creating one if it does not yet exist.
188
+ *
189
+ * Resolution order:
190
+ * 1. Module-level cache (avoids the round-trip within a process lifetime).
191
+ * 2. Remote LIST filtered by connection_id + title (survives restarts).
192
+ * 3. Remote CREATE — only when no matching VMCP is found.
193
+ *
194
+ * Any network failure is logged and causes the function to return undefined
195
+ * so callers continue without a default rather than failing the whole sync.
196
+ */
197
+ async function resolveDefaultVirtualMcp(
198
+ connectionId: string,
199
+ client: MeshWorkflowClient,
200
+ tag: string,
201
+ ): Promise<string | undefined> {
202
+ const cached = defaultVmcpByConnection.get(connectionId);
203
+ if (cached) {
204
+ console.log(`${tag} Using cached default Virtual MCP: ${cached}`);
205
+ return cached;
206
+ }
207
+
208
+ const title = defaultVmcpTitle(connectionId);
209
+
210
+ try {
211
+ const result = await client.COLLECTION_VIRTUAL_MCP_LIST({
212
+ where: {
213
+ operator: "and",
214
+ conditions: [
215
+ { field: ["connection_id"], operator: "eq", value: connectionId },
216
+ { field: ["title"], operator: "eq", value: title },
217
+ ],
218
+ },
219
+ limit: 1,
220
+ });
221
+ if (result.items.length > 0) {
222
+ const vmcpId = result.items[0]!.id;
223
+ setDefaultVmcp(connectionId, vmcpId);
224
+ console.log(`${tag} Found existing default Virtual MCP: ${vmcpId}`);
225
+ return vmcpId;
226
+ }
227
+ } catch (err) {
228
+ console.warn(
229
+ `${tag} Could not list Virtual MCPs — proceeding without default. Error: ${err instanceof Error ? err.message : String(err)}`,
230
+ );
231
+ return undefined;
232
+ }
233
+
234
+ try {
235
+ const created = await client.COLLECTION_VIRTUAL_MCP_CREATE({
236
+ data: {
237
+ title,
238
+ connections: [{ connection_id: connectionId, selected_tools: null }],
239
+ },
240
+ });
241
+ const vmcpId = created.item.id;
242
+ setDefaultVmcp(connectionId, vmcpId);
243
+ console.log(`${tag} Created default Virtual MCP: ${vmcpId}`);
244
+ return vmcpId;
245
+ } catch (err) {
246
+ console.warn(
247
+ `${tag} Could not create default Virtual MCP — proceeding without default. Error: ${err instanceof Error ? err.message : String(err)}`,
248
+ );
249
+ return undefined;
250
+ }
251
+ }
252
+
253
+ function fingerprintWorkflows(declared: WorkflowDefinition[]): string {
254
+ return JSON.stringify(
255
+ declared.map((w) => ({
256
+ title: w.title,
257
+ description: w.description ?? null,
258
+ virtual_mcp_id: w.virtual_mcp_id ?? null,
259
+ steps: w.steps,
260
+ toolId: w.toolId ?? null,
261
+ })),
262
+ );
263
+ }
264
+
265
+ async function doSyncWorkflows(
266
+ declared: WorkflowDefinition[],
267
+ meshUrl: string,
268
+ connectionId: string,
269
+ token?: string,
270
+ _clientOverride?: MeshWorkflowClient,
271
+ ): Promise<void> {
272
+ const tag = `[Workflows][${connectionId}]`;
273
+
274
+ // I6: Reject any title that slugifies to empty — would produce IDs like "conn_abc::".
275
+ const emptySlugWf = declared.find((w) => slugify(w.title) === "");
276
+ if (emptySlugWf !== undefined) {
277
+ console.warn(
278
+ `${tag} Workflow title "${emptySlugWf.title}" produces an empty ID. Skipping sync.`,
279
+ );
280
+ return;
281
+ }
282
+
283
+ if (declared.length > 0) {
284
+ const slugs = declared.map((w) => slugify(w.title));
285
+ const uniqueSlugs = new Set(slugs);
286
+ if (uniqueSlugs.size !== slugs.length) {
287
+ const duplicateSlugs = new Set(
288
+ slugs.filter((s, i) => slugs.indexOf(s) !== i),
289
+ );
290
+ const collidingTitles = declared
291
+ .filter((w) => duplicateSlugs.has(slugify(w.title)))
292
+ .map((w) => w.title);
293
+ console.warn(
294
+ `${tag} Workflow titles that produce duplicate IDs: ${[...new Set(collidingTitles)].join(", ")}. Skipping sync.`,
295
+ );
296
+ return;
297
+ }
298
+ }
299
+
300
+ // I4: Skip the remote round-trip when the declared set is identical to the last sync.
301
+ const fingerprint = fingerprintWorkflows(declared);
302
+ const storedFingerprint = workflowFingerprints.get(connectionId);
303
+ if (storedFingerprint === fingerprint) {
304
+ console.log(
305
+ `${tag} Fingerprint unchanged — skipping sync. Declared: ${declared.length} workflow(s): [${declared.map((w) => w.title).join(", ")}]`,
306
+ );
307
+ return;
308
+ }
309
+ console.log(
310
+ `${tag} Fingerprint changed (or first sync) — starting sync. Declared: ${declared.length} workflow(s): [${declared.map((w) => w.title).join(", ")}]`,
311
+ storedFingerprint
312
+ ? "(previous fingerprint existed)"
313
+ : "(no previous fingerprint)",
314
+ );
315
+
316
+ const client = _clientOverride ?? createMeshSelfClient(meshUrl, token);
317
+
318
+ // Only resolve (or lazily create) the default Virtual MCP when at least one
319
+ // declared workflow actually needs the fallback `virtual_mcp_id`.
320
+ const needsDefault = declared.some((w) => w.virtual_mcp_id === undefined);
321
+ const defaultVmcpId = needsDefault
322
+ ? await resolveDefaultVirtualMcp(connectionId, client, tag)
323
+ : undefined;
324
+
325
+ let existing: WorkflowCollectionItem[];
326
+ try {
327
+ const allItems: WorkflowCollectionItem[] = [];
328
+ let offset = 0;
329
+ const limit = 200;
330
+ while (true) {
331
+ const page = await client.COLLECTION_WORKFLOW_LIST({ limit, offset });
332
+ allItems.push(...page.items);
333
+ if (!page.hasMore || page.items.length === 0) break;
334
+ offset += page.items.length;
335
+ }
336
+ existing = allItems;
337
+ console.log(
338
+ `${tag} LIST returned ${existing.length} total workflow(s). IDs owned by this connection: [${
339
+ existing
340
+ .filter((w) => w.id.startsWith(`${connectionId}::`))
341
+ .map((w) => w.id)
342
+ .join(", ") || "none"
343
+ }]`,
344
+ );
345
+ } catch (err) {
346
+ const errMsg = err instanceof Error ? err.message : String(err);
347
+ console.warn(
348
+ `${tag} Could not list workflows (workflows plugin may not be enabled). Skipping sync. Error: ${errMsg}`,
349
+ );
350
+ return;
351
+ }
352
+
353
+ const prefix = `${connectionId}::`;
354
+ const managed = new Map(
355
+ existing.filter((w) => w.id.startsWith(prefix)).map((w) => [w.id, w]),
356
+ );
357
+
358
+ // I5: Build ID→definition map synchronously so declaredIds is ready before
359
+ // parallelizing — the orphan-delete pass needs the complete set upfront.
360
+ const declaredEntries = declared.map(
361
+ (wf) => [workflowId(connectionId, wf.title), wf] as const,
362
+ );
363
+ const declaredIds = new Set(declaredEntries.map(([id]) => id));
364
+
365
+ let hadError = false;
366
+
367
+ // I5: Upserts run in parallel — no ordering dependency between workflows.
368
+ await Promise.all(
369
+ declaredEntries.map(async ([id, wf]) => {
370
+ const op = managed.has(id) ? "UPDATE" : "CREATE";
371
+ console.log(`${tag} ${op} "${wf.title}" (id=${id})`);
372
+ try {
373
+ // Explicit declaration wins; fall back to the auto-resolved default.
374
+ const resolvedVmcpId = wf.virtual_mcp_id ?? defaultVmcpId;
375
+
376
+ if (op === "UPDATE") {
377
+ const result = await client.COLLECTION_WORKFLOW_UPDATE({
378
+ id,
379
+ data: {
380
+ title: wf.title,
381
+ description: wf.description,
382
+ ...(resolvedVmcpId !== undefined && {
383
+ virtual_mcp_id: resolvedVmcpId,
384
+ }),
385
+ steps: wf.steps,
386
+ },
387
+ });
388
+ if (!result.success) {
389
+ hadError = true;
390
+ console.warn(
391
+ `${tag} UPDATE "${wf.title}" returned success=false:`,
392
+ String(result.error ?? "(no error message)"),
393
+ );
394
+ } else {
395
+ console.log(`${tag} UPDATE "${wf.title}" OK`);
396
+ }
397
+ } else {
398
+ await client.COLLECTION_WORKFLOW_CREATE({
399
+ data: {
400
+ id,
401
+ title: wf.title,
402
+ description: wf.description,
403
+ virtual_mcp_id: resolvedVmcpId,
404
+ steps: wf.steps,
405
+ },
406
+ });
407
+ console.log(`${tag} CREATE "${wf.title}" OK`);
408
+ }
409
+ } catch (error) {
410
+ hadError = true;
411
+ console.warn(
412
+ `${tag} Failed to ${op} workflow "${wf.title}":`,
413
+ error instanceof Error ? error.message : String(error),
414
+ );
415
+ }
416
+ }),
417
+ );
418
+
419
+ // I5: Deletes run in parallel — orphans are independent of each other.
420
+ const orphanIds = [...managed.keys()].filter((id) => !declaredIds.has(id));
421
+ if (orphanIds.length > 0) {
422
+ console.log(
423
+ `${tag} Deleting ${orphanIds.length} orphaned workflow(s): [${orphanIds.join(", ")}]`,
424
+ );
425
+ }
426
+ await Promise.all(
427
+ orphanIds.map(async (id) => {
428
+ try {
429
+ await client.COLLECTION_WORKFLOW_DELETE({ id });
430
+ console.log(`${tag} DELETE "${id}" OK`);
431
+ } catch (error) {
432
+ hadError = true;
433
+ console.warn(
434
+ `${tag} Failed to delete orphaned workflow "${id}":`,
435
+ error instanceof Error ? error.message : String(error),
436
+ );
437
+ }
438
+ }),
439
+ );
440
+
441
+ // I4: Only record the fingerprint when every operation succeeded so that
442
+ // a follow-up call with an identical declared set retries any failures
443
+ // rather than silently skipping them.
444
+ if (!hadError) {
445
+ setFingerprint(connectionId, fingerprint);
446
+ console.log(`${tag} Sync complete — fingerprint stored.`);
447
+ } else {
448
+ console.warn(
449
+ `${tag} Sync finished with errors — fingerprint NOT stored so the next call will retry.`,
450
+ );
451
+ }
452
+ }
453
+
454
+ async function syncWorkflows(
455
+ declared: WorkflowDefinition[],
456
+ meshUrl: string,
457
+ connectionId: string,
458
+ token?: string,
459
+ /**
460
+ * @internal Only used in tests to capture payloads without a real server.
461
+ * Not part of the public API contract; may be removed without notice.
462
+ */
463
+ _clientOverride?: MeshWorkflowClient,
464
+ ): Promise<void> {
465
+ // I7: Chain onto any in-flight sync for this connectionId so concurrent calls
466
+ // never interleave LIST/CREATE/DELETE operations against the same connection.
467
+ const previous = syncInFlight.get(connectionId) ?? Promise.resolve();
468
+ const next = previous
469
+ // Isolate from predecessor's rejection so a failed prior sync doesn't
470
+ // propagate its error to unrelated callers queued behind it.
471
+ .catch(() => {})
472
+ .then(() =>
473
+ doSyncWorkflows(declared, meshUrl, connectionId, token, _clientOverride),
474
+ )
475
+ .finally(() => {
476
+ if (syncInFlight.get(connectionId) === next) {
477
+ syncInFlight.delete(connectionId);
478
+ }
479
+ });
480
+ syncInFlight.set(connectionId, next);
481
+ return next;
482
+ }
483
+
484
+ /**
485
+ * Scopes required by a connection that declares workflows.
486
+ * Co-located here so any rename of a server-side tool name causes a compile
487
+ * error in the consumer rather than a silent scope mismatch.
488
+ */
489
+ export const WORKFLOW_SCOPES = [
490
+ "SELF::COLLECTION_WORKFLOW_LIST",
491
+ "SELF::COLLECTION_WORKFLOW_CREATE",
492
+ "SELF::COLLECTION_WORKFLOW_UPDATE",
493
+ "SELF::COLLECTION_WORKFLOW_DELETE",
494
+ "SELF::COLLECTION_WORKFLOW_EXECUTION_CREATE",
495
+ "SELF::COLLECTION_VIRTUAL_MCP_LIST",
496
+ "SELF::COLLECTION_VIRTUAL_MCP_CREATE",
497
+ ] as const;
498
+
499
+ export const Workflow = {
500
+ sync: syncWorkflows,
501
+ slugify,
502
+ workflowId,
503
+ toolId: workflowToolId,
504
+ /**
505
+ * Creates a workflow execution via the mesh self-endpoint.
506
+ * Returns the execution ID of the newly created execution.
507
+ * This keeps the MeshWorkflowClient factory internal to this module.
508
+ */
509
+ createExecution: async (
510
+ meshUrl: string,
511
+ token: string | undefined,
512
+ params: {
513
+ workflow_collection_id: string;
514
+ virtual_mcp_id?: string;
515
+ input?: Record<string, unknown>;
516
+ start_at_epoch_ms?: number;
517
+ },
518
+ ): Promise<string> => {
519
+ const client = createMeshSelfClient(meshUrl, token);
520
+ const result = await client.COLLECTION_WORKFLOW_EXECUTION_CREATE(params);
521
+ return result.item.id;
522
+ },
523
+ /**
524
+ * Clears the cached fingerprint and default Virtual MCP ID for a connection
525
+ * so the next sync performs a full remote round-trip. Call this on connection
526
+ * teardown or when you need to force a re-sync without changing the declared
527
+ * workflow set.
528
+ */
529
+ clearFingerprint: (connectionId: string) => {
530
+ workflowFingerprints.delete(connectionId);
531
+ defaultVmcpByConnection.delete(connectionId);
532
+ },
533
+ };
534
+
535
+ // ============================================================================
536
+ // Fluent Workflow Builder
537
+ // ============================================================================
538
+
539
+ /**
540
+ * Minimal tool shape for builder type inference and schema injection.
541
+ * Defined locally to avoid a circular import with tools.ts (which imports
542
+ * WorkflowDefinition from this file). Includes inputSchema so the builder
543
+ * can derive per-tool input key suggestions, and outputSchema so the builder
544
+ * can auto-inject a step's outputSchema to detect schema drift at sync time.
545
+ */
546
+ type ToolLike<TId extends string = string> = {
547
+ id: TId;
548
+ inputSchema: ZodTypeAny;
549
+ outputSchema?: ZodTypeAny;
550
+ };
551
+
552
+ /**
553
+ * All valid @ref strings given the set of declared step names TSteps.
554
+ *
555
+ * - `@input` / `@input.field` — workflow input
556
+ * - `@stepName` / `@stepName.field` — output of a declared step
557
+ * - `@item` / `@item.${string}` — current forEach item
558
+ * - `@index` — current forEach index
559
+ * - `@ctx.execution_id` — current execution ID
560
+ *
561
+ * `string & {}` keeps the type as `string` so arbitrary values still compile,
562
+ * but the union members get autocomplete and type-narrowing in editors.
563
+ */
564
+ type KnownRefs<TSteps extends string> =
565
+ | `@input`
566
+ | `@input.${string}`
567
+ | `@item`
568
+ | `@item.${string}`
569
+ | `@index`
570
+ | `@ctx.execution_id`
571
+ | `@${TSteps}`
572
+ | `@${TSteps}.${string}`
573
+ | (string & {});
574
+
575
+ type StepInput<TSteps extends string> = Record<
576
+ string,
577
+ KnownRefs<TSteps> | unknown
578
+ >;
579
+
580
+ /**
581
+ * Derives the `input` type for a tool step.
582
+ *
583
+ * Keys are the tool's inputSchema field names (for autocomplete); values are
584
+ * @refs or any literal. An index signature allows additional arbitrary keys.
585
+ * Falls back to generic StepInput when the tool is not found in TTools.
586
+ */
587
+ type InputForTool<
588
+ TTools extends readonly ToolLike[],
589
+ TId extends string,
590
+ TSteps extends string,
591
+ > = Extract<TTools[number], { id: TId }> extends { inputSchema: infer TIn }
592
+ ? TIn extends ZodTypeAny
593
+ ? {
594
+ [K in keyof TIn["_output"]]?: KnownRefs<TSteps> | TIn["_output"][K];
595
+ } & { [key: string]: KnownRefs<TSteps> | unknown }
596
+ : StepInput<TSteps>
597
+ : StepInput<TSteps>;
598
+
599
+ type BaseStepFields = Omit<Step, "name" | "input" | "action">;
600
+ type BaseForEachFields = Omit<Step, "name" | "forEach" | "input" | "action">;
601
+
602
+ /**
603
+ * Tool-call variants of StepOpts — one discriminated member per tool ID so
604
+ * TypeScript narrows the `input` type based on the value of `toolName`.
605
+ * Falls back to `string` for toolName when no tools are registered.
606
+ */
607
+ type ToolCallStepOpts<
608
+ TSteps extends string,
609
+ TTools extends readonly ToolLike[],
610
+ > = [TTools[number]] extends [never]
611
+ ? BaseStepFields & {
612
+ action: { toolName: string & {}; transformCode?: string };
613
+ input?: StepInput<TSteps>;
614
+ }
615
+ : {
616
+ [TId in TTools[number]["id"]]: BaseStepFields & {
617
+ action: { toolName: TId; transformCode?: string };
618
+ input?: InputForTool<TTools, TId, TSteps>;
619
+ };
620
+ }[TTools[number]["id"]];
621
+
622
+ type StepOpts<TSteps extends string, TTools extends readonly ToolLike[]> =
623
+ | ToolCallStepOpts<TSteps, TTools>
624
+ | (BaseStepFields & { action: { code: string }; input?: StepInput<TSteps> });
625
+
626
+ type ToolCallForEachOpts<
627
+ TSteps extends string,
628
+ TTools extends readonly ToolLike[],
629
+ > = [TTools[number]] extends [never]
630
+ ? BaseForEachFields & {
631
+ action: { toolName: string & {}; transformCode?: string };
632
+ input?: StepInput<TSteps>;
633
+ concurrency?: number;
634
+ }
635
+ : {
636
+ [TId in TTools[number]["id"]]: BaseForEachFields & {
637
+ action: { toolName: TId; transformCode?: string };
638
+ input?: InputForTool<TTools, TId, TSteps>;
639
+ concurrency?: number;
640
+ };
641
+ }[TTools[number]["id"]];
642
+
643
+ type ForEachItemOpts<
644
+ TSteps extends string,
645
+ TTools extends readonly ToolLike[],
646
+ > =
647
+ | ToolCallForEachOpts<TSteps, TTools>
648
+ | (BaseForEachFields & {
649
+ action: { code: string };
650
+ input?: StepInput<TSteps>;
651
+ concurrency?: number;
652
+ });
653
+
654
+ class WorkflowBuilder<
655
+ TSteps extends string = never,
656
+ TTools extends readonly ToolLike[] = never[],
657
+ > {
658
+ private readonly _steps: Step[] = [];
659
+ private readonly _tools: readonly ToolLike[];
660
+
661
+ constructor(
662
+ private readonly meta: Omit<WorkflowDefinition, "steps">,
663
+ tools: readonly ToolLike[] = [],
664
+ ) {
665
+ this._tools = tools;
666
+ }
667
+
668
+ /**
669
+ * Auto-injects the referenced tool's outputSchema into the step if:
670
+ * 1. The step action is a tool call (has toolName)
671
+ * 2. The matched tool has an outputSchema
672
+ * 3. The step does not already have an explicit outputSchema
673
+ *
674
+ * This ensures that when a tool's outputSchema changes, the workflow
675
+ * fingerprint changes and the sync correctly updates the stored workflow.
676
+ */
677
+ private _withToolSchema(step: Step): Step {
678
+ const action = step.action as { toolName?: string } | undefined;
679
+ if (!action?.toolName) return step;
680
+ const tool = this._tools.find((t) => t.id === action.toolName);
681
+ if (!tool?.outputSchema || step.outputSchema !== undefined) return step;
682
+ return {
683
+ ...step,
684
+ outputSchema: z.toJSONSchema(tool.outputSchema) as Step["outputSchema"],
685
+ };
686
+ }
687
+
688
+ step<TName extends string>(
689
+ name: TName,
690
+ opts: StepOpts<TSteps, TTools>,
691
+ ): WorkflowBuilder<TSteps | TName, TTools> {
692
+ this._steps.push(
693
+ this._withToolSchema({ name, ...(opts as Omit<Step, "name">) }),
694
+ );
695
+ return this as unknown as WorkflowBuilder<TSteps | TName, TTools>;
696
+ }
697
+
698
+ /**
699
+ * Creates a step that iterates over an array resolved from a @ref.
700
+ * Maps to the engine's Step.forEach field.
701
+ *
702
+ * @param name - Unique step name
703
+ * @param ref - @ref to the array to iterate (e.g. "@fetch_users")
704
+ * @param opts - Step definition (action, input, config, outputSchema)
705
+ */
706
+ forEachItem<TName extends string>(
707
+ name: TName,
708
+ ref: KnownRefs<TSteps>,
709
+ opts: ForEachItemOpts<TSteps, TTools>,
710
+ ): WorkflowBuilder<TSteps | TName, TTools> {
711
+ const { concurrency = 1, ...rest } = opts;
712
+ this._steps.push(
713
+ this._withToolSchema({
714
+ name,
715
+ ...(rest as Omit<Step, "name" | "forEach">),
716
+ forEach: { ref, concurrency },
717
+ }),
718
+ );
719
+ return this as unknown as WorkflowBuilder<TSteps | TName, TTools>;
720
+ }
721
+
722
+ /**
723
+ * Spreads an array of pre-built steps into the workflow.
724
+ * Step names from the array are not tracked in the type — use .step() for
725
+ * tracked composition.
726
+ */
727
+ addSteps(steps: Step[]): this {
728
+ this._steps.push(...steps);
729
+ return this;
730
+ }
731
+
732
+ build(): WorkflowDefinition {
733
+ return { ...this.meta, steps: [...this._steps] };
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Fluent builder for workflow definitions.
739
+ *
740
+ * Pass your locally-declared tools as the second argument to get autocomplete
741
+ * for `toolName` throughout the workflow. Step names are also tracked so
742
+ * `@ref` strings autocomplete in `input` after each `.step()` call.
743
+ *
744
+ * @example
745
+ * const GET_USERS = createTool({ id: "GET_USERS", ... });
746
+ * const PROCESS_USER = createTool({ id: "PROCESS_USER", ... });
747
+ *
748
+ * const myWorkflow = createWorkflow(
749
+ * { title: "Fetch and Process" },
750
+ * [GET_USERS, PROCESS_USER],
751
+ * )
752
+ * .step("fetch_users", {
753
+ * action: { toolName: "GET_USERS" }, // ← autocomplete: "GET_USERS" | "PROCESS_USER"
754
+ * })
755
+ * .forEachItem("process_user", "@fetch_users", {
756
+ * // ^ autocomplete: @fetch_users, @input, @item...
757
+ * action: { toolName: "PROCESS_USER" },
758
+ * input: { userId: "@item.id" },
759
+ * })
760
+ * .build();
761
+ */
762
+ export function createWorkflow<TTools extends readonly ToolLike[] = never[]>(
763
+ meta: Omit<WorkflowDefinition, "steps">,
764
+ tools?: TTools,
765
+ ): WorkflowBuilder<never, TTools> {
766
+ return new WorkflowBuilder(meta, tools ?? []) as unknown as WorkflowBuilder<
767
+ never,
768
+ TTools
769
+ >;
770
+ }