@codemation/core-nodes 0.8.1 → 0.10.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.
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { defineHumanApprovalNode } from "@codemation/core";
3
+ import type { Item, JsonValue } from "@codemation/core";
4
+ import { InboxChannelResolverToken } from "@codemation/core";
5
+
6
+ /**
7
+ * A subject field (title / body) for an inbox approval. Either a static string
8
+ * or a contextual callback that builds the string from the item using ordinary
9
+ * JavaScript template literals — e.g. `({ item }) => `Approve ${item.json.vendor}``.
10
+ * Code-first: no template DSL, just functions.
11
+ */
12
+ type InboxSubjectField = string | ((args: { item: Item }) => string);
13
+
14
+ function resolveSubjectField(field: InboxSubjectField, item: Item): string {
15
+ return typeof field === "function" ? field({ item }) : field;
16
+ }
17
+
18
+ /**
19
+ * Auto-detecting inbox approval node.
20
+ *
21
+ * Uses `ctx.resolve(InboxChannelResolverToken)` to pick the right inbox channel
22
+ * at runtime:
23
+ * - In managed mode (PairingConfig present): routes to the control-plane inbox.
24
+ * - Otherwise: routes to the local inbox.
25
+ *
26
+ * Authors use this node directly; no extra wiring needed per deployment mode.
27
+ */
28
+ export const inboxApproval = defineHumanApprovalNode({
29
+ key: "inbox.approval",
30
+ title: "Inbox Approval",
31
+ description: "Suspend and wait for a human reviewer to approve or reject.",
32
+ icon: "lucide:inbox",
33
+ channel: "inbox",
34
+
35
+ configSchema: z.object({
36
+ title: z.custom<InboxSubjectField>((v) => typeof v === "string" || typeof v === "function"),
37
+ body: z.custom<InboxSubjectField>((v) => typeof v === "string" || typeof v === "function"),
38
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
39
+ timeout: z.string().default("24h"),
40
+ onTimeout: z.enum(["halt", "auto-accept"]).default("halt"),
41
+ }),
42
+ decisionSchema: z.object({
43
+ approved: z.boolean(),
44
+ note: z.string().optional(),
45
+ }),
46
+ defaultTimeout: "24h",
47
+ defaultOnTimeout: "halt",
48
+
49
+ async deliver({ task, config, item }, ctx) {
50
+ const resolver = ctx.resolve(InboxChannelResolverToken);
51
+ if (!resolver) {
52
+ throw new Error("inboxApproval: no InboxChannelResolver registered. Ensure the host DI container is wired.");
53
+ }
54
+ const { channel, workspaceId } = resolver.resolve();
55
+ const subject = {
56
+ title: resolveSubjectField(config.title, item),
57
+ summary: resolveSubjectField(config.body, item),
58
+ attributes: { workflowId: ctx.workflowId, item: item.json as JsonValue },
59
+ };
60
+ const delivery = await channel.deliver({
61
+ task,
62
+ subject,
63
+ priority: config.priority,
64
+ item,
65
+ workspaceId,
66
+ });
67
+ ctx.telemetry.addSpanEvent({
68
+ name: "hitl.task.delivered",
69
+ attributes: { taskId: task.taskId, channel: channel.kind },
70
+ });
71
+ return delivery;
72
+ },
73
+
74
+ async onDecision({ decision, actor, delivery }, ctx) {
75
+ const resolver = ctx.resolve(InboxChannelResolverToken);
76
+ if (!resolver) return;
77
+ const { channel } = resolver.resolve();
78
+ await channel.updateOnDecision?.({ delivery, decision, actor });
79
+ },
80
+
81
+ async onTimeout({ delivery, policy }, ctx) {
82
+ const resolver = ctx.resolve(InboxChannelResolverToken);
83
+ if (!resolver) return;
84
+ const { channel } = resolver.resolve();
85
+ await channel.updateOnTimeout?.({ delivery, policy });
86
+ },
87
+ });
@@ -32,11 +32,16 @@ export type ResolvedTool = Readonly<{
32
32
  * span and the planned tool-call's `invocationId`. Node-backed sub-agent tools use these hooks
33
33
  * via {@link ChildExecutionScopeFactory} to re-root their runtime ctx under the tool-call boundary
34
34
  * (fresh activationId, telemetry parented at the tool-call span, `parentInvocationId` set).
35
+ *
36
+ * `humanApproval` is present only when the tool was created via `defineHumanApprovalNode`
37
+ * (via its marker) — detected during `resolveTools` in `AIAgentNode`.
35
38
  */
36
39
  export type ItemScopedToolBinding = Readonly<{
37
40
  config: ToolConfig;
38
41
  inputSchema: ZodSchemaAny;
39
42
  execute(input: unknown, hooks?: ItemScopedToolCallHooks): Promise<unknown>;
43
+ /** Present when this binding is backed by a HITL-approval node (story 10). */
44
+ humanApproval?: Readonly<{ onRejected: "halt" | "return" }>;
40
45
  }>;
41
46
 
42
47
  export type ItemScopedToolCallHooks = Readonly<{
@@ -0,0 +1,131 @@
1
+ import { defineNode } from "@codemation/core";
2
+ import { z } from "zod";
3
+ import { managedHmacFetchFactory } from "../chatModels/ManagedHmacSignerFactory.types";
4
+
5
+ const ANALYZER_TYPES = ["document", "invoice", "image", "auto"] as const;
6
+ export type DocScannerAnalyzerType = (typeof ANALYZER_TYPES)[number];
7
+
8
+ /** Per-field value/confidence shape as returned by apps/doc-scanner. */
9
+ export type DocScannerField = Readonly<{
10
+ value: unknown;
11
+ confidence: number | null;
12
+ }>;
13
+
14
+ /** Output shape of CodemationDocumentScanner — identical to the service wire response. */
15
+ export type DocScannerOutput = Readonly<{
16
+ markdown: string;
17
+ fields: Readonly<Record<string, DocScannerField>>;
18
+ }>;
19
+
20
+ export type CodemationDocumentScannerConfig = Readonly<{
21
+ /** Key on `item.binary` that holds the document. Default: "data". */
22
+ binaryField?: string;
23
+ /** Analyzer type. Default: "auto" (routes on mime type on the service side). */
24
+ analyzerType?: DocScannerAnalyzerType;
25
+ /** MIME type override. Falls back to attachment.mimeType. */
26
+ contentType?: string;
27
+ /** Include per-field confidence scores (0–1). Default: false.
28
+ * Enabling this roughly doubles contextualization tokens for document analyzers.
29
+ * Image and auto-to-image requests silently ignore this flag (confidence stays null). */
30
+ includeConfidence?: boolean;
31
+ /** Max bytes checked before any read. Default: 50 MiB (same cap as OCR nodes, LD10). */
32
+ maxBytes?: number;
33
+ }>;
34
+
35
+ export const codemationDocumentScannerNode = defineNode({
36
+ key: "codemation.document-scanner",
37
+ title: "Codemation Document Scanner",
38
+ description:
39
+ "Analyzes a binary attachment (document or image) via the managed Codemation document-scanning service " +
40
+ "and returns markdown text plus structured fields. No Azure credential required — auth uses the workspace " +
41
+ "pairing secret. Enable includeConfidence to get per-field confidence scores (0–1).",
42
+ icon: "lucide:scan-text",
43
+ input: {
44
+ binaryField: "data",
45
+ analyzerType: "auto" as DocScannerAnalyzerType,
46
+ contentType: undefined as string | undefined,
47
+ includeConfidence: false,
48
+ maxBytes: undefined as number | undefined,
49
+ },
50
+ configSchema: z.object({
51
+ binaryField: z.string().optional(),
52
+ analyzerType: z.enum(ANALYZER_TYPES).optional(),
53
+ contentType: z.string().optional(),
54
+ includeConfidence: z.boolean().optional(),
55
+ maxBytes: z.number().int().positive().optional(),
56
+ }),
57
+ // keepBinaries is omitted (false) — the output replaces the item payload.
58
+ // To carry the source binary forward, set keepBinaries: true at the call site
59
+ // or use a downstream step that reads item.binary.
60
+ inspectorSummary({ config }) {
61
+ const cfg = config as unknown as CodemationDocumentScannerConfig;
62
+ const rows: Array<{ label: string; value: string }> = [
63
+ { label: "Analyzer type", value: cfg.analyzerType ?? "auto" },
64
+ ];
65
+ const binaryField = cfg.binaryField ?? "data";
66
+ if (binaryField !== "data") rows.push({ label: "Binary field", value: binaryField });
67
+ if (cfg.includeConfidence) rows.push({ label: "Confidence", value: "enabled" });
68
+ if (cfg.contentType) rows.push({ label: "Content type", value: cfg.contentType });
69
+ return rows;
70
+ },
71
+ async execute({ item, ctx }, { config: rawConfig }) {
72
+ const config = rawConfig as unknown as CodemationDocumentScannerConfig;
73
+
74
+ // eslint-disable-next-line no-restricted-properties -- DOC_SCANNER_GATEWAY_URL is injected by the control-plane provisioner into the workspace process env; reading it at execute time is the justified boundary.
75
+ const gatewayUrl = process.env["DOC_SCANNER_GATEWAY_URL"];
76
+ if (!gatewayUrl) {
77
+ throw new Error(
78
+ "Codemation Document Scanner not available in this environment (DOC_SCANNER_GATEWAY_URL is not set).",
79
+ );
80
+ }
81
+
82
+ // Workspace pairing identity — injected by the CP provisioner (mirrors
83
+ // CodemationChatModelFactory). Passed to the signer; the HMAC binds THIS
84
+ // workspace (LD4) and does not sign the file body (LD11).
85
+ // eslint-disable-next-line no-restricted-properties -- WORKSPACE_ID is injected by the provisioner; read at execute time.
86
+ const workspaceId = process.env["WORKSPACE_ID"];
87
+ // eslint-disable-next-line no-restricted-properties -- WORKSPACE_PAIRING_SECRET is injected by the provisioner; read at execute time.
88
+ const pairingSecret = process.env["WORKSPACE_PAIRING_SECRET"];
89
+ if (!workspaceId || !pairingSecret) {
90
+ throw new Error("Codemation Document Scanner not available (workspace pairing is not configured).");
91
+ }
92
+
93
+ const binaryField = config.binaryField ?? "data";
94
+ const attachment = item.binary?.[binaryField];
95
+ if (!attachment) {
96
+ throw new Error(`Codemation Document Scanner: no binary attachment at key "${binaryField}".`);
97
+ }
98
+
99
+ const body = await ctx.binary.getBytes(attachment, config.maxBytes);
100
+ const contentType = config.contentType ?? attachment.mimeType ?? "application/octet-stream";
101
+ const analyzerType = config.analyzerType ?? "auto";
102
+ const includeConfidence = config.includeConfidence ?? false;
103
+
104
+ const confidenceSuffix = includeConfidence ? "&confidence=true" : "";
105
+ const url = `${gatewayUrl}/analyze?type=${encodeURIComponent(analyzerType)}${confidenceSuffix}`;
106
+
107
+ // signBody: false (LD11): the HMAC binds the workspace, not the file body.
108
+ // The signer forwards the bytes untouched and signs an empty-body hash, so
109
+ // we never re-read or normalise the binary payload.
110
+ const hmacFetch = managedHmacFetchFactory(workspaceId, pairingSecret, { signBody: false });
111
+
112
+ const response = await hmacFetch(url, {
113
+ method: "POST",
114
+ body: body.buffer as ArrayBuffer,
115
+ headers: {
116
+ "Content-Type": contentType,
117
+ "Content-Length": String(body.byteLength),
118
+ "X-Codemation-Caller": "workflow-node",
119
+ },
120
+ });
121
+
122
+ if (!response.ok) {
123
+ const text = await response.text().catch(() => "(unreadable)");
124
+ throw new Error(
125
+ `Codemation Document Scanner: service responded ${response.status} ${response.statusText} — ${text}`,
126
+ );
127
+ }
128
+
129
+ return (await response.json()) as DocScannerOutput;
130
+ },
131
+ });