@unoverse-platform/studio 0.1.90 → 0.1.92
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.
- package/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -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
|
+
}
|