@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.
Files changed (132) hide show
  1. package/local/plugin.ts +21 -65
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +155 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +659 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. package/local/mcp.ts +0 -265
@@ -0,0 +1,162 @@
1
+ import { evaluate } from "../templating.js";
2
+ import { decodeDynamoJson } from "../auth/aws.js";
3
+
4
+ /**
5
+ * Reading a response off the wire. Bytes to payloads, and nothing else.
6
+ *
7
+ * Part of the manifest runtime (DECLARATIVE_NODES.md §2): the manifest DESCRIBES the
8
+ * service, this half COMPUTES it. Split by concern so each piece stays readable.
9
+ */
10
+
11
+ export interface Emission {
12
+ /**
13
+ * The output connector this fired on. Absent on a `send` row, which addresses a NODE
14
+ * instead of a connector and therefore has no dot on the canvas and no edge to draw.
15
+ */
16
+ emit?: string;
17
+ /** Target NODE id, resolved from the row's `send` template. Present only on a send row. */
18
+ to?: string;
19
+ /** Input handle on the target node. Defaults to `input`. */
20
+ handle?: string;
21
+ value: unknown;
22
+ }
23
+
24
+ /**
25
+ * Read a Server-Sent Events stream into parsed payloads.
26
+ *
27
+ * Reading and DECIDING are separate: this turns bytes into events, and events.ts decides
28
+ * what leaves the node. Keeping them apart is why the tool loop can watch the same stream
29
+ * for a tool call without the emission table knowing anything about tools.
30
+ */
31
+ export async function readSse(res: Response, terminator: string | undefined, onPayload: (payload: any) => Promise<void>): Promise<void> {
32
+ const reader = res.body?.getReader();
33
+ if (!reader) throw new Error("response has no body to stream");
34
+ const decoder = new TextDecoder();
35
+ let buffer = "";
36
+
37
+ for (;;) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+ buffer += decoder.decode(value, { stream: true });
41
+
42
+ // SSE frames are separated by a blank line; a frame's data may span lines.
43
+ let split: number;
44
+ while ((split = buffer.indexOf("\n\n")) !== -1) {
45
+ const frame = buffer.slice(0, split);
46
+ buffer = buffer.slice(split + 2);
47
+
48
+ const data = frame
49
+ .split("\n")
50
+ .filter((l) => l.startsWith("data:"))
51
+ .map((l) => l.slice(5).trim())
52
+ .join("");
53
+ if (!data || (terminator && data === terminator)) continue;
54
+
55
+ let payload: any;
56
+ try {
57
+ payload = JSON.parse(data);
58
+ } catch {
59
+ continue; // a keep-alive or comment frame
60
+ }
61
+
62
+ await onPayload(payload);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read a settled response body, per the declared transport and encoding.
69
+ *
70
+ * `encoding` is a second axis to `transport`: transport says how the reply is FRAMED
71
+ * (one body, or a stream of events), encoding says how the values inside it are SPELLED.
72
+ * DynamoDB is JSON-framed and type-tagged, so it needs both.
73
+ */
74
+ export async function readSettled(res: Response, transport: string, encoding?: string): Promise<any> {
75
+ if (transport === "text") return res.text();
76
+
77
+ /**
78
+ * THE HEADERS ARE THE ANSWER. A HEAD request has no body by definition: the size, the
79
+ * content type and the modified date all come back as headers and there is nothing else.
80
+ *
81
+ * Reading it as `text` gave an empty string and a manifest with no way to reach any of it,
82
+ * which is what makes this a transport rather than a flag. Lower-cased keys, because HTTP
83
+ * header names are case-insensitive and an expression asking for `content-length` should
84
+ * not depend on what the vendor happened to capitalise.
85
+ */
86
+ if (transport === "headers") {
87
+ const out: Record<string, string> = {};
88
+ res.headers.forEach((value, key) => {
89
+ out[key.toLowerCase()] = value;
90
+ });
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * XML, for the vendors that never moved. S3's list endpoints are the reason.
96
+ *
97
+ * Parsed to the same plain object shape a JSON reply would give, so an events table reads
98
+ * `response.ListBucketResult.Contents` either way and nothing downstream knows or cares
99
+ * which wire format it came from.
100
+ *
101
+ * `isArray` is the setting that matters. XML cannot distinguish one element from a list of
102
+ * one, so a bucket holding a SINGLE object parses `Contents` as an object while a bucket
103
+ * holding two parses it as an array. A manifest written against the second silently breaks
104
+ * on the first, and the failure only appears once a bucket happens to have one file in it.
105
+ * Naming the repeatable elements up front makes them always arrays.
106
+ */
107
+ if (transport === "xml") {
108
+ const { XMLParser } = await import("fast-xml-parser");
109
+ const parser = new XMLParser({
110
+ ignoreAttributes: false,
111
+ attributeNamePrefix: "@",
112
+ isArray: (name) => XML_REPEATED.has(name),
113
+ });
114
+ return parser.parse(await res.text());
115
+ }
116
+
117
+ /**
118
+ * BYTES. Audio a model generated, an image to send on to another model, a file to store.
119
+ *
120
+ * Handed over as BASE64 rather than a Buffer or a stream, because what leaves a node
121
+ * travels on the event bus and through JSON: a Buffer does not survive that trip, and a
122
+ * stream cannot be handed to two consumers. base64 is the form every downstream thing can
123
+ * actually hold.
124
+ *
125
+ * `contentType` comes along because bytes without their type are unusable — the receiver
126
+ * has to guess whether it is holding an mp3 or a PDF — and the vendor already said.
127
+ */
128
+ if (transport === "binary") {
129
+ const bytes = Buffer.from(await res.arrayBuffer());
130
+ return {
131
+ base64: bytes.toString("base64"),
132
+ contentType: res.headers.get("content-type") || "application/octet-stream",
133
+ bytes: bytes.length,
134
+ };
135
+ }
136
+
137
+ const payload = await res.json().catch(() => ({}));
138
+ return encoding === "dynamodbJson" ? decodeDynamoJson(payload) : payload;
139
+ }
140
+
141
+ /**
142
+ * Elements that are a LIST even when there is one of them.
143
+ *
144
+ * Named rather than inferred, because the ambiguity is in XML itself and no parser can
145
+ * resolve it without being told. Add to this when a node meets a new repeatable element;
146
+ * getting it wrong shows up as "works with 2 files, breaks with 1".
147
+ */
148
+ const XML_REPEATED = new Set(["Contents", "CommonPrefixes", "Bucket", "Version", "DeleteMarker", "member", "item"]);
149
+
150
+ /**
151
+ * A 200 carrying an error body must not read as success.
152
+ *
153
+ * This was declared by every node and implemented on the service channel only, so on the
154
+ * workflow channel a failed stream emitted nothing and reported success. Same class of
155
+ * gap as `retry`: named in the schema, absent from the executor.
156
+ */
157
+ export async function assertOk(node: unknown, call: any, payload: any, where: string): Promise<void> {
158
+ if (!call.error?.when) return;
159
+ if (!(await evaluate(call.error.when, { response: payload }))) return;
160
+ const message = call.error.message ? await evaluate(call.error.message, { response: payload }) : "upstream error";
161
+ throw new Error(`${where}: ${message}`);
162
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * THE MANIFEST RUNTIME — the half that COMPUTES what a manifest DESCRIBES.
3
+ *
4
+ * The split (DECLARATIVE_NODES.md §2): every enum value in api.schema.json must have an
5
+ * implementation somewhere in this folder and NOWHERE else, which is what makes accepting a
6
+ * manifest from someone else safe. `manifest-capabilities.test.ts` enforces that parity by
7
+ * walking the schema and this tree, so a capability offered without code fails the build rather
8
+ * than a stranger's workflow.
9
+ *
10
+ * Nothing in here is per-vendor. Adding an auth scheme or a transport makes it available to
11
+ * every node that will ever be written.
12
+ *
13
+ * THIS FILE IS A BARREL and should stay one. It held the two channels, the narrator and the
14
+ * last-call dispatch until it reached 425 lines of four unrelated concerns, which is the shape
15
+ * of file nobody reads and everybody appends to. One import surface, no logic:
16
+ *
17
+ * context.ts what a manifest may see, and the resolvers applied first
18
+ * templating.ts {{ }} and `return ...`, both borrowed from the platform
19
+ * events.ts the events table: everything that LEAVES a node, in one ordered list
20
+ * narrate.ts the running commentary while it works
21
+ * state.ts platform storage, and the run's saved context
22
+ * channels/ the two ways a node is reached, which never cross
23
+ * workflow.ts the GRAPH runs it: the shape of a run, start to finish
24
+ * final.ts the last call, the only one that may stream — one dispatch per reply shape
25
+ * service.ts an AGENT calls it: one method, one value handed straight back
26
+ * http/ building and sending. One fetch, one allowedHosts check
27
+ * auth/ the schemes, per scheme
28
+ * loops/ many requests, or many passes, for one node: paginate, poll, chunk, loop
29
+ * duplex/ a socket that stays open. Two of them: the vendor's and the audio lane
30
+ * tools/ the HTTP tool exchange, by turns
31
+ */
32
+ export type { RunContext } from "./context.js";
33
+ export type { Emission } from "./http/response.js";
34
+ export { emptyContext, applyResolvers, RESOLVERS } from "./context.js";
35
+ export { render, evaluate, primeTemplating } from "./templating.js";
36
+ export { assertAllowedHost } from "./http/allowedHosts.js";
37
+ export { buildRequest, resolveBody, sendRequest, runCalls } from "./http/request.js";
38
+ export { readSse, readSettled, assertOk } from "./http/response.js";
39
+ export { makeStateStore, performState, type StateStore } from "./state.js";
40
+ export { fetchPaginated } from "./loops/paginate.js";
41
+ export { fetchPolled } from "./loops/poll.js";
42
+ export { sendChunked } from "./loops/chunk.js";
43
+ // Iteration bookkeeping for the LoopStart/LoopEnd pair. ONE capability rather than the seven raw
44
+ // Redis commands the retired nodes reached for — see loops/loop.ts for why that distinction is
45
+ // the whole point.
46
+ export { performLoop, normaliseItems, makeLoopOps, type LoopOps, type LoopPass, type LoopStep } from "./loops/loop.js";
47
+ export { makeEmitter, type Emitter, type EventSource } from "./events.js";
48
+ export { makeNarrator, type Narrator } from "./narrate.js";
49
+ export { runToolLoop, type ToolBridge } from "./tools/toolloop.js";
50
+ export { performDoc, MUTATING_DOC_OPS, docKeyFor } from "./docstore/index.js";
51
+ export { runDuplexSession } from "./duplex/session.js";
52
+ // The audio lane is its own module: session.ts owns the VENDOR socket, audioLane.ts owns the bytes
53
+ // and the browser. Two sockets, two files.
54
+ export { resamplePcm16, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
55
+ export { performApi, type RunResult } from "./channels/workflow.js";
56
+ export { runFinal } from "./channels/final.js";
57
+ export { performService } from "./channels/service.js";
@@ -0,0 +1,98 @@
1
+ /**
2
+ * CHUNKED WRITES. One call in the manifest, many requests on the wire.
3
+ *
4
+ * The mirror of pagination: that one loops because the vendor decides how much comes back,
5
+ * this one loops because the vendor decides how much may go in at once. Airtable takes 10
6
+ * records per write, HubSpot 100, Salesforce 200, so writing a collection of unknown length
7
+ * is N requests and a manifest has no way to repeat a call N times.
8
+ *
9
+ * The loop is computation, the batch size and the body shape are description, which is the
10
+ * usual split. Every node that writes a collection inherits this.
11
+ *
12
+ * The reply is `{ sent, batches, results, errors }`. Partial success is the normal outcome
13
+ * of a batched write and it must be visible: one rejected batch out of ten is not a failed
14
+ * call, and it is not a successful one either.
15
+ */
16
+ import type { ComposedNode } from "../../compose.js";
17
+ import type { RunContext } from "../context.js";
18
+ import { evaluate } from "../templating.js";
19
+ import { sendRequest } from "../http/request.js";
20
+ import { fetchPolled } from "./poll.js";
21
+ import { readSettled } from "../http/response.js";
22
+
23
+ export interface Chunked {
24
+ /** Items actually accepted, across every batch that succeeded. */
25
+ sent: number;
26
+ batches: number;
27
+ /**
28
+ * What the walk was OVER, in order — the resolved collection, not a count of it.
29
+ *
30
+ * The other four fields all describe what HAPPENED, and for a write that is the whole
31
+ * story. Fanning out a READ needs the input as well: pairing reply i back to the thing
32
+ * that produced it is impossible from a summary, and re-deriving the list in the events
33
+ * table means the same expression written twice, free to drift.
34
+ */
35
+ items: unknown[];
36
+ /**
37
+ * One entry per batch, IN ORDER, `null` where the batch failed.
38
+ *
39
+ * Positional, not compacted. A write only ever reads `sent`, so the difference did not
40
+ * show until `chunk` was used to fan out a READ (one item per request), where the caller
41
+ * has to pair each reply back to the item that produced it. Dropping the failures would
42
+ * shift every later reply onto the wrong item and label the results wrongly, which is
43
+ * worse than an error, because it looks like an answer.
44
+ */
45
+ results: unknown[];
46
+ /** One entry per FAILED batch, naming which. Empty means every batch landed. */
47
+ errors: string[];
48
+ }
49
+
50
+ /** Independent of anything the manifest says, so a bad `size` cannot melt a vendor. */
51
+ const HARD_BATCH_CAP = 200;
52
+
53
+ export async function sendChunked(
54
+ node: ComposedNode,
55
+ call: any,
56
+ ctx: RunContext,
57
+ label: string,
58
+ ): Promise<Chunked> {
59
+ const c = call.chunk;
60
+ const all = await evaluate(c.items, ctx as unknown as Record<string, unknown>);
61
+ const items: unknown[] = Array.isArray(all) ? all : [];
62
+ const size = Math.min(Math.max(Number(c.size) || 1, 1), HARD_BATCH_CAP);
63
+
64
+ const out: Chunked = { sent: 0, batches: 0, items, results: [], errors: [] };
65
+ if (!items.length) return out;
66
+
67
+ for (let i = 0; i < items.length; i += size) {
68
+ const batch = items.slice(i, i + size);
69
+ const n = out.batches + 1;
70
+
71
+ // `batch` is in scope for the body, which is the whole point: the manifest describes
72
+ // what ONE request looks like for a slice, and this repeats it.
73
+ const scoped = { ...ctx, batch } as RunContext;
74
+
75
+ try {
76
+ // The two loops COMPOSE, because they are orthogonal: this one walks the collection,
77
+ // `poll` waits on one request. A vendor whose endpoint takes a single item AND runs
78
+ // it as a job (Hyperbrowser's fetch is both) needs both, and declaring them mutually
79
+ // exclusive would put that node back in TypeScript for no reason.
80
+ out.results.push(
81
+ call.poll
82
+ ? await fetchPolled(node, call, scoped, `${label} (batch ${n})`)
83
+ : await readSettled(await sendRequest(node, call, scoped, `${label} (batch ${n})`), call.transport, call.encoding),
84
+ );
85
+ out.sent += batch.length;
86
+ } catch (err: any) {
87
+ // KEEP GOING. A batched write that abandons everything because batch 3 of 10 failed
88
+ // is worse than one that reports which: the first two already landed and cannot be
89
+ // taken back, so stopping leaves the caller unable to tell what happened.
90
+ out.errors.push(`batch ${n}: ${err?.message ?? err}`);
91
+ // Holds the slot, so `results[i]` is always the reply to batch `i`.
92
+ out.results.push(null);
93
+ }
94
+ out.batches = n;
95
+ }
96
+
97
+ return out;
98
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * ITERATION BOOKKEEPING — the capability behind the LoopStart / LoopEnd pair.
3
+ *
4
+ * WHY THIS IS ONE CAPABILITY AND NOT SEVERAL STATE OPS. The retired executors reached for
5
+ * hset, hget, hincrby, rpush, lrange, exists, del and expire. Declaring those one by one in
6
+ * api.schema.json would have amounted to "a manifest may run arbitrary Redis", which is the
7
+ * exact authority this format exists to withhold (DECLARATIVE_NODES.md §2). So the manifest
8
+ * names the three things a loop actually DOES, and the key layout, the counter and the
9
+ * teardown stay in here where they can be read once and audited:
10
+ *
11
+ * open take an array, record it, hand back the first item
12
+ * read hand back the item at the CURRENT index, without moving it
13
+ * advance collect this pass's value, move the index, and say whether there is more
14
+ *
15
+ * THE PAIR IS NOT AN ACTOR, which is the thing to understand before changing any of this.
16
+ * There is no XState access and no generator: LoopStart is an ordinary settle-once node that
17
+ * fires on its `items` input and then fires AGAIN because LoopEnd routes a `continue` signal
18
+ * back to it along a real edge. Each pass is a separate execution of both nodes, which is why
19
+ * all the state has to live outside them — a local variable would not survive the gap.
20
+ *
21
+ * `read` and `advance` are deliberately separate so that ITEM EMISSION HAS ONE HOME. LoopEnd
22
+ * moves the index and LoopStart reads it; if LoopEnd also returned the next item, the same
23
+ * fact would be computed in two places and could disagree by one.
24
+ *
25
+ * The engine has its own stake in the output shape: `executingState.ts` lifts {item, index,
26
+ * total} off a node whose type is literally "LoopStart" into `activeLoop`, and that is what
27
+ * makes {{ loop.item }} resolve for every node inside the body. So those three names are a
28
+ * contract, not a preference.
29
+ */
30
+
31
+ /** The Redis surface a loop needs. Injected, so this file touches no platform global. */
32
+ export interface LoopOps {
33
+ open(executionId: string, loopId: string, items: unknown): Promise<LoopPass>;
34
+ read(executionId: string, loopId: string): Promise<LoopPass>;
35
+ advance(executionId: string, loopId: string, collect: unknown): Promise<LoopStep>;
36
+ }
37
+
38
+ /** One pass, as LoopStart emits it. The three names the engine reads. */
39
+ export interface LoopPass {
40
+ item: unknown;
41
+ index: number;
42
+ total: number;
43
+ }
44
+
45
+ /** The end of one pass, as LoopEnd emits it. */
46
+ export interface LoopStep {
47
+ continuing: boolean;
48
+ index: number;
49
+ total: number;
50
+ collected?: unknown[];
51
+ }
52
+
53
+ /** An hour, matching the retired nodes. The state is within-run; this only stops a leak. */
54
+ const TTL_SECONDS = 3600;
55
+
56
+ /**
57
+ * THE ITEMS INPUT, normalised — and this is executor work rather than manifest work because
58
+ * what it unwraps is the PLATFORM'S OWN envelope, not the shape of anyone's data.
59
+ *
60
+ * A wired input arrives nested twice, by source node id and then by that source's output
61
+ * handle: `{ upstream1: { rows: [...] } }`. The retired executor peeled that off, and doing it
62
+ * in an expression instead would mean repeating `config.items || signal.items || []` five times
63
+ * inside one nested ternary, because the sandbox cannot bind an intermediate. That is the
64
+ * FieldValidator trap, and the answer here is that the envelope belongs to us.
65
+ *
66
+ * TRANSCRIBED from LoopStart's executor, branch for branch:
67
+ * an array, or anything not an object use it as-is
68
+ * one source holding an object the first ARRAY field in it, else the object itself
69
+ * one source holding anything else that value
70
+ * then a non-array becomes a one-item array, so "loop over a single thing" means one pass
71
+ * rather than nothing.
72
+ */
73
+ export function normaliseItems(raw: unknown): unknown[] {
74
+ let items: any = raw ?? [];
75
+
76
+ if (!Array.isArray(items) && items !== null && typeof items === "object") {
77
+ const firstSource = Object.values(items)[0];
78
+ if (firstSource && typeof firstSource === "object" && !Array.isArray(firstSource)) {
79
+ items = Object.values(firstSource).find((v) => Array.isArray(v)) || firstSource;
80
+ } else {
81
+ items = firstSource || [];
82
+ }
83
+ }
84
+
85
+ return Array.isArray(items) ? items : [items];
86
+ }
87
+
88
+ /**
89
+ * Bound to a live client. `undefined` when the platform has no Redis.
90
+ *
91
+ * KEYS ARE NAMESPACED, unlike the saved-context hash in state.ts, and the difference is about
92
+ * who else reads them. `saved:<executionId>` is the engine's and must stay bare. These two are
93
+ * ours alone, read by nothing outside this file, and both halves of the pair always move
94
+ * versions together — so there is no bare-versus-prefixed mismatch to create.
95
+ */
96
+ export function makeLoopOps(redis: any, namespace: string | undefined): LoopOps | undefined {
97
+ if (!redis) return undefined;
98
+ const ns = namespace ? `${namespace}:` : "";
99
+ const stateKey = (executionId: string, loopId: string) => `${ns}loop:${executionId}:${loopId}`;
100
+ const collectedKey = (executionId: string, loopId: string) => `${ns}loop:collected:${executionId}:${loopId}`;
101
+
102
+ const parseItem = (raw: string): unknown => {
103
+ try {
104
+ return JSON.parse(raw);
105
+ } catch {
106
+ return raw; // a value stored as plain text reads back as text rather than failing the loop
107
+ }
108
+ };
109
+
110
+ return {
111
+ async open(executionId, loopId, items) {
112
+ const list = normaliseItems(items);
113
+ const key = stateKey(executionId, loopId);
114
+ await redis.hset(key, { items: JSON.stringify(list), index: "0", total: String(list.length) });
115
+ await redis.expire(key, TTL_SECONDS);
116
+ return { item: list.length ? list[0] : null, index: 0, total: list.length };
117
+ },
118
+
119
+ async read(executionId, loopId) {
120
+ const key = stateKey(executionId, loopId);
121
+ const [itemsJson, indexStr, totalStr] = await Promise.all([
122
+ redis.hget(key, "items"),
123
+ redis.hget(key, "index"),
124
+ redis.hget(key, "total"),
125
+ ]);
126
+ const list: unknown[] = JSON.parse(itemsJson || "[]");
127
+ const index = parseInt(indexStr || "0", 10);
128
+ return { item: list[index] ?? null, index, total: parseInt(totalStr || "0", 10) };
129
+ },
130
+
131
+ async advance(executionId, loopId, collect) {
132
+ const key = stateKey(executionId, loopId);
133
+ const bag = collectedKey(executionId, loopId);
134
+
135
+ // A MISSING LOOP IS AN ERROR, not an empty result. It means LoopEnd is pointing at a
136
+ // LoopStart that never ran — usually a mistyped loopStartNodeId — and returning "done"
137
+ // would silently complete a loop that never had a first pass.
138
+ if (!(await redis.exists(key))) throw new Error(`loop state not found for ${executionId}:${loopId}`);
139
+
140
+ // Empty string as well as null: `collect` is a template field, so a config left blank
141
+ // resolves to "" rather than to nothing, and storing that would put a blank entry in the
142
+ // results for every pass.
143
+ if (collect !== undefined && collect !== null && collect !== "") {
144
+ // An ARRAY IS SPREAD into separate entries rather than nested, matching the retired
145
+ // node: collecting a per-item list gives one flat result list at the end.
146
+ for (const item of Array.isArray(collect) ? collect : [collect]) await redis.rpush(bag, JSON.stringify(item));
147
+ await redis.expire(bag, TTL_SECONDS);
148
+ }
149
+
150
+ // hincrby, so two nodes finishing at once cannot both read the same index and skip one.
151
+ const index: number = await redis.hincrby(key, "index", 1);
152
+ const total = parseInt((await redis.hget(key, "total")) || "0", 10);
153
+ if (index < total) return { continuing: true, index, total };
154
+
155
+ const collected = (await redis.exists(bag)) ? ((await redis.lrange(bag, 0, -1)) as string[]).map(parseItem) : [];
156
+ // Torn down on the last pass, so a long workflow does not carry every loop it ever ran.
157
+ await redis.del(key, bag);
158
+ return { continuing: false, index, total, collected };
159
+ },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Perform one declared loop operation.
165
+ *
166
+ * Unlike a state read, THERE IS NO DEGRADED MODE. A cache that reads cold is a cache doing its
167
+ * job badly; a loop with nowhere to keep its index cannot iterate at all, and pretending
168
+ * otherwise would run the body once and call it done.
169
+ */
170
+ export async function performLoop(
171
+ op: string,
172
+ executionId: string | undefined,
173
+ loopId: string,
174
+ value: unknown,
175
+ ops: LoopOps | undefined,
176
+ ): Promise<LoopPass | LoopStep> {
177
+ if (!ops) throw new Error(`loop "${op}" needs Redis to keep its index, and this platform has none configured`);
178
+ if (!executionId) throw new Error(`loop "${op}" needs the run's executionId, and this run supplied none`);
179
+ if (!loopId) throw new Error(`loop "${op}" needs the LoopStart node's id as its "key", and none resolved`);
180
+
181
+ switch (op) {
182
+ case "open":
183
+ return ops.open(executionId, loopId, value);
184
+ case "read":
185
+ return ops.read(executionId, loopId);
186
+ case "advance":
187
+ return ops.advance(executionId, loopId, value);
188
+ default:
189
+ throw new Error(`loop operation "${op}" is declared in the schema but not implemented in the executor`);
190
+ }
191
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * PAGINATION. One call in the manifest, many requests on the wire.
3
+ *
4
+ * Vendors disagree about the mechanism and agree about the shape: ask, get a page and a way
5
+ * to ask for the next, repeat until there is no next or you have enough. The disagreement
6
+ * is description (where the token lives, what it goes back as); the loop is computation, so
7
+ * it belongs here and every list-returning node inherits it (DECLARATIVE_NODES.md §2).
8
+ *
9
+ * Without this a manifest cannot express a second page at all, because it has no way to
10
+ * repeat a call an unknown number of times. `calls` is an ordered list of DIFFERENT calls,
11
+ * not a loop.
12
+ *
13
+ * A paginated call's reply is `{ items, pages, truncated }`, not the last page's body. The
14
+ * accumulation is the answer, and handing back the final page would quietly lose the rest.
15
+ */
16
+ import type { ComposedNode } from "../../compose.js";
17
+ import type { RunContext } from "../context.js";
18
+ import { evaluate } from "../templating.js";
19
+ import { sendRequest } from "../http/request.js";
20
+ import { readSettled, assertOk } from "../http/response.js";
21
+
22
+ /**
23
+ * Put the page token where the manifest said. Explicit on BOTH values rather than treating
24
+ * query as an implicit else: the capability guard reads the enum and asks the executor to
25
+ * name every member, which is what stops a schema value existing with no implementation.
26
+ */
27
+ function place(call: any, where: string, into: string, value: unknown): any {
28
+ switch (where) {
29
+ case "query":
30
+ return { ...call, query: { ...(call.query ?? {}), [into]: value } };
31
+ case "body":
32
+ return { ...call, body: withBodyKey(call.body, into, value) };
33
+ default:
34
+ throw new Error(`pagination "in: ${where}" is declared in the schema but not implemented in the executor`);
35
+ }
36
+ }
37
+
38
+ /** Set a key on a body that may be an object OR a `return ...` expression. */
39
+ function withBodyKey(body: any, key: string, value: unknown): any {
40
+ return typeof body === "string"
41
+ ? `return Object.assign({}, (${body.replace(/^return\s+/, "")}), ${JSON.stringify({ [key]: value })})`
42
+ : { ...(body ?? {}), [key]: value };
43
+ }
44
+
45
+ export interface Paginated {
46
+ items: unknown[];
47
+ pages: number;
48
+ /** True when a bound stopped the walk while the vendor still had more. */
49
+ truncated: boolean;
50
+ }
51
+
52
+ /**
53
+ * A hard ceiling on requests, independent of anything the manifest says.
54
+ *
55
+ * A vendor that keeps returning the same cursor, or a manifest whose `cursor` expression
56
+ * always finds a value, would otherwise loop until the process died. `max` is the author's
57
+ * bound; this is the platform's, and it exists because the author's can be wrong.
58
+ */
59
+ const HARD_PAGE_CAP = 100;
60
+
61
+ export async function fetchPaginated(
62
+ node: ComposedNode,
63
+ call: any,
64
+ ctx: RunContext,
65
+ label: string,
66
+ ): Promise<Paginated> {
67
+ const p = call.paginate;
68
+
69
+ // Branch on the declared strategy. Without this the enum would be decoration: a value
70
+ // added to the schema would silently walk as a cursor and look like it worked.
71
+ if (p.strategy !== "cursor" && p.strategy !== "page" && p.strategy !== "offset")
72
+ throw new Error(`pagination strategy "${p.strategy}" is declared in the schema but not implemented in the executor`);
73
+
74
+ /**
75
+ * UNBOUNDED IS THE DEFAULT, and it must not go through the sandbox to get there.
76
+ *
77
+ * This read `evaluate(p.max ?? "return Infinity")`, which threw `unknown identifier
78
+ * 'Infinity'` — the expression allowlist has no such global, by design. So EVERY
79
+ * paginated call that did not set `max` failed at the first page. AirtableInsert's dedup
80
+ * scan is one of them, in the shipped tree.
81
+ *
82
+ * It survived because the two nodes written since both happen to set `max` from config,
83
+ * and a default only breaks when someone takes it.
84
+ */
85
+ const max = p.max
86
+ ? Number(await evaluate(String(p.max), ctx as unknown as Record<string, unknown>)) || Infinity
87
+ : Infinity;
88
+
89
+ const items: unknown[] = [];
90
+ // `page` counts from 1, `offset` counts ITEMS from 0, and both go out on the first
91
+ // request; `cursor` is absent until a reply hands one over.
92
+ let cursor: unknown = p.strategy === "page" ? 1 : p.strategy === "offset" ? 0 : undefined;
93
+ let pages = 0;
94
+ let more = false;
95
+
96
+ for (; pages < HARD_PAGE_CAP; ) {
97
+ // The token rides on a COPY of the call, so the manifest's own keys are untouched.
98
+ //
99
+ // `in` says WHERE: a query parameter by default, or the body, because a JSON-body search
100
+ // endpoint takes its page number in the body and there is nowhere else to put it.
101
+ const spec = cursor === undefined ? call : place(call, p.in ?? "query", p.into, cursor);
102
+
103
+ const res = await sendRequest(node, spec, ctx, `${label} (page ${pages + 1})`);
104
+ const payload = await readSettled(res, call.transport, call.encoding);
105
+ await assertOk(node, call, payload, `${label} (page ${pages + 1})`);
106
+ pages++;
107
+
108
+ const page = await evaluate(p.items, { response: payload } as Record<string, unknown>);
109
+ for (const item of Array.isArray(page) ? page : []) {
110
+ if (items.length >= max) break;
111
+ items.push(item);
112
+ }
113
+
114
+ if (p.strategy === "page" || p.strategy === "offset") {
115
+ // No token to follow, so the END is a SHORT PAGE: fewer items than a full one means
116
+ // there is nothing after it. Both counting strategies end the same way; they differ
117
+ // only in what the number MEANS, and so in what it steps by.
118
+ //
119
+ // page an index of pages, 1, 2, 3 → step 1
120
+ // offset a count of ITEMS already seen → step size
121
+ //
122
+ // Getting that step wrong is the quiet kind of wrong: stepping an offset by 1 re-reads
123
+ // the same window minus one row and the walk returns mostly duplicates.
124
+ const got = Array.isArray(page) ? page.length : 0;
125
+ const size = Number(await evaluate(String(p.size ?? "return 0"), ctx as unknown as Record<string, unknown>)) || 0;
126
+ if (got === 0 || (size && got < size)) return { items, pages, truncated: false };
127
+ cursor = Number(cursor) + (p.strategy === "offset" ? (size || got) : 1);
128
+ } else {
129
+ cursor = await evaluate(p.cursor, { response: payload } as Record<string, unknown>);
130
+ // A falsy cursor is the vendor saying "that was the last page".
131
+ if (!cursor) return { items, pages, truncated: false };
132
+ }
133
+ if (items.length >= max) {
134
+ more = true;
135
+ break;
136
+ }
137
+ }
138
+
139
+ if (pages >= HARD_PAGE_CAP)
140
+ console.warn(`[manifests] ${label}: stopped at the ${HARD_PAGE_CAP}-page ceiling with a cursor still present`);
141
+
142
+ return { items, pages, truncated: more || pages >= HARD_PAGE_CAP };
143
+ }