@unoverse-platform/studio 0.1.89 → 0.1.91

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 (132) hide show
  1. package/local/plugin.ts +20 -12
  2. package/package.json +13 -3
  3. package/screens/TemplatesLibraryView.tsx +31 -6
  4. package/screens/TemplatesView.tsx +1 -1
  5. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  6. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  7. package/vendor/base/src/agent-mcp/index.ts +44 -0
  8. package/vendor/base/src/agent-mcp/invoke.ts +140 -0
  9. package/vendor/base/src/agent-mcp/lean.ts +111 -0
  10. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  11. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  12. package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
  13. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  14. package/vendor/base/src/agent-mcp/types.ts +130 -0
  15. package/vendor/base/src/auth/auth.ts +278 -0
  16. package/vendor/base/src/auth/authConfig.ts +73 -0
  17. package/vendor/base/src/boot.ts +245 -0
  18. package/vendor/base/src/definitions/definitions.ts +18 -0
  19. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  20. package/vendor/base/src/definitions/fsCache.ts +128 -0
  21. package/vendor/base/src/definitions/index.ts +19 -0
  22. package/vendor/base/src/definitions/inputs.ts +30 -0
  23. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  24. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  25. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  26. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  27. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  28. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  29. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  30. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  31. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  32. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  33. package/vendor/base/src/definitions/load/load.ts +487 -0
  34. package/vendor/base/src/definitions/model.ts +238 -0
  35. package/vendor/base/src/definitions/registry/catalog.ts +643 -0
  36. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  37. package/vendor/base/src/definitions/theme.ts +383 -0
  38. package/vendor/base/src/executor.ts +284 -0
  39. package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
  40. package/vendor/base/src/items/baseVersion.ts +96 -0
  41. package/vendor/base/src/items/collect.ts +199 -0
  42. package/vendor/base/src/items/fingerprint.ts +34 -0
  43. package/vendor/base/src/items/publish.ts +198 -0
  44. package/vendor/base/src/lint/context.mjs +56 -0
  45. package/vendor/base/src/lint/design/defs.mjs +46 -0
  46. package/vendor/base/src/lint/design/file.mjs +737 -0
  47. package/vendor/base/src/lint/design/index.d.mts +30 -0
  48. package/vendor/base/src/lint/design/index.mjs +996 -0
  49. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  50. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  51. package/vendor/base/src/lint/design/walk.mjs +444 -0
  52. package/vendor/base/src/lint/index.mjs +28 -0
  53. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  54. package/vendor/base/src/lint/nodes/index.mjs +64 -0
  55. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  56. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  57. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  58. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  59. package/vendor/base/src/logger.ts +33 -0
  60. package/vendor/base/src/manifests/compose.ts +539 -0
  61. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  62. package/vendor/base/src/manifests/executor/context.ts +107 -0
  63. package/vendor/base/src/manifests/executor/index.ts +47 -0
  64. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  65. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  66. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  67. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  68. package/vendor/base/src/manifests/executor/send.ts +65 -0
  69. package/vendor/base/src/manifests/executor/session.ts +46 -0
  70. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  71. package/vendor/base/src/manifests/index.ts +280 -0
  72. package/vendor/base/src/manifests/integrity.ts +93 -0
  73. package/vendor/base/src/manifests/rows.ts +33 -0
  74. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  75. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  76. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  77. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  78. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  79. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  80. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  81. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  82. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  83. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  84. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  85. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  86. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  87. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  88. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  89. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  90. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  91. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  92. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  93. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  94. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  95. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  96. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  97. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  98. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  99. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  100. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  101. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  102. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  103. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  104. package/vendor/base/src/manifests/source.ts +187 -0
  105. package/vendor/base/src/manifests/watch.ts +67 -0
  106. package/vendor/base/src/markdown/document.ts +141 -0
  107. package/vendor/base/src/paths.ts +180 -0
  108. package/vendor/base/src/platform/audioManager.ts +100 -0
  109. package/vendor/base/src/platform/clientTransport.ts +65 -0
  110. package/vendor/base/src/platform/executionContext.ts +154 -0
  111. package/vendor/base/src/platform/index.ts +10 -0
  112. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  113. package/vendor/base/src/platform/redis.ts +54 -0
  114. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  115. package/vendor/base/src/pluginBase.ts +248 -0
  116. package/vendor/base/src/plugins/README.md +57 -0
  117. package/vendor/base/src/plugins/discovery.ts +87 -0
  118. package/vendor/base/src/plugins/index.ts +16 -0
  119. package/vendor/base/src/plugins/loader.ts +179 -0
  120. package/vendor/base/src/plugins/redis.ts +36 -0
  121. package/vendor/base/src/plugins/state.ts +53 -0
  122. package/vendor/base/src/registry.ts +95 -0
  123. package/vendor/base/src/security/permissions.ts +93 -0
  124. package/vendor/base/src/security/rules.ts +73 -0
  125. package/vendor/base/src/template/SafeExpression.ts +556 -0
  126. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  127. package/vendor/base/src/template/index.ts +22 -0
  128. package/vendor/base/src/types.ts +50 -0
  129. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  130. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  131. package/vendor/base/src/workflow/routing.ts +122 -0
  132. package/vendor/sdk/lib/core/store.ts +26 -6
@@ -0,0 +1,637 @@
1
+ /**
2
+ * Building and sending the request. One fetch, one allowedHosts check, both channels.
3
+ *
4
+ * Part of the manifest runtime (DECLARATIVE_NODES.md §2): the manifest DESCRIBES the
5
+ * service, this half COMPUTES it. Split by concern so each piece stays readable.
6
+ */
7
+ import type { ComposedNode } from "../../compose.js";
8
+ import type { RunContext } from "../context.js";
9
+ import { render, evaluate, primeTemplating } from "../templating.js";
10
+ import { assertAllowedHost } from "./allowedHosts.js";
11
+ import { readSettled, assertOk } from "./response.js";
12
+ import { performState, type StateStore } from "../state.js";
13
+ import { mintClientCredentials, invalidateToken } from "../auth/oauth.js";
14
+ import { signAwsRequest, presignAwsUrl, encodeDynamoJson, decodeDynamoJson, type AwsSigning } from "../auth/aws.js";
15
+ import { fetchPaginated } from "../loops/paginate.js";
16
+ import { fetchPolled } from "../loops/poll.js";
17
+ import { performLoop } from "../loops/loop.js";
18
+ import { performDoc } from "../docstore/index.js";
19
+ import { sendChunked } from "../loops/chunk.js";
20
+
21
+ /**
22
+ * Build the HTTP request from a call. Auth schemes are executor capabilities.
23
+ *
24
+ * ONE RESOLUTION RULE, EVERYWHERE IN A CALL. Every string here goes through `resolveValue`:
25
+ * a Handlebars template unless it starts with `return `, in which case it is a sandboxed
26
+ * expression. That rule used to apply to the BODY only, and the gap was arbitrary rather
27
+ * than designed.
28
+ *
29
+ * It cost a real bug. A URL is often assembled from a previous call's reply, and the
30
+ * vendor's shape is not always uniform: HubSpot's v3 associations return the related id as
31
+ * `toObjectId` on some object pairs and `id` on others, which the retired TypeScript
32
+ * handled with `toObjectId ?? id`. Handlebars has no `??`, so the manifest could not say
33
+ * it, and the only way out was picking an API version whose shape happened to be
34
+ * predictable. That is a node contorting itself around a limitation of the executor, which
35
+ * is exactly backwards.
36
+ *
37
+ * The allowedHosts consequence, stated because it is a security control: the LINTER checks hosts
38
+ * statically by blanking `{{ }}` and parsing what remains, and it cannot do that with an
39
+ * expression. Runtime enforcement is unaffected, since `sendRequest` calls
40
+ * `assertAllowedHost` on the RESOLVED url no matter how it was produced, and that is the
41
+ * single chokepoint every call passes through. Lint reports which nodes it could not check
42
+ * statically rather than staying quiet about it.
43
+ */
44
+ export async function buildRequest(
45
+ api: any,
46
+ ctx: RunContext,
47
+ forceReauth = false,
48
+ ): Promise<{ url: string; init: RequestInit; rawBody: unknown }> {
49
+ const req = api.request ?? {};
50
+
51
+ // AUTH FIRST, because a minted token can carry WHERE to talk as well as how. Salesforce
52
+ // returns instance_url and the org's data lives there rather than at the login host, so
53
+ // the url has to be resolved against what auth produced, not before it.
54
+ const authHeaders: Record<string, string> = {};
55
+ const authQuery: Array<[string, string]> = [];
56
+ let scoped = ctx;
57
+ // DEFERRED, not resolved. awsSigV4 cannot produce a header here: a signature covers the
58
+ // method, path, query, headers and a hash of the BODY, and none of those are settled
59
+ // until the request is fully assembled. So this carries what signing will need, and
60
+ // `sendRequest` signs once everything is final.
61
+ let sigv4: AwsSigning | undefined;
62
+
63
+ const pre = (v: unknown) => resolveValue(v, ctx);
64
+
65
+ /**
66
+ * An auth value that resolved to NOTHING is a missing credential, not a value to send.
67
+ *
68
+ * Without this the header went out as the literal string "undefined" and the vendor
69
+ * answered "invalid API key", which is indistinguishable from a key that is simply wrong.
70
+ * Whoever sees that error then goes looking at the vendor's dashboard for a problem that
71
+ * is actually an unset field one screen away.
72
+ */
73
+ const required = async (v: unknown, what: string): Promise<string> => {
74
+ const resolved = await pre(v);
75
+ const out = resolved === undefined || resolved === null ? "" : String(resolved);
76
+ if (!out.trim())
77
+ throw new Error(
78
+ `${auth.scheme} auth needs ${what}, which resolved to nothing. ` +
79
+ `Check the credential is attached to this node and its field is filled in.`,
80
+ );
81
+ return out;
82
+ };
83
+ // `credential` — the OUTBOUND one, how this node proves itself to the vendor. Renamed from
84
+ // `auth` on 2026-07-28: node.yaml's inbound `auth` (who may RUN this node) had the same
85
+ // name one file away, and the two were routinely mistaken for each other.
86
+ const auth = req.credential ?? { scheme: "none" };
87
+ switch (auth.scheme) {
88
+ case "none":
89
+ break;
90
+ case "bearer":
91
+ authHeaders.Authorization = `Bearer ${await required(auth.token, "a token")}`;
92
+ break;
93
+ case "basic":
94
+ authHeaders.Authorization = `Basic ${Buffer.from(
95
+ `${await required(auth.username, "a username")}:${await required(auth.password, "a password")}`,
96
+ ).toString("base64")}`;
97
+ break;
98
+ case "apiKeyHeader":
99
+ authHeaders[auth.header] = await required(auth.value ?? auth.token, `a value for the ${auth.header} header`);
100
+ break;
101
+ case "apiKeyQuery":
102
+ authQuery.push([auth.param, await required(auth.value ?? auth.token, `a value for the ${auth.param} parameter`)]);
103
+ break;
104
+ case "oauth2ClientCredentials": {
105
+ const minted = await mintClientCredentials(
106
+ await required(auth.tokenUrl, "a token url"),
107
+ await required(auth.clientId, "a client id"),
108
+ await required(auth.clientSecret, "a client secret"),
109
+ auth.scope ? String(await pre(auth.scope)) : undefined,
110
+ forceReauth,
111
+ );
112
+ authHeaders.Authorization = `Bearer ${minted.accessToken}`;
113
+ // In scope for everything resolved below, which is how a url reaches instance_url.
114
+ scoped = { ...ctx, token: { instanceUrl: minted.instanceUrl } } as RunContext;
115
+ break;
116
+ }
117
+ case "awsSigV4": {
118
+ // The manifest names the SERVICE and the REGION, because those decide what the
119
+ // signature is scoped to and they are properties of the endpoint being described.
120
+ // The keys come from the credential, never from the manifest.
121
+ const creds = (ctx.credentials?.awsCredential ?? {}) as Record<string, string>;
122
+ sigv4 = {
123
+ region: String(await required(auth.region ?? creds.region, "a region")),
124
+ service: String(auth.service ?? ""),
125
+ accessKeyId: await required(creds.accessKeyId, "an access key id"),
126
+ secretAccessKey: await required(creds.secretAccessKey, "a secret access key"),
127
+ // Only for temporary credentials. Sent AND signed when present, absent otherwise:
128
+ // an empty token header is not the same as no token header, and AWS rejects it.
129
+ sessionToken: creds.sessionToken || undefined,
130
+ };
131
+ if (!sigv4.service)
132
+ throw new Error(
133
+ `awsSigV4 auth needs a service (e.g. dynamodb, s3, bedrock), which the manifest did not name. ` +
134
+ `It is what the signature is scoped to, so it cannot be guessed from the URL.`,
135
+ );
136
+ break;
137
+ }
138
+ default:
139
+ // Reachable only if api.schema.json gained a value this file did not implement.
140
+ throw new Error(`auth scheme "${auth.scheme}" is declared in the schema but not implemented in the executor`);
141
+ }
142
+
143
+ const headers: Record<string, string> = {
144
+ "Content-Type": "application/json",
145
+ ...((await resolveValue(req.headers ?? {}, scoped)) as Record<string, string>),
146
+ ...authHeaders,
147
+ };
148
+ const query = new URLSearchParams();
149
+ for (const [k, v] of Object.entries((await resolveValue(req.query ?? {}, scoped)) as Record<string, unknown>))
150
+ query.set(k, String(v));
151
+ for (const [k, v] of authQuery) query.set(k, v);
152
+
153
+ let url = String(await resolveValue(req.url, scoped));
154
+
155
+ /**
156
+ * AN UNSET `scope.platformUrl` NAMES ITSELF.
157
+ *
158
+ * A node that calls the platform builds its url from `scope.platformUrl`, which is supplied from
159
+ * UNOVERSE_SERVICE_URL with no fallback (executor.ts). Unset, the expression yields undefined and
160
+ * string concatenation produces "undefined/content/ingest" — a URL that fails at DNS, reporting a
161
+ * host nobody configured rather than a variable nobody set.
162
+ *
163
+ * Same trap as a missing job id in poll.ts, and the same fix: the string form of nothing looks like
164
+ * a value, so it is caught by name here rather than left to the network.
165
+ */
166
+ if (/^undefined\b|^null\b/.test(url.trim()))
167
+ throw new Error(
168
+ `the request url resolved to "${url}". If it is built from scope.platformUrl, set ` +
169
+ `UNOVERSE_SERVICE_URL — it has no default, because a hardcoded localhost fallback fails silently ` +
170
+ `in production.`,
171
+ );
172
+ if ([...query.keys()].length) url += (url.includes("?") ? "&" : "?") + query.toString();
173
+
174
+ const method = req.method ?? "GET";
175
+ return { url, init: { method, headers, body: undefined }, rawBody: req.body, scoped, sigv4 } as any;
176
+ }
177
+
178
+ /**
179
+ * Resolve the request body, at ANY depth.
180
+ *
181
+ * Two kinds of value, and the distinction is the same one a developer already knows from
182
+ * config template fields: a string containing `{{ }}` is a Handlebars template, and a
183
+ * string starting with `return ` is a sandboxed expression. Expressions are the only way
184
+ * to express a value whose SHAPE depends on the run: a conditionally-present array
185
+ * member, a key whose name varies by model, or a prompt that differs by which moment the
186
+ * narrator is describing.
187
+ *
188
+ * THIS USED TO CHECK THE TOP LEVEL ONLY, and the failure was silent in the worst way.
189
+ * A nested `return ...` matched neither branch: `render()` ignores any string without
190
+ * `{{`, so the expression's SOURCE TEXT was sent to the vendor as the value. The
191
+ * narrator received its own code as the customer's message on every single run, wrote a
192
+ * generic line from its instructions alone, and nothing anywhere errored. Recursing is
193
+ * what makes "a body value may be an expression" mean what it says.
194
+ */
195
+ export async function resolveBody(body: any, ctx: RunContext): Promise<unknown> {
196
+ return resolveValue(body ?? {}, ctx);
197
+ }
198
+
199
+ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
200
+ if (typeof value === "string") {
201
+ return value.trimStart().startsWith("return ")
202
+ ? evaluate(value, ctx as unknown as Record<string, unknown>)
203
+ : render(value, ctx);
204
+ }
205
+ if (Array.isArray(value)) return Promise.all(value.map((v) => resolveValue(v, ctx)));
206
+ if (value && typeof value === "object") {
207
+ const out: Record<string, unknown> = {};
208
+ for (const [k, v] of Object.entries(value)) {
209
+ const r = await resolveValue(v, ctx);
210
+ // Same rule render() applies: a key that resolved to nothing is OMITTED rather
211
+ // than sent as an empty string, because vendors reject "" where they accept absence.
212
+ if (r !== undefined && r !== "") out[k] = r;
213
+ }
214
+ return out;
215
+ }
216
+ return value;
217
+ }
218
+
219
+ /**
220
+ * `fetch`, bounded by the call's `timeoutMs`.
221
+ *
222
+ * THIS WAS DECLARED AND NOT IMPLEMENTED. `timeoutMs` has been in api.schema.json and in seven
223
+ * manifests since the first of them was written, and the runtime never read it — so every call had
224
+ * no client-side limit at all. It surfaced as an OpenAI node sitting for 154 SECONDS against a
225
+ * declared `timeoutMs: 120000` before the vendor's edge gave up and returned a Cloudflare 520. The
226
+ * node looked hung, the number in the manifest was fiction, and nothing anywhere said so.
227
+ *
228
+ * TIME TO RESPONSE, not time to completion, which is why this clears the timer the moment `fetch`
229
+ * resolves rather than passing a bare `AbortSignal.timeout`. An abort signal stays live while the
230
+ * body is read, so the simple version would cut a long SSE stream off at the timeout — killing a
231
+ * working voice or agent turn on a limit that was only ever meant to catch a host that never
232
+ * answers. Headers arriving is the signal that the host is alive; after that the transport decides
233
+ * how long the body may take.
234
+ *
235
+ * A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
236
+ * so the manifest's own number appears in the log that reports it.
237
+ */
238
+ async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
239
+ const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
240
+ if (!ms) return fetch(url, init);
241
+
242
+ // Composed rather than replaced: a caller's own signal (a cancelled run) must still abort.
243
+ const controller = new AbortController();
244
+ const outer: AbortSignal | undefined = init.signal;
245
+ const onAbort = () => controller.abort();
246
+ outer?.addEventListener("abort", onAbort, { once: true });
247
+ const timer = setTimeout(() => controller.abort(), ms);
248
+
249
+ try {
250
+ return await fetch(url, { ...init, signal: controller.signal });
251
+ } catch (err: any) {
252
+ if (controller.signal.aborted && !outer?.aborted)
253
+ throw new Error(`${label}: no response after ${ms}ms (timeoutMs)`);
254
+ throw err;
255
+ } finally {
256
+ // Cleared as soon as the headers are in, so the body may stream for as long as it needs.
257
+ clearTimeout(timer);
258
+ outer?.removeEventListener("abort", onAbort);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * A MULTIPART BODY, from the same plain object every other encoding starts from.
264
+ *
265
+ * Needed because some endpoints only accept a file upload: ElevenLabs speech-to-text takes the audio
266
+ * as a form part, and no amount of JSON expresses that. Without it a node that uploads anything cannot
267
+ * be a manifest at all.
268
+ *
269
+ * TWO KINDS OF FIELD, and the manifest says which by SHAPE rather than by a flag:
270
+ *
271
+ * a scalar a text field. Numbers and booleans are stringified, because a form
272
+ * carries text and the alternative is `[object Object]` reaching a vendor
273
+ * { base64, mimeType?, a FILE part. base64 is how bytes travel between calls — `transport:
274
+ * filename? } binary` produced it, and an event bus cannot carry a Buffer — so this is
275
+ * where it turns back into bytes, at the last moment before sending
276
+ *
277
+ * A file part needs a FILENAME even when the content is what matters: many servers ignore a part with
278
+ * none, and the failure is a 400 about a missing field rather than about the name. `file` is the
279
+ * fallback, which is what the retired ElevenLabs client used.
280
+ */
281
+ function toFormData(body: unknown): FormData {
282
+ const form = new FormData();
283
+ if (!body || typeof body !== "object") return form;
284
+
285
+ for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
286
+ if (value === undefined || value === null) continue;
287
+
288
+ const part = value as Record<string, unknown>;
289
+ if (typeof value === "object" && typeof part.base64 === "string") {
290
+ const bytes = Buffer.from(part.base64, "base64");
291
+ form.set(key, new Blob([bytes], { type: String(part.mimeType ?? "application/octet-stream") }), String(part.filename ?? "file"));
292
+ continue;
293
+ }
294
+
295
+ // An object that is NOT a file part is JSON in a text field, which is how vendors take nested
296
+ // options on a form. Stringifying it as [object Object] would be silently wrong.
297
+ form.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
298
+ }
299
+ return form;
300
+ }
301
+
302
+ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: RunContext, label: string, forceReauth = false): Promise<Response> {
303
+ await primeTemplating();
304
+ const built: any = await buildRequest({ request: requestSpec }, ctx, forceReauth);
305
+ const { url, init, rawBody } = built;
306
+ // Nor HEAD: the fetch spec forbids a body on either, and undici enforces it. A bodyless
307
+ // POST still sends "{}" below (resolveBody defaults the absent body to an object), which
308
+ // some vendors require — but on HEAD that default became a body on a request that cannot
309
+ // carry one, and S3FileContent's metadata HEAD was the first call in the fleet to find out.
310
+ if (init.method !== "GET" && init.method !== "HEAD") {
311
+ const resolved = await resolveBody(rawBody, built.scoped ?? ctx);
312
+ /**
313
+ * RAW BYTES, not JSON. Storing a file means PUTting the file, not a JSON document that
314
+ * describes it, and JSON.stringify would upload the quoted base64 text instead.
315
+ *
316
+ * The manifest hands over base64 because that is the only form bytes take while they
317
+ * travel between calls — `transport: binary` produced it, and an event bus cannot carry
318
+ * a Buffer. This is where it turns back into bytes, at the last moment before sending.
319
+ */
320
+ if (requestSpec?.encoding === "binary") {
321
+ const b64 = typeof resolved === "string" ? resolved : String((resolved as any)?.base64 ?? "");
322
+ init.body = Buffer.from(b64, "base64");
323
+ // Content-Length must be the BYTE count, and the default JSON header would be a lie
324
+ // about what is in the body. Only set when the manifest did not say.
325
+ const headers = init.headers as Record<string, string>;
326
+ if (headers["Content-Type"] === "application/json") delete headers["Content-Type"];
327
+ } else if (requestSpec?.encoding === "multipart") {
328
+ init.body = toFormData(resolved);
329
+ /**
330
+ * THE BOUNDARY IS NOT OURS TO WRITE. `fetch` generates it and sets Content-Type itself, so a
331
+ * declared `multipart/form-data` header would arrive WITHOUT the boundary parameter and the
332
+ * vendor would reject a body it could not split. Deleting the header is the fix, and it is the
333
+ * same reasoning as `binary` above: the default JSON header is a lie about what is being sent.
334
+ */
335
+ delete (init.headers as Record<string, string>)["Content-Type"];
336
+ } else if (requestSpec?.encoding === "ndjson") {
337
+ /**
338
+ * NEWLINE-DELIMITED JSON: one complete JSON document per line, no enclosing array and
339
+ * no commas between them. Pinecone's integrated-inference upsert is the reason — it
340
+ * takes a batch of records this way so a server can stream them without holding the
341
+ * whole batch in memory.
342
+ *
343
+ * THE ARRAY IS NOT THE BODY. `JSON.stringify` of the same records would produce
344
+ * `[{...},{...}]`, which is valid JSON and a 400 here: the vendor parses line by line
345
+ * and the very first line would be an unterminated document. That is why this is an
346
+ * encoding rather than something a manifest could express by hand — an expression
347
+ * returning a pre-joined string would be a string, and every other encoding starts
348
+ * from the same plain resolved value.
349
+ */
350
+ const rows = Array.isArray(resolved) ? resolved : [resolved];
351
+ init.body = rows.map((row) => JSON.stringify(row)).join("\n");
352
+ // Not application/json: a server dispatching on content type would hand this to a
353
+ // strict JSON parser, which fails on the second line.
354
+ (init.headers as Record<string, string>)["Content-Type"] = "application/x-ndjson";
355
+ } else {
356
+ // BEFORE signing, because the signature covers the bytes actually sent. Encoding after
357
+ // would sign one body and send another, which AWS rejects with a signature mismatch.
358
+ init.body = JSON.stringify(requestSpec?.encoding === "dynamodbJson" ? encodeDynamoJson(resolved) : resolved);
359
+ }
360
+ }
361
+ // AFTER templating, because the host may itself be templated (a per-tenant
362
+ // subdomain), so only the resolved URL can be judged.
363
+ // The LAST argument is what makes the `"*"` host rule safe: a call with no `credential`
364
+ // block sends nothing secret, so there is nothing for a wildcard host to leak. One that
365
+ // does is held to the declared list no matter what the package also allows.
366
+ assertAllowedHost(url, node.allowedHosts, node.type, !!requestSpec?.credential && requestSpec.credential.scheme !== "none");
367
+
368
+ // LAST, once the url, headers and body are final. A SigV4 signature covers all three, so
369
+ // it is the one auth scheme that cannot be resolved up front with the others. Signing
370
+ // before the body existed would have produced a valid signature over an empty request and
371
+ // a 403 on every call that sent one.
372
+ if (built.sigv4) init.headers = await signAwsRequest(url, init.method, init.headers, init.body, built.sigv4);
373
+
374
+ const retry = requestSpec?.retry;
375
+ const attempts = Math.max(1, retry?.attempts ?? 1);
376
+ const retryOn: number[] = retry?.on ?? [];
377
+
378
+ /**
379
+ * ONE LINE OUT, ONE LINE BACK. A remote node's whole life otherwise happens between
380
+ * "Executing node X" and its verdict, and every minute of that silence gets read as a
381
+ * hang — a 60-second upload, a slow vendor, a poll loop all look identical from the
382
+ * engine log. The QUERY IS CUT, and not for brevity: a presigned url carries its
383
+ * signature there, and a list call carries its filters. Poll attempts are quiet here
384
+ * because the poll loop narrates itself, by status change rather than by request.
385
+ */
386
+ const target = url.split("?")[0] + (url.includes("?") ? "?…" : "");
387
+ const quiet = /\(poll \d/.test(label);
388
+ const started = Date.now();
389
+ if (!quiet) console.log(`[manifests] ${label}: ${init.method} ${target}`);
390
+
391
+ let res!: Response;
392
+ for (let attempt = 1; ; attempt++) {
393
+ /**
394
+ * A HEARTBEAT WHILE THE VENDOR THINKS. A non-streaming model call can be minutes of
395
+ * legitimate silence (an Opus reading a whole document), and after the poll loop
396
+ * learned to narrate its wait, the one remaining silence was this await. Every 30s,
397
+ * with the bound, so a reader can tell "slow vendor, still inside its budget" from
398
+ * "hung" without knowing the manifest.
399
+ */
400
+ const bound = Number(requestSpec?.timeoutMs) || 120000;
401
+ const waiter = quiet
402
+ ? null
403
+ : setInterval(() => {
404
+ console.log(`[manifests] ${label}: still waiting on the vendor — ${Math.round((Date.now() - started) / 1000)}s elapsed (bound ${Math.round(bound / 1000)}s)`);
405
+ }, 30_000);
406
+ waiter?.unref?.();
407
+ try {
408
+ res = await fetchWithTimeout(url, init, requestSpec?.timeoutMs, label);
409
+ } finally {
410
+ if (waiter) clearInterval(waiter);
411
+ }
412
+ if (res.ok) {
413
+ if (!quiet) console.log(`[manifests] ${label}: ${res.status} in ${Date.now() - started}ms`);
414
+ return res;
415
+ }
416
+
417
+ // Statuses the manifest declares as an ANSWER rather than a failure (`okOn: [404]`),
418
+ // so a projection can interpret them — an existence check, a lookup by an id the
419
+ // model may have mistyped. Without this, the projection's graceful miss-handling is
420
+ // unreachable: the transport throws first and the caller sees an empty result.
421
+ if (Array.isArray(requestSpec?.okOn) && requestSpec.okOn.includes(res.status)) {
422
+ if (!quiet) console.log(`[manifests] ${label}: ${res.status} (declared ok) in ${Date.now() - started}ms`);
423
+ return res;
424
+ }
425
+
426
+ // A minted token can be revoked or expire early, and the vendor says so with a 401.
427
+ // Refresh ONCE and retry, exactly as the retired client did. Guarded by !forceReauth so
428
+ // a genuinely rejected credential fails rather than looping.
429
+ if (res.status === 401 && requestSpec?.credential?.scheme === "oauth2ClientCredentials" && !forceReauth) {
430
+ invalidateToken(String(render(requestSpec.credential.tokenUrl, ctx)), String(render(requestSpec.credential.clientId, ctx)));
431
+ console.warn(`[manifests] ${label}: 401, refreshing the access token and retrying once`);
432
+ return sendRequest(node, requestSpec, ctx, label, true);
433
+ }
434
+
435
+ // Only the statuses the manifest named, and never the last attempt. A retry on a
436
+ // 400 just sends the same bad request again.
437
+ if (attempt >= attempts || !retryOn.includes(res.status)) break;
438
+
439
+ const wait =
440
+ retry.backoff === "exponential" ? 250 * 2 ** (attempt - 1) : retry.backoff === "fixed" ? 250 : 0;
441
+ console.warn(`[manifests] ${label}: ${res.status}, retrying in ${wait}ms (${attempt}/${attempts - 1})`);
442
+ if (wait) await new Promise((r) => setTimeout(r, wait));
443
+ }
444
+
445
+ const detail = await res.text().catch(() => "");
446
+ throw new Error(`${label}: ${res.status} ${res.statusText}${detail ? ` — ${detail.slice(0, 400)}` : ""}`);
447
+ }
448
+
449
+ /**
450
+ * SETTLING calls, made in order, each seeing what the earlier ones returned.
451
+ *
452
+ * A node declares its calls as a list, and this runs the part of that list whose replies
453
+ * are only ever read by something later: every call but the last when the graph runs the
454
+ * node, and every call of a service method. The last call of a graph run is framed
455
+ * separately, in runFinal, because it alone may stream.
456
+ *
457
+ * Why more than one call is an executor capability and not something a manifest could
458
+ * fake: one fact often takes more than one request to establish, and the second request's
459
+ * URL is built from the first's reply. Resolving a CRM contact is a search by email, then
460
+ * a follow of the company association only when the contact's own company field came back
461
+ * blank. No body template expresses "and then", so without this the node stays code.
462
+ *
463
+ * FLAT, not nested. The list reads top to bottom, exactly like the events table and for
464
+ * the same reason: no merge order to reason about, and no way to declare one call twice.
465
+ * Each entry is an ordinary call spec, so `sendRequest` is untouched and every capability
466
+ * one call has (auth, retry, error, allowedHosts) they all have for free.
467
+ *
468
+ * Two rules the shape depends on:
469
+ * - A call's `name` is how later calls and `returns` reach its reply, via `calls.<name>`.
470
+ * - A call SKIPPED by `when` leaves no key behind, so "did it happen?" is `!!calls.x`
471
+ * rather than a sentinel nobody would remember to check.
472
+ */
473
+ /**
474
+ * A PRESIGN entry makes no request at all: it computes credential-carrying urls from the
475
+ * credentials and the clock. Shared by `runCalls` and `runFinal`, because a presign is legal
476
+ * anywhere in the list — S3Files ends on one (list, then mint a link per object found), and
477
+ * before `runFinal` knew this shape, that last entry fell through to `sendRequest` with no
478
+ * `url` and failed as "the request url resolved to undefined".
479
+ *
480
+ * ALWAYS A LIST, even for one URL, so the events table never cares how many there were.
481
+ */
482
+ export async function performPresign(node: ComposedNode, call: any, scoped: RunContext): Promise<string[]> {
483
+ const p = call.presign;
484
+ const items = (await evaluate(p.for, scoped as unknown as Record<string, unknown>)) as unknown[];
485
+ const creds = (scoped.credentials?.awsCredential ?? {}) as Record<string, string>;
486
+ const signing: AwsSigning = {
487
+ region: String(p.region ?? creds.region ?? ""),
488
+ service: String(p.service ?? "s3"),
489
+ accessKeyId: String(creds.accessKeyId ?? ""),
490
+ secretAccessKey: String(creds.secretAccessKey ?? ""),
491
+ sessionToken: creds.sessionToken || undefined,
492
+ };
493
+ const expiresIn = Number(await evaluate(String(p.expiresIn ?? "return 3600"), scoped as unknown as Record<string, unknown>)) || 3600;
494
+
495
+ const urls: string[] = [];
496
+ for (const item of Array.isArray(items) ? items : []) {
497
+ // `item` is in scope, so the manifest says how ONE url is built and this repeats it.
498
+ const target = String(await evaluate(p.url, { ...scoped, item } as unknown as Record<string, unknown>));
499
+ // ALWAYS credential-carrying: a presigned url IS authority, so it can never ride
500
+ // on the unauthenticated wildcard.
501
+ assertAllowedHost(target, node.allowedHosts, node.type, true);
502
+ urls.push(await presignAwsUrl(target, signing, expiresIn));
503
+ }
504
+ return urls;
505
+ }
506
+
507
+ export async function runCalls(
508
+ node: ComposedNode,
509
+ calls: any[],
510
+ ctx: RunContext,
511
+ label: string,
512
+ store?: StateStore,
513
+ ): Promise<{ results: Record<string, any>; last: any }> {
514
+ const results: Record<string, any> = {};
515
+ let last: any = undefined;
516
+
517
+ for (const call of calls) {
518
+ // Rebuilt each time so a call sees every earlier reply, and never its own.
519
+ const scoped: RunContext = { ...ctx, calls: results };
520
+
521
+ if (call.when && !(await evaluate(call.when, scoped as unknown as Record<string, unknown>))) continue;
522
+
523
+ // A PLATFORM STATE operation rather than a request. Same list, same `name`, same
524
+ // `when`: reading a cache, calling a vendor only when it was cold, then writing the
525
+ // answer back is one sequence, and splitting it across two sections would put the
526
+ // order in the reader's head instead of on the page.
527
+ if (call.state) {
528
+ const value = call.value ? ((await evaluate(call.value, scoped as unknown as Record<string, unknown>)) as any) : undefined;
529
+ // `key` is optional now that `save` exists: its key is the run's, so a manifest naming one
530
+ // would be guessing at the engine's layout. `render(undefined)` gives back undefined, and
531
+ // String() of that is the text "undefined" — harmless only because `save` ignores the key
532
+ // entirely, which is why performState takes the scope separately rather than a built key.
533
+ const payload = await performState(
534
+ call.state,
535
+ String(render(call.key, scoped)),
536
+ value,
537
+ call.max,
538
+ store,
539
+ ctx.scope,
540
+ );
541
+ results[call.name] = payload;
542
+ last = payload;
543
+ continue;
544
+ }
545
+
546
+ /**
547
+ * A DOCSTORE operation — the sectioned, hash-checked markdown document in Redis
548
+ * (docstore/index.ts). In this list for the same reason `state` and `loop` are: it is a
549
+ * platform capability the manifest NAMES, not a request, and it belongs to the sequence.
550
+ *
551
+ * `params` defaults to the CALLER'S params, because nine of the ten ops exist as service
552
+ * methods whose arguments arrive exactly there; an explicit `params` expression overrides
553
+ * for the odd case. The doc's KEY is derived from the run's own ids inside performDoc —
554
+ * never from the manifest, which could otherwise read another conversation's document.
555
+ */
556
+ if (call.docstore) {
557
+ const args = call.params
558
+ ? ((await evaluate(call.params, scoped as unknown as Record<string, unknown>)) as Record<string, any>)
559
+ : ctx.params;
560
+ const payload = await performDoc(String(call.docstore), args ?? {}, store?.raw, ctx.scope, ctx.config);
561
+ results[call.name] = payload;
562
+ last = payload;
563
+ continue;
564
+ }
565
+
566
+ // ITERATION BOOKKEEPING, and it belongs in this list for the same reason `state` does: a
567
+ // LoopStart both READS the advanced index and OPENS a new loop, one or the other depending on
568
+ // whether it was re-fired, and those are two entries whose `when` tells them apart.
569
+ if (call.loop) {
570
+ const value = call.value ? await evaluate(call.value, scoped as unknown as Record<string, unknown>) : undefined;
571
+ const payload = await performLoop(call.loop, ctx.scope?.executionId, String(render(call.key, scoped)), value, store?.loop);
572
+ results[call.name] = payload;
573
+ last = payload;
574
+ continue;
575
+ }
576
+
577
+ /**
578
+ * A PRESIGN entry makes no request at all. Like `state`, it earns its place in the same
579
+ * ordered list because it belongs to the sequence: list a bucket, then mint a link for
580
+ * each thing found. Splitting it elsewhere would put the order in the reader's head.
581
+ *
582
+ * ALWAYS A LIST, even for one URL. A bucket listing needs a link per object and a single
583
+ * file needs one, and having those be two different shapes would mean the events table
584
+ * cared how many there were.
585
+ */
586
+ if (call.presign) {
587
+ const urls = await performPresign(node, call, scoped);
588
+ results[call.name] = urls;
589
+ last = urls;
590
+ continue;
591
+ }
592
+
593
+ // A CHUNKED call is many requests over one collection: { sent, batches, results, errors }.
594
+ if (call.chunk) {
595
+ const written = await sendChunked(node, call, scoped, `${label}.${call.name}`);
596
+ results[call.name] = written;
597
+ last = written;
598
+ continue;
599
+ }
600
+
601
+ // A PAGINATED call is many requests and one reply: { items, pages, truncated }.
602
+ if (call.paginate) {
603
+ const walked = await fetchPaginated(node, call, scoped, `${label}.${call.name}`);
604
+ results[call.name] = walked;
605
+ last = walked;
606
+ continue;
607
+ }
608
+
609
+ // A POLLED call is a JOB: start it, then ask until it is done. The reply is the final
610
+ // status payload, never the start receipt.
611
+ if (call.poll) {
612
+ const finished = await fetchPolled(node, call, scoped, `${label}.${call.name}`);
613
+ results[call.name] = finished;
614
+ last = finished;
615
+ continue;
616
+ }
617
+
618
+ // SETTLING transports. `xml` settles exactly like json — one body, parsed once — so it
619
+ // belongs here; only a STREAM is barred from a non-final position.
620
+ if (call.transport !== "json" && call.transport !== "text" && call.transport !== "xml" && call.transport !== "headers" && call.transport !== "binary") {
621
+ throw new Error(
622
+ `${label}.${call.name}: this call must settle (transport json or text), not "${call.transport}". ` +
623
+ `Only a node's LAST call may stream, because its reply is the answer; an earlier one exists ` +
624
+ `so a later call can read it, and there is nothing to read from a stream nobody framed.`,
625
+ );
626
+ }
627
+
628
+ const res = await sendRequest(node, call, scoped, `${label}.${call.name}`);
629
+ const payload = await readSettled(res, call.transport, call.encoding);
630
+ await assertOk(node, call, payload, `${label}.${call.name}`);
631
+
632
+ results[call.name] = payload;
633
+ last = payload;
634
+ }
635
+
636
+ return { results, last };
637
+ }