@codemation/core-nodes 1.0.1 → 1.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/index.cjs +3002 -65
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1521 -551
  5. package/dist/index.d.ts +1521 -551
  6. package/dist/index.js +2969 -73
  7. package/dist/index.js.map +1 -1
  8. package/package.json +5 -3
  9. package/src/authoring/defineRestNode.types.ts +204 -0
  10. package/src/credentials/ApiKeyCredentialType.ts +60 -0
  11. package/src/credentials/BasicAuthCredentialType.ts +51 -0
  12. package/src/credentials/BearerTokenCredentialType.ts +40 -0
  13. package/src/credentials/OAuth2ClientCredentialsTypeFactory.ts +117 -0
  14. package/src/credentials/OAuth2TokenExchangeFactory.ts +52 -0
  15. package/src/credentials/index.ts +4 -0
  16. package/src/http/HttpBodyBuilder.ts +90 -0
  17. package/src/http/HttpRequestExecutor.ts +150 -0
  18. package/src/http/HttpUrlBuilder.ts +22 -0
  19. package/src/http/httpRequest.types.ts +69 -0
  20. package/src/index.ts +9 -0
  21. package/src/nodes/AIAgentNode.ts +101 -3
  22. package/src/nodes/AgentToolExecutionCoordinator.ts +29 -3
  23. package/src/nodes/AssertionNode.ts +42 -0
  24. package/src/nodes/CronTriggerFactory.ts +45 -0
  25. package/src/nodes/CronTriggerNode.ts +40 -0
  26. package/src/nodes/HttpRequestNodeFactory.ts +99 -23
  27. package/src/nodes/IsTestRunNode.ts +25 -0
  28. package/src/nodes/NodeBackedToolRuntime.ts +40 -4
  29. package/src/nodes/TestTriggerNode.ts +33 -0
  30. package/src/nodes/aiAgentSupport.types.ts +18 -3
  31. package/src/nodes/assertion.ts +42 -0
  32. package/src/nodes/collections/collectionDeleteNode.types.ts +23 -0
  33. package/src/nodes/collections/collectionFindOneNode.types.ts +26 -0
  34. package/src/nodes/collections/collectionGetNode.types.ts +26 -0
  35. package/src/nodes/collections/collectionInsertNode.types.ts +22 -0
  36. package/src/nodes/collections/collectionListNode.types.ts +30 -0
  37. package/src/nodes/collections/collectionUpdateNode.types.ts +23 -0
  38. package/src/nodes/collections/index.ts +6 -0
  39. package/src/nodes/httpRequest.ts +61 -1
  40. package/src/nodes/isTestRun.ts +24 -0
  41. package/src/nodes/testTrigger.ts +72 -0
@@ -0,0 +1,150 @@
1
+ import type { Item } from "@codemation/core";
2
+ import type { HttpRequestResult, HttpRequestSpec } from "./httpRequest.types";
3
+ import type { HttpBodyBuilder } from "./HttpBodyBuilder";
4
+ import type { HttpUrlBuilder } from "./HttpUrlBuilder";
5
+
6
+ /**
7
+ * Executes a single HTTP request described by {@link HttpRequestSpec}.
8
+ *
9
+ * - Credential sessions provide header/query deltas via `applyToRequest`.
10
+ * - Body encoding is delegated to {@link HttpBodyBuilder}.
11
+ * - URL query merging is delegated to {@link HttpUrlBuilder}.
12
+ * - Binary response bodies: when `download.mode` triggers binary attach, the
13
+ * `bodyBinaryName` field is set in the result but the body is NOT read here.
14
+ * Callers that need binary attachment should use `buildRequest` to get the
15
+ * resolved URL + init and make the fetch + binary attach themselves.
16
+ *
17
+ * Collaborators (`fetch`, body builder, url builder) are injected so callers
18
+ * own construction at composition roots and tests can supply deterministic stubs.
19
+ */
20
+ export class HttpRequestExecutor {
21
+ constructor(
22
+ private readonly fetchFn: typeof globalThis.fetch,
23
+ private readonly bodyBuilder: HttpBodyBuilder,
24
+ private readonly urlBuilder: HttpUrlBuilder,
25
+ ) {}
26
+
27
+ /**
28
+ * Builds the fetch init (headers, query, body) from the spec + credential delta,
29
+ * returning both the resolved URL and the RequestInit so callers can make the
30
+ * actual fetch call themselves (useful for streaming / binary attach).
31
+ */
32
+ async buildRequest(spec: HttpRequestSpec, item: Item): Promise<Readonly<{ url: string; init: RequestInit }>> {
33
+ const credentialDelta = spec.credential?.applyToRequest(spec) ?? {};
34
+
35
+ const mergedHeaders: Record<string, string> = {
36
+ ...(spec.headers ?? {}),
37
+ ...(credentialDelta.headers ?? {}),
38
+ };
39
+
40
+ const mergedQuery: Record<string, string | string[]> = {
41
+ ...(spec.query ?? {}),
42
+ ...(credentialDelta.query ?? {}),
43
+ };
44
+
45
+ const encodedBody = await this.bodyBuilder.build(spec.body, item, spec.ctx);
46
+
47
+ // Only set Content-Type from the encoded body when it is non-empty
48
+ // (empty string = FormData will set it automatically).
49
+ if (encodedBody && encodedBody.contentType) {
50
+ mergedHeaders["content-type"] = encodedBody.contentType;
51
+ }
52
+
53
+ const resolvedUrl = this.urlBuilder.build(spec.url, mergedQuery);
54
+
55
+ const init: RequestInit = {
56
+ method: spec.method,
57
+ headers: mergedHeaders,
58
+ ...(encodedBody ? { body: encodedBody.body } : {}),
59
+ };
60
+
61
+ return { url: resolvedUrl, init };
62
+ }
63
+
64
+ /**
65
+ * Executes an HTTP request and returns parsed result.
66
+ * For binary downloads (when `shouldAttachBody` is true), the body is NOT consumed
67
+ * and callers must call `ctx.binary.attach` directly using the resolved URL + init
68
+ * (available via `buildRequest`).
69
+ */
70
+ async execute(spec: HttpRequestSpec, item: Item): Promise<HttpRequestResult> {
71
+ const { url: resolvedUrl, init } = await this.buildRequest(spec, item);
72
+
73
+ const response = await this.fetchFn(resolvedUrl, init);
74
+
75
+ const responseHeaders = this.readHeaders(response.headers);
76
+ const mimeType = this.resolveMimeType(responseHeaders);
77
+
78
+ const downloadMode = spec.download?.mode ?? "auto";
79
+ const binaryName = spec.download?.binaryName ?? "body";
80
+ const shouldDownload = this.shouldAttachBody(downloadMode, mimeType);
81
+
82
+ const isJson = this.isJsonMimeType(mimeType);
83
+
84
+ let json: unknown | undefined;
85
+ let text: string | undefined;
86
+ let bodyBinaryName: string | undefined;
87
+
88
+ if (shouldDownload) {
89
+ // Signal to caller that binary attachment is needed.
90
+ bodyBinaryName = binaryName;
91
+ // Do NOT read the body here — the caller must handle binary attach separately.
92
+ } else if (isJson) {
93
+ try {
94
+ json = await response.json();
95
+ } catch {
96
+ text = await response.text();
97
+ }
98
+ } else {
99
+ text = await response.text();
100
+ }
101
+
102
+ return {
103
+ url: resolvedUrl,
104
+ method: spec.method.toUpperCase(),
105
+ status: response.status,
106
+ ok: response.ok,
107
+ statusText: response.statusText,
108
+ mimeType,
109
+ headers: responseHeaders,
110
+ ...(json !== undefined ? { json } : {}),
111
+ ...(text !== undefined ? { text } : {}),
112
+ ...(bodyBinaryName !== undefined ? { bodyBinaryName } : {}),
113
+ };
114
+ }
115
+
116
+ private readHeaders(headers: Headers): Readonly<Record<string, string>> {
117
+ const values: Record<string, string> = {};
118
+ headers.forEach((value, key) => {
119
+ values[key] = value;
120
+ });
121
+ return values;
122
+ }
123
+
124
+ private resolveMimeType(headers: Readonly<Record<string, string>>): string {
125
+ const contentType = headers["content-type"];
126
+ if (!contentType) {
127
+ return "application/octet-stream";
128
+ }
129
+ return contentType.split(";")[0]?.trim() || "application/octet-stream";
130
+ }
131
+
132
+ private isJsonMimeType(mimeType: string): boolean {
133
+ return mimeType === "application/json" || mimeType.endsWith("+json");
134
+ }
135
+
136
+ private shouldAttachBody(mode: "auto" | "always" | "never", mimeType: string): boolean {
137
+ if (mode === "always") {
138
+ return true;
139
+ }
140
+ if (mode === "never") {
141
+ return false;
142
+ }
143
+ return (
144
+ mimeType.startsWith("image/") ||
145
+ mimeType.startsWith("audio/") ||
146
+ mimeType.startsWith("video/") ||
147
+ mimeType === "application/pdf"
148
+ );
149
+ }
150
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Merges query parameters into a base URL.
3
+ * Handles both scalar and array values, and preserves any existing params.
4
+ */
5
+ export class HttpUrlBuilder {
6
+ build(baseUrl: string, query?: Readonly<Record<string, string | string[]>>): string {
7
+ if (!query || Object.keys(query).length === 0) {
8
+ return baseUrl;
9
+ }
10
+ const parsed = new URL(baseUrl);
11
+ for (const [key, value] of Object.entries(query)) {
12
+ if (Array.isArray(value)) {
13
+ for (const entry of value) {
14
+ parsed.searchParams.append(key, entry);
15
+ }
16
+ } else {
17
+ parsed.searchParams.append(key, value);
18
+ }
19
+ }
20
+ return parsed.toString();
21
+ }
22
+ }
@@ -0,0 +1,69 @@
1
+ import type { NodeExecutionContext } from "@codemation/core";
2
+ import type { RunnableNodeConfig } from "@codemation/core";
3
+
4
+ /**
5
+ * Binary reference key into `item.binary`.
6
+ */
7
+ export type BinaryRef = string;
8
+
9
+ /**
10
+ * Discriminated union for the HTTP request body.
11
+ */
12
+ export type HttpBodySpec =
13
+ | Readonly<{ kind: "none" }>
14
+ | Readonly<{ kind: "json"; data: unknown }>
15
+ | Readonly<{ kind: "form"; data: Readonly<Record<string, string>> }>
16
+ | Readonly<{
17
+ kind: "multipart";
18
+ fields: Readonly<Record<string, string>>;
19
+ binaries?: Readonly<Record<string, BinaryRef>>;
20
+ }>;
21
+
22
+ /**
23
+ * Session interface that credential types implement.
24
+ * Returns header/query deltas so the executor can merge them without
25
+ * mutating the immutable HttpRequestSpec.
26
+ */
27
+ export interface CredentialSession {
28
+ applyToRequest(spec: HttpRequestSpec): HttpCredentialDelta;
29
+ }
30
+
31
+ /**
32
+ * Mutations the credential session wants to apply to the outgoing request.
33
+ */
34
+ export type HttpCredentialDelta = Readonly<{
35
+ headers?: Readonly<Record<string, string>>;
36
+ query?: Readonly<Record<string, string>>;
37
+ }>;
38
+
39
+ /**
40
+ * Full specification of one HTTP request. All URLs are fully resolved before
41
+ * being passed here (template substitution already applied by the caller).
42
+ */
43
+ export type HttpRequestSpec = Readonly<{
44
+ url: string;
45
+ method: string;
46
+ headers?: Readonly<Record<string, string>>;
47
+ query?: Readonly<Record<string, string | string[]>>;
48
+ body?: HttpBodySpec;
49
+ credential?: CredentialSession;
50
+ download?: Readonly<{ mode: "auto" | "always" | "never"; binaryName: string }>;
51
+ /** Execution context — needed for binary attach. */
52
+ ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>;
53
+ }>;
54
+
55
+ /**
56
+ * Result of executing an HTTP request.
57
+ */
58
+ export type HttpRequestResult = Readonly<{
59
+ url: string;
60
+ method: string;
61
+ status: number;
62
+ ok: boolean;
63
+ statusText: string;
64
+ mimeType: string;
65
+ headers: Readonly<Record<string, string>>;
66
+ json?: unknown;
67
+ text?: string;
68
+ bodyBinaryName?: string;
69
+ }>;
package/src/index.ts CHANGED
@@ -1,22 +1,30 @@
1
1
  export * from "./canvasIconName";
2
+ export * from "./credentials/index";
3
+ export * from "./http/httpRequest.types";
4
+ export * from "./authoring/defineRestNode.types";
2
5
  export * from "./chatModels/OpenAIChatModelFactory";
3
6
  export * from "./chatModels/OpenAiStrictJsonSchemaFactory";
4
7
  export * from "./chatModels/OpenAiCredentialSession";
5
8
  export * from "./chatModels/openAiChatModelConfig";
6
9
  export * from "./chatModels/OpenAiChatModelPresetsFactory";
7
10
  export * from "./nodes/aiAgent";
11
+ export * from "./nodes/assertion";
8
12
  export * from "./nodes/CallbackNodeFactory";
9
13
  export * from "./nodes/httpRequest";
10
14
  export * from "./nodes/aggregate";
11
15
  export * from "./nodes/filter";
12
16
  export * from "./nodes/if";
17
+ export * from "./nodes/isTestRun";
13
18
  export * from "./nodes/switch";
14
19
  export * from "./nodes/split";
20
+ export * from "./nodes/CronTriggerFactory";
21
+ export * from "./nodes/CronTriggerNode";
15
22
  export * from "./nodes/ManualTriggerFactory";
16
23
  export * from "./nodes/mapData";
17
24
  export * from "./nodes/merge";
18
25
  export * from "./nodes/noOp";
19
26
  export * from "./nodes/subWorkflow";
27
+ export * from "./nodes/testTrigger";
20
28
  export * from "./nodes/wait";
21
29
  export * from "./nodes/webhookRespondNowAndContinueError";
22
30
  export * from "./nodes/webhookRespondNowError";
@@ -30,3 +38,4 @@ export * from "./nodes/ConnectionCredentialNode";
30
38
  export * from "./nodes/ConnectionCredentialNodeConfig";
31
39
  export * from "./nodes/ConnectionCredentialNodeConfigFactory";
32
40
  export * from "./nodes/ConnectionCredentialExecutionContextFactory";
41
+ export * from "./nodes/collections";
@@ -356,7 +356,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
356
356
  return {
357
357
  config: entry.config,
358
358
  inputSchema: entry.runtime.inputSchema,
359
- execute: async (input: unknown): Promise<unknown> => {
359
+ execute: async (input, hooks): Promise<unknown> => {
360
360
  const validated = entry.runtime.inputSchema.parse(input) as unknown;
361
361
  return await entry.runtime.execute({
362
362
  config: entry.config,
@@ -365,6 +365,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
365
365
  item,
366
366
  itemIndex,
367
367
  items,
368
+ hooks,
368
369
  });
369
370
  },
370
371
  } satisfies ItemScopedToolBinding;
@@ -421,11 +422,36 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
421
422
  activationId: ctx.activationId,
422
423
  inputsByPort: itemInputsByPort,
423
424
  });
425
+ await ctx.nodeState?.appendConnectionInvocation({
426
+ invocationId,
427
+ connectionNodeId: languageModelConnectionNodeId,
428
+ parentAgentNodeId: ctx.nodeId,
429
+ parentAgentActivationId: ctx.activationId,
430
+ status: "queued",
431
+ managedInput: summarizedInput,
432
+ queuedAt: startedAt.toISOString(),
433
+ iterationId: ctx.iterationId,
434
+ itemIndex: ctx.itemIndex,
435
+ parentInvocationId: ctx.parentInvocationId,
436
+ });
424
437
  await ctx.nodeState?.markRunning({
425
438
  nodeId: languageModelConnectionNodeId,
426
439
  activationId: ctx.activationId,
427
440
  inputsByPort: itemInputsByPort,
428
441
  });
442
+ await ctx.nodeState?.appendConnectionInvocation({
443
+ invocationId,
444
+ connectionNodeId: languageModelConnectionNodeId,
445
+ parentAgentNodeId: ctx.nodeId,
446
+ parentAgentActivationId: ctx.activationId,
447
+ status: "running",
448
+ managedInput: summarizedInput,
449
+ queuedAt: startedAt.toISOString(),
450
+ startedAt: startedAt.toISOString(),
451
+ iterationId: ctx.iterationId,
452
+ itemIndex: ctx.itemIndex,
453
+ parentInvocationId: ctx.parentInvocationId,
454
+ });
429
455
  try {
430
456
  const tools = this.buildToolSet(itemScopedTools);
431
457
  const callOptions = this.resolveCallOptions(model, guardrails.modelInvocationOptions);
@@ -441,11 +467,12 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
441
467
  });
442
468
  const turnResult = this.extractTurnResult(result as AnyGenerateTextResult);
443
469
  const finishedAt = new Date();
470
+ const managedOutput = this.summarizeTurnOutput(turnResult);
444
471
  await ctx.nodeState?.markCompleted({
445
472
  nodeId: languageModelConnectionNodeId,
446
473
  activationId: ctx.activationId,
447
474
  inputsByPort: itemInputsByPort,
448
- outputs: AgentOutputFactory.fromUnknown({ content: turnResult.text }),
475
+ outputs: AgentOutputFactory.fromUnknown(managedOutput),
449
476
  });
450
477
  await span.attachArtifact({ kind: "ai.messages", contentType: "application/json", previewJson: summarizedInput });
451
478
  await span.attachArtifact({ kind: "ai.response", contentType: "application/json", previewJson: turnResult.text });
@@ -458,10 +485,13 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
458
485
  parentAgentActivationId: ctx.activationId,
459
486
  status: "completed",
460
487
  managedInput: summarizedInput,
461
- managedOutput: turnResult.text,
488
+ managedOutput,
462
489
  queuedAt: startedAt.toISOString(),
463
490
  startedAt: startedAt.toISOString(),
464
491
  finishedAt: finishedAt.toISOString(),
492
+ iterationId: ctx.iterationId,
493
+ itemIndex: ctx.itemIndex,
494
+ parentInvocationId: ctx.parentInvocationId,
465
495
  });
466
496
  return turnResult;
467
497
  } catch (error) {
@@ -504,11 +534,36 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
504
534
  activationId: ctx.activationId,
505
535
  inputsByPort: itemInputsByPort,
506
536
  });
537
+ await ctx.nodeState?.appendConnectionInvocation({
538
+ invocationId,
539
+ connectionNodeId: languageModelConnectionNodeId,
540
+ parentAgentNodeId: ctx.nodeId,
541
+ parentAgentActivationId: ctx.activationId,
542
+ status: "queued",
543
+ managedInput: summarizedInput,
544
+ queuedAt: startedAt.toISOString(),
545
+ iterationId: ctx.iterationId,
546
+ itemIndex: ctx.itemIndex,
547
+ parentInvocationId: ctx.parentInvocationId,
548
+ });
507
549
  await ctx.nodeState?.markRunning({
508
550
  nodeId: languageModelConnectionNodeId,
509
551
  activationId: ctx.activationId,
510
552
  inputsByPort: itemInputsByPort,
511
553
  });
554
+ await ctx.nodeState?.appendConnectionInvocation({
555
+ invocationId,
556
+ connectionNodeId: languageModelConnectionNodeId,
557
+ parentAgentNodeId: ctx.nodeId,
558
+ parentAgentActivationId: ctx.activationId,
559
+ status: "running",
560
+ managedInput: summarizedInput,
561
+ queuedAt: startedAt.toISOString(),
562
+ startedAt: startedAt.toISOString(),
563
+ iterationId: ctx.iterationId,
564
+ itemIndex: ctx.itemIndex,
565
+ parentInvocationId: ctx.parentInvocationId,
566
+ });
512
567
  try {
513
568
  const callOptions = this.resolveCallOptions(model, guardrails.modelInvocationOptions);
514
569
  const outputSchema =
@@ -551,6 +606,9 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
551
606
  queuedAt: startedAt.toISOString(),
552
607
  startedAt: startedAt.toISOString(),
553
608
  finishedAt: finishedAt.toISOString(),
609
+ iterationId: ctx.iterationId,
610
+ itemIndex: ctx.itemIndex,
611
+ parentInvocationId: ctx.parentInvocationId,
554
612
  });
555
613
  return result.experimental_output;
556
614
  } catch (error) {
@@ -589,6 +647,22 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
589
647
  };
590
648
  }
591
649
 
650
+ /**
651
+ * Build a no-code-friendly output payload for an LLM round.
652
+ *
653
+ * Always includes `content` (matching the canvas snapshot shape used elsewhere) and adds a
654
+ * `toolCalls` array when the round produced tool calls so the execution inspector surfaces the
655
+ * planned calls instead of just an empty `""` for tool-only rounds.
656
+ */
657
+ private summarizeTurnOutput(turnResult: TurnResult): JsonValue {
658
+ if (turnResult.toolCalls.length === 0) return { content: turnResult.text };
659
+ const toolCalls = turnResult.toolCalls.map((toolCall) => ({
660
+ name: toolCall.name,
661
+ args: this.resultToJsonValue(toolCall.input) ?? null,
662
+ }));
663
+ return { content: turnResult.text, toolCalls };
664
+ }
665
+
592
666
  private extractTurnResult(result: AnyGenerateTextResult): TurnResult {
593
667
  const usage = this.extractUsageFromResult(result);
594
668
  const text = result.text;
@@ -642,6 +716,13 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
642
716
  [CodemationTelemetryAttributeNames.connectionInvocationId]: invocationId,
643
717
  [GenAiTelemetryAttributeNames.operationName]: "chat",
644
718
  [GenAiTelemetryAttributeNames.requestModel]: this.resolveChatModelName(ctx.config.chatModel),
719
+ ...(ctx.iterationId ? { [CodemationTelemetryAttributeNames.iterationId]: ctx.iterationId } : {}),
720
+ ...(typeof ctx.itemIndex === "number"
721
+ ? { [CodemationTelemetryAttributeNames.iterationIndex]: ctx.itemIndex }
722
+ : {}),
723
+ ...(ctx.parentInvocationId
724
+ ? { [CodemationTelemetryAttributeNames.parentInvocationId]: ctx.parentInvocationId }
725
+ : {}),
645
726
  },
646
727
  });
647
728
  }
@@ -707,12 +788,25 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
707
788
  plannedToolCalls: ReadonlyArray<PlannedToolCall>,
708
789
  ctx: NodeExecutionContext<AIAgent<any, any>>,
709
790
  ): Promise<void> {
791
+ const queuedAt = new Date().toISOString();
710
792
  for (const plannedToolCall of plannedToolCalls) {
711
793
  await ctx.nodeState?.markQueued({
712
794
  nodeId: plannedToolCall.nodeId,
713
795
  activationId: ctx.activationId,
714
796
  inputsByPort: AgentToolCallPortMap.fromInput(plannedToolCall.toolCall.input ?? {}),
715
797
  });
798
+ await ctx.nodeState?.appendConnectionInvocation({
799
+ invocationId: plannedToolCall.invocationId,
800
+ connectionNodeId: plannedToolCall.nodeId,
801
+ parentAgentNodeId: ctx.nodeId,
802
+ parentAgentActivationId: ctx.activationId,
803
+ status: "queued",
804
+ managedInput: this.resultToJsonValue(plannedToolCall.toolCall.input),
805
+ queuedAt,
806
+ iterationId: ctx.iterationId,
807
+ itemIndex: ctx.itemIndex,
808
+ parentInvocationId: ctx.parentInvocationId,
809
+ });
716
810
  }
717
811
  }
718
812
 
@@ -732,6 +826,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
732
826
  toolCall,
733
827
  invocationIndex,
734
828
  nodeId: ConnectionNodeIdFactory.toolConnectionNodeId(parentNodeId, binding.config.name),
829
+ invocationId: ConnectionInvocationIdFactory.create(),
735
830
  } satisfies PlannedToolCall;
736
831
  });
737
832
  }
@@ -771,6 +866,9 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
771
866
  queuedAt: args.startedAt.toISOString(),
772
867
  startedAt: args.startedAt.toISOString(),
773
868
  finishedAt: finishedAt.toISOString(),
869
+ iterationId: args.ctx.iterationId,
870
+ itemIndex: args.ctx.itemIndex,
871
+ parentInvocationId: args.ctx.parentInvocationId,
774
872
  });
775
873
  return effectiveError;
776
874
  }
@@ -1,5 +1,5 @@
1
1
  import type { JsonValue, NodeExecutionContext } from "@codemation/core";
2
- import { CodemationTelemetryAttributeNames, ConnectionInvocationIdFactory, inject, injectable } from "@codemation/core";
2
+ import { CodemationTelemetryAttributeNames, inject, injectable } from "@codemation/core";
3
3
 
4
4
  import type { AIAgent } from "./AIAgentConfig";
5
5
  import { AgentOutputFactory } from "./AgentOutputFactory";
@@ -53,7 +53,7 @@ export class AgentToolExecutionCoordinator {
53
53
  ): Promise<ExecutedToolCall> {
54
54
  const { plannedToolCall, ctx } = args;
55
55
  const toolCallInputsByPort = AgentToolCallPortMap.fromInput(plannedToolCall.toolCall.input ?? {});
56
- const invocationId = ConnectionInvocationIdFactory.create();
56
+ const invocationId = plannedToolCall.invocationId;
57
57
  const startedAt = new Date();
58
58
  const span = ctx.telemetry.startChildSpan({
59
59
  name: "agent.tool.call",
@@ -62,6 +62,13 @@ export class AgentToolExecutionCoordinator {
62
62
  attributes: {
63
63
  [CodemationTelemetryAttributeNames.connectionInvocationId]: invocationId,
64
64
  [CodemationTelemetryAttributeNames.toolName]: plannedToolCall.binding.config.name,
65
+ ...(ctx.iterationId ? { [CodemationTelemetryAttributeNames.iterationId]: ctx.iterationId } : {}),
66
+ ...(typeof ctx.itemIndex === "number"
67
+ ? { [CodemationTelemetryAttributeNames.iterationIndex]: ctx.itemIndex }
68
+ : {}),
69
+ ...(ctx.parentInvocationId
70
+ ? { [CodemationTelemetryAttributeNames.parentInvocationId]: ctx.parentInvocationId }
71
+ : {}),
65
72
  },
66
73
  });
67
74
  await ctx.nodeState?.markRunning({
@@ -69,9 +76,24 @@ export class AgentToolExecutionCoordinator {
69
76
  activationId: ctx.activationId,
70
77
  inputsByPort: toolCallInputsByPort,
71
78
  });
79
+ await ctx.nodeState?.appendConnectionInvocation({
80
+ invocationId,
81
+ connectionNodeId: plannedToolCall.nodeId,
82
+ parentAgentNodeId: ctx.nodeId,
83
+ parentAgentActivationId: ctx.activationId,
84
+ status: "running",
85
+ managedInput: this.toJsonValue(plannedToolCall.toolCall.input),
86
+ queuedAt: startedAt.toISOString(),
87
+ startedAt: startedAt.toISOString(),
88
+ iterationId: ctx.iterationId,
89
+ parentInvocationId: ctx.parentInvocationId,
90
+ });
72
91
 
73
92
  try {
74
- const result = await plannedToolCall.binding.execute(plannedToolCall.toolCall.input ?? {});
93
+ const result = await plannedToolCall.binding.execute(plannedToolCall.toolCall.input ?? {}, {
94
+ parentSpan: span,
95
+ parentInvocationId: invocationId,
96
+ });
75
97
  const serialized = typeof result === "string" ? result : JSON.stringify(result);
76
98
  const finishedAt = new Date();
77
99
  await ctx.nodeState?.markCompleted({
@@ -102,6 +124,8 @@ export class AgentToolExecutionCoordinator {
102
124
  queuedAt: startedAt.toISOString(),
103
125
  startedAt: startedAt.toISOString(),
104
126
  finishedAt: finishedAt.toISOString(),
127
+ iterationId: ctx.iterationId,
128
+ parentInvocationId: ctx.parentInvocationId,
105
129
  });
106
130
  return {
107
131
  toolName: plannedToolCall.binding.config.name,
@@ -262,6 +286,8 @@ export class AgentToolExecutionCoordinator {
262
286
  queuedAt: args.startedAt.toISOString(),
263
287
  startedAt: args.startedAt.toISOString(),
264
288
  finishedAt: finishedAt.toISOString(),
289
+ iterationId: args.ctx.iterationId,
290
+ parentInvocationId: args.ctx.parentInvocationId,
265
291
  });
266
292
  }
267
293
 
@@ -0,0 +1,42 @@
1
+ import type { AssertionResult, RunnableNode, RunnableNodeExecuteArgs } from "@codemation/core";
2
+ import { node } from "@codemation/core";
3
+
4
+ import { Assertion } from "./assertion";
5
+
6
+ /**
7
+ * Runs the author's `assertions` callback for each input item and emits one workflow `Item` per
8
+ * returned {@link AssertionResult} on `main`. Persistence is handled by a host-side subscriber
9
+ * to `nodeCompleted` events that filters on `config.emitsAssertions === true`; this node does
10
+ * not write to any store on its own.
11
+ *
12
+ * If the author callback throws, we emit a single synthetic AssertionResult with `errored: true`
13
+ * and `score: 0`. Without this catch the whole node would fail and no assertion row would be
14
+ * persisted — making the rollup blind to "the assertion code itself is broken." The synthetic
15
+ * row keeps `failedAssertionsByRunId` consistent and gives the UI something to surface.
16
+ */
17
+ @node({ packageName: "@codemation/core-nodes" })
18
+ export class AssertionNode implements RunnableNode<Assertion<any>> {
19
+ kind = "node" as const;
20
+ outputPorts = ["main"] as const;
21
+
22
+ async execute(args: RunnableNodeExecuteArgs<Assertion<any>>): Promise<unknown> {
23
+ const ctx = args.ctx;
24
+ const config = ctx.config;
25
+ try {
26
+ const results: ReadonlyArray<AssertionResult> = await config.assertions(args.item, ctx);
27
+ // Engine "array → fan-out on main, each element is item.json" — returning the plain results
28
+ // makes downstream `item.json` exactly an AssertionResult. Wrapping in `{ json: result }`
29
+ // would double-wrap (engine would see `Item`-shaped values but treat them as JSON values).
30
+ return [...results];
31
+ } catch (err) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ const erroredResult: AssertionResult = {
34
+ name: config.name ?? "assertion",
35
+ score: 0,
36
+ errored: true,
37
+ message,
38
+ };
39
+ return [erroredResult];
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,45 @@
1
+ import type { TriggerNodeConfig, TypeToken } from "@codemation/core";
2
+
3
+ import { Cron } from "croner";
4
+ import type { CronCallback } from "croner";
5
+
6
+ import { CronTriggerNode } from "./CronTriggerNode";
7
+
8
+ export type CronTickJson = { firedAt: string; scheduledFor: string };
9
+
10
+ /**
11
+ * Schedules a workflow on a standard cron expression.
12
+ *
13
+ * Each tick emits one item: `{ firedAt: string, scheduledFor: string }` — both ISO-8601 timestamps.
14
+ * `firedAt` is the wall-clock moment the callback ran; `scheduledFor` is the cron-computed
15
+ * firing instant (these differ when the job was delayed).
16
+ *
17
+ * Timezone defaults to UTC when omitted — cron without an explicit TZ is a DST footgun.
18
+ */
19
+ export class CronTrigger implements TriggerNodeConfig<CronTickJson> {
20
+ readonly kind = "trigger" as const;
21
+ readonly type: TypeToken<unknown> = CronTriggerNode;
22
+ readonly icon = "lucide:clock" as const;
23
+ readonly id?: string;
24
+
25
+ constructor(
26
+ public readonly name: string,
27
+ private readonly args: Readonly<{ schedule: string; timezone?: string }>,
28
+ id?: string,
29
+ ) {
30
+ new Cron(args.schedule, { paused: true, timezone: args.timezone });
31
+ this.id = id;
32
+ }
33
+
34
+ get schedule(): string {
35
+ return this.args.schedule;
36
+ }
37
+
38
+ get timezone(): string | undefined {
39
+ return this.args.timezone;
40
+ }
41
+
42
+ createJob(callback: CronCallback): Cron {
43
+ return new Cron(this.args.schedule, { timezone: this.args.timezone, protect: true }, callback);
44
+ }
45
+ }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ Items,
3
+ NodeExecutionContext,
4
+ NodeOutputs,
5
+ TestableTriggerNode,
6
+ TriggerSetupContext,
7
+ TriggerTestItemsContext,
8
+ } from "@codemation/core";
9
+
10
+ import { node } from "@codemation/core";
11
+
12
+ import { CronTrigger } from "./CronTriggerFactory";
13
+
14
+ @node({ packageName: "@codemation/core-nodes" })
15
+ export class CronTriggerNode implements TestableTriggerNode<CronTrigger> {
16
+ readonly kind = "trigger" as const;
17
+ readonly outputPorts = ["main"] as const;
18
+
19
+ async setup(ctx: TriggerSetupContext<CronTrigger>): Promise<undefined> {
20
+ const job = ctx.config.createJob(async (self) => {
21
+ const scheduledFor = self.currentRun()?.toISOString() ?? ctx.now().toISOString();
22
+ await ctx.emit([{ json: { firedAt: ctx.now().toISOString(), scheduledFor } }]);
23
+ });
24
+ ctx.registerCleanup({
25
+ stop: () => {
26
+ job.stop();
27
+ },
28
+ });
29
+ return undefined;
30
+ }
31
+
32
+ async execute(items: Items, _ctx: NodeExecutionContext<CronTrigger>): Promise<NodeOutputs> {
33
+ return { main: items };
34
+ }
35
+
36
+ async getTestItems(ctx: TriggerTestItemsContext<CronTrigger>): Promise<Items> {
37
+ const nowIso = ctx.now().toISOString();
38
+ return [{ json: { firedAt: nowIso, scheduledFor: nowIso } }];
39
+ }
40
+ }