@unoverse-platform/studio 0.1.97 → 0.1.98
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/bin/studio.mjs +2 -2
- package/local/catalog.ts +5 -1
- package/local/nodes.ts +28 -41
- package/local/plugin.ts +71 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +43 -23
- package/local/source.ts +3 -8
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityView.tsx +3 -3
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +2 -0
- package/src/App.tsx +8 -3
- package/src/host.ts +1 -14
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
- package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +29 -25
- package/vendor/base/src/items/publish.ts +30 -6
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +5 -0
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +80 -9
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +9 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +30 -11
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/local/sample-identity/brand/brand.yaml +0 -88
- package/local/sample-identity/brand/manifest.yaml +0 -13
- package/local/sample-identity/organisation/manifest.yaml +0 -15
- package/local/sample-identity/organisation/organisation.yaml +0 -53
- package/local/sample-identity/purpose/manifest.yaml +0 -12
- package/local/sample-identity/purpose/purpose.yaml +0 -46
- package/local/sample-identity/story/manifest.yaml +0 -12
- package/local/sample-identity/story/story.yaml +0 -63
- package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
|
@@ -188,22 +188,58 @@ export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, b
|
|
|
188
188
|
// on, and resetting first would clear the accumulator the row is about to read.
|
|
189
189
|
resetTurn(match);
|
|
190
190
|
},
|
|
191
|
+
/**
|
|
192
|
+
* Add to what EVERY row sees from now on, whatever it fires from.
|
|
193
|
+
*
|
|
194
|
+
* For state the caller accumulates over a run that no single event carries: a conversation
|
|
195
|
+
* held on a socket keeps what has been said so far, and a row that fires on one event (a
|
|
196
|
+
* hand-off) or at the end (the transcript) needs all of it.
|
|
197
|
+
*/
|
|
198
|
+
extend(scope: Record<string, unknown>) {
|
|
199
|
+
Object.assign(base, scope);
|
|
200
|
+
},
|
|
191
201
|
narrator: (line: string) => fire("narrator", { narrator: { line } }),
|
|
192
202
|
tool: (call: { name: string; args: unknown; output: unknown }) => fire("tool", { call }),
|
|
193
203
|
|
|
194
204
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
205
|
+
* SEND WHAT THE THROTTLE IS HOLDING, without touching the accumulator.
|
|
206
|
+
*
|
|
207
|
+
* A throttled row keeps the newest value as `pending` and waits for a LATER DELTA to
|
|
208
|
+
* carry it out — there is no tick, by design (a timer here would fire against a run
|
|
209
|
+
* that may already be over). That is correct while tokens keep arriving and wrong the
|
|
210
|
+
* moment they stop: an answer that finishes inside one throttle window leaves every
|
|
211
|
+
* word after the first sitting in `pending`, and the next thing to move it is whatever
|
|
212
|
+
* arrives next, however much later.
|
|
213
|
+
*
|
|
214
|
+
* Seen live (SAB Account Agent, 2026-09-08): the model said "I'm checking your account
|
|
215
|
+
* activity for food spending last month" in a few milliseconds, the reader saw "I", and
|
|
216
|
+
* the rest appeared eleven seconds afterwards — when a delta from the turn AFTER the
|
|
217
|
+
* tool call finally pushed it out. It read as the stream stopping for the tool. It was
|
|
218
|
+
* this, and the tool's duration was a coincidence.
|
|
219
|
+
*
|
|
220
|
+
* The CALLER decides when a turn ends, because only the loop knows: the manifest
|
|
221
|
+
* describes the wire, the loop counts the turns.
|
|
197
222
|
*/
|
|
198
|
-
|
|
223
|
+
flush() {
|
|
199
224
|
for (let i = 0; i < rows.length; i++) {
|
|
200
225
|
const s = state.get(i);
|
|
201
226
|
if (s?.pending === undefined) continue;
|
|
202
227
|
const emission = { ...destination(rows[i], s.to), value: s.pending };
|
|
203
228
|
s.pending = undefined;
|
|
229
|
+
// The clock too, or the first delta of the next turn is held by a window that
|
|
230
|
+
// started before the flush and the same stall repeats one turn later.
|
|
231
|
+
s.lastAt = 0;
|
|
204
232
|
emissions.push(emission);
|
|
205
233
|
onEmit(emission);
|
|
206
234
|
}
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Send anything a throttle held back, then run the `complete` rows over the whole run.
|
|
239
|
+
* Flushing first matters: `complete` is usually defined over what was emitted.
|
|
240
|
+
*/
|
|
241
|
+
async finish() {
|
|
242
|
+
this.flush();
|
|
207
243
|
await fire("complete", { events: emissions.slice() });
|
|
208
244
|
return emissions;
|
|
209
245
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `concurrency` ON A CALL: at most this many of the call in flight at once, in this process,
|
|
3
|
+
* per credential (DECLARATIVE_NODES.md 8a). Earned against Hyperbrowser on 2026-09-09: the
|
|
4
|
+
* plan runs one browser session at a time, three product reads started their fetches in the
|
|
5
|
+
* same second, and two came back 429. The retry list already had 429, and it did not help:
|
|
6
|
+
* an exponential backoff of a second or so is nothing beside a twenty-second scrape.
|
|
7
|
+
*
|
|
8
|
+
* A vendor's limit is the node's fact, declared once on the call, so every caller gets it: a
|
|
9
|
+
* canvas workflow fanning out over ten URLs as much as an ingestion read. The caller says
|
|
10
|
+
* nothing. Absent means unbounded, which is what every call was before this existed.
|
|
11
|
+
*
|
|
12
|
+
* Process-wide by design: the manifest runtime is the one place every request goes through,
|
|
13
|
+
* and a per-process gate is exactly the shape of the limit (sessions the process holds open).
|
|
14
|
+
* Two engines on two hosts are two limits, and that is the vendor's problem to reject, which
|
|
15
|
+
* the retry then absorbs.
|
|
16
|
+
*/
|
|
17
|
+
interface Gate {
|
|
18
|
+
active: number;
|
|
19
|
+
waiting: Array<() => void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const gates = new Map<string, Gate>();
|
|
23
|
+
|
|
24
|
+
/** Run `fn` when fewer than `limit` runs share `key`; otherwise wait in arrival order. */
|
|
25
|
+
export async function withConcurrency<T>(key: string, limit: number, fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
if (!(limit >= 1)) return fn();
|
|
27
|
+
let gate = gates.get(key);
|
|
28
|
+
if (!gate) { gate = { active: 0, waiting: [] }; gates.set(key, gate); }
|
|
29
|
+
if (gate.active >= limit) await new Promise<void>((resolve) => gate!.waiting.push(resolve));
|
|
30
|
+
gate.active += 1;
|
|
31
|
+
try {
|
|
32
|
+
return await fn();
|
|
33
|
+
} finally {
|
|
34
|
+
gate.active -= 1;
|
|
35
|
+
const next = gate.waiting.shift();
|
|
36
|
+
if (next) next();
|
|
37
|
+
else if (gate.active === 0) gates.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How many runs share `key` right now, for a test or a status line. */
|
|
42
|
+
export function inFlight(key: string): number {
|
|
43
|
+
return gates.get(key)?.active ?? 0;
|
|
44
|
+
}
|
|
@@ -14,9 +14,11 @@ import { mintClientCredentials, invalidateToken } from "../auth/oauth.js";
|
|
|
14
14
|
import { signAwsRequest, presignAwsUrl, encodeDynamoJson, decodeDynamoJson, type AwsSigning } from "../auth/aws.js";
|
|
15
15
|
import { fetchPaginated } from "../loops/paginate.js";
|
|
16
16
|
import { fetchPolled } from "../loops/poll.js";
|
|
17
|
+
import { runRepeat } from "../loops/repeat.js";
|
|
17
18
|
import { performLoop } from "../loops/loop.js";
|
|
18
19
|
import { performDoc } from "../docstore/index.js";
|
|
19
20
|
import { sendChunked } from "../loops/chunk.js";
|
|
21
|
+
import { withConcurrency } from "./concurrency.js";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Build the HTTP request from a call. Auth schemes are executor capabilities.
|
|
@@ -235,7 +237,7 @@ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
|
|
|
235
237
|
* A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
|
|
236
238
|
* so the manifest's own number appears in the log that reports it.
|
|
237
239
|
*/
|
|
238
|
-
async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
|
|
240
|
+
export async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
|
|
239
241
|
const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
|
|
240
242
|
if (!ms) return fetch(url, init);
|
|
241
243
|
|
|
@@ -592,7 +594,7 @@ export async function runCalls(
|
|
|
592
594
|
|
|
593
595
|
// A CHUNKED call is many requests over one collection: { sent, batches, results, errors }.
|
|
594
596
|
if (call.chunk) {
|
|
595
|
-
const written = await sendChunked(node, call, scoped, `${label}.${call.name}`);
|
|
597
|
+
const written = await gated(node, call, scoped, () => sendChunked(node, call, scoped, `${label}.${call.name}`));
|
|
596
598
|
results[call.name] = written;
|
|
597
599
|
last = written;
|
|
598
600
|
continue;
|
|
@@ -600,7 +602,7 @@ export async function runCalls(
|
|
|
600
602
|
|
|
601
603
|
// A PAGINATED call is many requests and one reply: { items, pages, truncated }.
|
|
602
604
|
if (call.paginate) {
|
|
603
|
-
const walked = await fetchPaginated(node, call, scoped, `${label}.${call.name}`);
|
|
605
|
+
const walked = await gated(node, call, scoped, () => fetchPaginated(node, call, scoped, `${label}.${call.name}`));
|
|
604
606
|
results[call.name] = walked;
|
|
605
607
|
last = walked;
|
|
606
608
|
continue;
|
|
@@ -609,12 +611,21 @@ export async function runCalls(
|
|
|
609
611
|
// A POLLED call is a JOB: start it, then ask until it is done. The reply is the final
|
|
610
612
|
// status payload, never the start receipt.
|
|
611
613
|
if (call.poll) {
|
|
612
|
-
const finished = await fetchPolled(node, call, scoped, `${label}.${call.name}`);
|
|
614
|
+
const finished = await gated(node, call, scoped, () => fetchPolled(node, call, scoped, `${label}.${call.name}`));
|
|
613
615
|
results[call.name] = finished;
|
|
614
616
|
last = finished;
|
|
615
617
|
continue;
|
|
616
618
|
}
|
|
617
619
|
|
|
620
|
+
// A REPEATED call is turns of ordinary calls, each built from the one before, until a reply
|
|
621
|
+
// says done: { last, turns, stopped } (loops/repeat.ts). Each inner call is gated on its own.
|
|
622
|
+
if (call.repeat) {
|
|
623
|
+
const walked = await runRepeat(node, call, scoped, `${label}.${call.name}`, runCalls, store);
|
|
624
|
+
results[call.name] = walked;
|
|
625
|
+
last = walked;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
618
629
|
// SETTLING transports. `xml` settles exactly like json — one body, parsed once — so it
|
|
619
630
|
// belongs here; only a STREAM is barred from a non-final position.
|
|
620
631
|
if (call.transport !== "json" && call.transport !== "text" && call.transport !== "xml" && call.transport !== "headers" && call.transport !== "binary") {
|
|
@@ -625,9 +636,12 @@ export async function runCalls(
|
|
|
625
636
|
);
|
|
626
637
|
}
|
|
627
638
|
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
639
|
+
const payload = await gated(node, call, scoped, async () => {
|
|
640
|
+
const res = await sendRequest(node, call, scoped, `${label}.${call.name}`);
|
|
641
|
+
const settled = await readSettled(res, call.transport, call.encoding);
|
|
642
|
+
await assertOk(node, call, settled, `${label}.${call.name}`);
|
|
643
|
+
return settled;
|
|
644
|
+
});
|
|
631
645
|
|
|
632
646
|
results[call.name] = payload;
|
|
633
647
|
last = payload;
|
|
@@ -635,3 +649,18 @@ export async function runCalls(
|
|
|
635
649
|
|
|
636
650
|
return { results, last };
|
|
637
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* A call that declares `concurrency` runs through one gate per node, call and credential
|
|
655
|
+
* (concurrency.ts): the whole call, its polling included, because the vendor's session is
|
|
656
|
+
* held until the job ends. The credential is part of the key so two orgs' keys are two
|
|
657
|
+
* limits, as the vendor counts them. Used here for the lead calls and in channels/final.ts
|
|
658
|
+
* for the last one: a one-call node like HyperbrowserScrape only ever runs through final,
|
|
659
|
+
* which is where the first live run slipped past the gate (2026-09-09, two fetches at once).
|
|
660
|
+
*/
|
|
661
|
+
export async function gated<T>(node: ComposedNode, call: any, scoped: RunContext, fn: () => Promise<T>): Promise<T> {
|
|
662
|
+
const limit = Number(call.concurrency);
|
|
663
|
+
if (!(limit >= 1)) return fn();
|
|
664
|
+
const credential = call.credential?.value ? String(render(call.credential.value, scoped)) : "";
|
|
665
|
+
return withConcurrency(`${node.type}:${call.name}:${credential}`, limit, fn);
|
|
666
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* service.ts an AGENT calls it: one method, one value handed straight back
|
|
26
26
|
* http/ building and sending. One fetch, one allowedHosts check
|
|
27
27
|
* auth/ the schemes, per scheme
|
|
28
|
-
* loops/ many requests, or many passes, for one node: paginate, poll, chunk, loop
|
|
28
|
+
* loops/ many requests, or many passes, for one node: paginate, poll, chunk, repeat, loop
|
|
29
29
|
* duplex/ a socket that stays open. Two of them: the vendor's and the audio lane
|
|
30
30
|
* tools/ the HTTP tool exchange, by turns
|
|
31
31
|
*/
|
|
@@ -39,6 +39,7 @@ export { readSse, readSettled, assertOk } from "./http/response.js";
|
|
|
39
39
|
export { makeStateStore, performState, type StateStore } from "./state.js";
|
|
40
40
|
export { fetchPaginated } from "./loops/paginate.js";
|
|
41
41
|
export { fetchPolled } from "./loops/poll.js";
|
|
42
|
+
export { runRepeat, HARD_TURN_CAP } from "./loops/repeat.js";
|
|
42
43
|
export { sendChunked } from "./loops/chunk.js";
|
|
43
44
|
// Iteration bookkeeping for the LoopStart/LoopEnd pair. ONE capability rather than the seven raw
|
|
44
45
|
// Redis commands the retired nodes reached for — see loops/loop.ts for why that distinction is
|
|
@@ -51,7 +52,7 @@ export { performDoc, MUTATING_DOC_OPS, docKeyFor } from "./docstore/index.js";
|
|
|
51
52
|
export { runDuplexSession } from "./duplex/session.js";
|
|
52
53
|
// The audio lane is its own module: session.ts owns the VENDOR socket, audioLane.ts owns the bytes
|
|
53
54
|
// and the browser. Two sockets, two files.
|
|
54
|
-
export { resamplePcm16, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
|
|
55
|
+
export { resamplePcm16, mulawToPcm16, pcm16ToMulaw, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
|
|
55
56
|
export { performApi, type RunResult } from "./channels/workflow.js";
|
|
56
57
|
export { runFinal } from "./channels/final.js";
|
|
57
58
|
export { performService } from "./channels/service.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GO ROUND IN TURNS — the fourth loop (packages/docs/nodes/calls-that-loop.md `repeat`;
|
|
3
|
+
* DECLARATIVE_NODES.md, the capability table).
|
|
4
|
+
*
|
|
5
|
+
* `paginate` walks one API's pages, `chunk` sends one collection in slices, `poll` asks one
|
|
6
|
+
* question until the answer changes. None can say "ask different questions, each chosen by the
|
|
7
|
+
* last reply, until a reply says done", which is what walking a tree a branch at a time needs
|
|
8
|
+
* (OpenAISearchDetail). A manifest describes ONE turn as a short list of ordinary calls; the
|
|
9
|
+
* turning is computation, so it lives here.
|
|
10
|
+
*
|
|
11
|
+
* What a turn sees: every earlier call of the SAME turn as `calls.<name>`, as at the top level,
|
|
12
|
+
* and the turn before as `previous.<name>`. `previous` is null on the first turn, never absent:
|
|
13
|
+
* the expression sandbox refuses a name it does not know, so "is there a turn before?" must be
|
|
14
|
+
* a question it can answer (`previous ? … : …`).
|
|
15
|
+
*
|
|
16
|
+
* What stops it, borrowed from the agent node's own tool loop (toolExchange.yaml):
|
|
17
|
+
* `until` asked after every turn over that turn's calls; true ends the walk
|
|
18
|
+
* `maxTurns` the author's bound
|
|
19
|
+
* `stuckAfterRepeats` a turn identical to the one before, that many times running, is stuck
|
|
20
|
+
* HARD_TURN_CAP the platform's bound, because the author's can be wrong
|
|
21
|
+
*
|
|
22
|
+
* The reply keeps EVERY turn, not only the last: the path taken is the lineage of the answer,
|
|
23
|
+
* and throwing it away would leave an answer nobody can check. Every turn is also drawn on the
|
|
24
|
+
* run's timeline with the same helper the tool loop uses, so the walk shows as it happens.
|
|
25
|
+
*/
|
|
26
|
+
import type { ComposedNode } from "../../compose.js";
|
|
27
|
+
import type { RunContext } from "../context.js";
|
|
28
|
+
import type { StateStore } from "../state.js";
|
|
29
|
+
import { evaluate, render } from "../templating.js";
|
|
30
|
+
import { recordToolTrace } from "../tools/toolloop.js";
|
|
31
|
+
|
|
32
|
+
/** The platform's ceiling on turns, whatever the manifest says. */
|
|
33
|
+
export const HARD_TURN_CAP = 20;
|
|
34
|
+
|
|
35
|
+
/** How a turn's calls are run: the runtime's own call list, handed in so this file owns no request code. */
|
|
36
|
+
export type RunList = (node: ComposedNode, calls: any[], ctx: RunContext, label: string, store?: StateStore) => Promise<{ results: Record<string, any>; last: any }>;
|
|
37
|
+
|
|
38
|
+
/** A repeated call's reply: the last turn, every turn in order, and why it ended. */
|
|
39
|
+
export interface Repeated {
|
|
40
|
+
last: Record<string, any>;
|
|
41
|
+
turns: Record<string, any>[];
|
|
42
|
+
stopped: "until" | "maxTurns" | "stuck";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A bound written as a number, a template (`{{ config.maxTurns }}`) or an expression. */
|
|
46
|
+
async function numberOf(v: unknown, ctx: RunContext): Promise<number> {
|
|
47
|
+
if (typeof v === "number") return v;
|
|
48
|
+
if (typeof v !== "string") return NaN;
|
|
49
|
+
const out = v.trim().startsWith("return") ? await evaluate(v, ctx as unknown as Record<string, unknown>) : render(v, ctx);
|
|
50
|
+
return Number(out);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runRepeat(node: ComposedNode, call: any, ctx: RunContext, label: string, run: RunList, store?: StateStore): Promise<Repeated> {
|
|
54
|
+
const r = call.repeat;
|
|
55
|
+
// NEVER A REPEAT INSIDE A TURN. Each is bounded, but nested they multiply (20 turns of 20),
|
|
56
|
+
// and a walk that needs a walk inside it is two nodes, not one. Lint refuses it too.
|
|
57
|
+
if (!Array.isArray(r?.calls) || !r.calls.length) throw new Error(`${label}: repeat has no calls, so a turn would do nothing`);
|
|
58
|
+
if (r.calls.some((c: any) => c?.repeat)) throw new Error(`${label}: a turn cannot itself repeat; nested repeats multiply their bounds`);
|
|
59
|
+
const asked = await numberOf(r.maxTurns, ctx);
|
|
60
|
+
const bound = Math.min(HARD_TURN_CAP, asked >= 1 ? Math.floor(asked) : HARD_TURN_CAP);
|
|
61
|
+
const stuckAt = Number(r.stuckAfterRepeats) >= 2 ? Math.floor(Number(r.stuckAfterRepeats)) : 0;
|
|
62
|
+
|
|
63
|
+
const turns: Record<string, any>[] = [];
|
|
64
|
+
let previous: Record<string, any> | null = null;
|
|
65
|
+
let same = 0;
|
|
66
|
+
let lastKey = "";
|
|
67
|
+
for (let n = 1; n <= bound; n++) {
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
const turnCtx: RunContext = { ...ctx, previous };
|
|
70
|
+
const { results } = await run(node, r.calls, turnCtx, `${label} (turn ${n})`, store);
|
|
71
|
+
turns.push(results);
|
|
72
|
+
recordToolTrace(node, ctx, `${call.name} · turn ${n}`, { turn: n }, results, started, true);
|
|
73
|
+
|
|
74
|
+
if (await evaluate(r.until, { ...turnCtx, calls: results } as unknown as Record<string, unknown>)) return { last: results, turns, stopped: "until" };
|
|
75
|
+
|
|
76
|
+
// STUCK: the same turn again. `maxTurns` alone would let a walk that keeps choosing the
|
|
77
|
+
// same branch spend every turn it has doing so.
|
|
78
|
+
const key = JSON.stringify(results);
|
|
79
|
+
same = key === lastKey ? same + 1 : 1;
|
|
80
|
+
lastKey = key;
|
|
81
|
+
if (stuckAt && same >= stuckAt) return { last: results, turns, stopped: "stuck" };
|
|
82
|
+
|
|
83
|
+
previous = results;
|
|
84
|
+
}
|
|
85
|
+
return { last: turns[turns.length - 1] ?? {}, turns, stopped: "maxTurns" };
|
|
86
|
+
}
|
|
@@ -47,6 +47,12 @@ export interface ToolBridge {
|
|
|
47
47
|
): Promise<{ content: string; minted: any[]; skills?: Array<{ name: string; title?: string; instructions: string; regionId?: string; regionName?: string | null; ms?: number }> }>;
|
|
48
48
|
/** Does this exchange end the turn? Depends on which tools were WIRED. */
|
|
49
49
|
endsTurn(calls: { name: string; resultContent: string }[]): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* WHAT SORT OF STEP a call to this tool is on the timeline. The bridge minted the tool,
|
|
52
|
+
* so the bridge knows whether it came from a task row; the loop only relays the answer
|
|
53
|
+
* to the trace. Optional, because a core-tools-only bridge has nothing but tools.
|
|
54
|
+
*/
|
|
55
|
+
kindOf?(toolName: string): "tool" | "task";
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -92,7 +98,7 @@ export function recordToolTrace(
|
|
|
92
98
|
* on the same timeline but is not a tool the model chose, so it is told apart by a stated
|
|
93
99
|
* kind rather than by the viewer sniffing its name.
|
|
94
100
|
*/
|
|
95
|
-
kind: "tool" | "skill" = "tool",
|
|
101
|
+
kind: "tool" | "skill" | "task" = "tool",
|
|
96
102
|
): void {
|
|
97
103
|
const executionId = (ctx as any)?.scope?.executionId;
|
|
98
104
|
const parentNodeId = (ctx as any)?.scope?.nodeId;
|
|
@@ -108,12 +114,20 @@ export function recordToolTrace(
|
|
|
108
114
|
*/
|
|
109
115
|
const parsed = parseMaybeJson(result);
|
|
110
116
|
|
|
117
|
+
/**
|
|
118
|
+
* THE ARGUMENTS AS RUN. A tool whose own settings add to what the model sent (Spatial's
|
|
119
|
+
* always-on objects) says so in its result as `arguments`, and the trace shows those, so
|
|
120
|
+
* Arguments is what the call really ran with, never only what the model typed.
|
|
121
|
+
*/
|
|
122
|
+
const ran = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as any).arguments : null;
|
|
123
|
+
const shown = ran && typeof ran === "object" && args && typeof args === "object" ? { ...(args as object), ...ran } : args;
|
|
124
|
+
|
|
111
125
|
const endTime = Date.now();
|
|
112
126
|
void saveMCPTraceToWorkflow({
|
|
113
127
|
executionId,
|
|
114
128
|
parentNodeId,
|
|
115
129
|
toolName,
|
|
116
|
-
arguments:
|
|
130
|
+
arguments: shown,
|
|
117
131
|
result: parsed,
|
|
118
132
|
startTime,
|
|
119
133
|
endTime,
|
|
@@ -280,6 +294,19 @@ export async function runToolLoop(
|
|
|
280
294
|
});
|
|
281
295
|
if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
|
|
282
296
|
|
|
297
|
+
// THE TURN IS OVER, SO NOTHING MORE IS COMING TO CARRY A THROTTLE OUT.
|
|
298
|
+
//
|
|
299
|
+
// A throttled row holds its newest value and waits for a LATER DELTA to send it. Within
|
|
300
|
+
// a turn that is right. At the end of one it strands whatever arrived in the last
|
|
301
|
+
// window — and if the model answered inside a single window, that is the whole answer
|
|
302
|
+
// but its first token. It then surfaces on the next turn's first delta, which is after
|
|
303
|
+
// any tool this turn called: the reader saw "I", then the rest eleven seconds later,
|
|
304
|
+
// and it read as the stream stopping for the tool (SAB, 2026-09-08).
|
|
305
|
+
//
|
|
306
|
+
// Here rather than in the manifest, and by the LOOP rather than a timer: only the loop
|
|
307
|
+
// knows where a turn ends, which is the same reason it owns counting them.
|
|
308
|
+
emitter.flush();
|
|
309
|
+
|
|
283
310
|
const calls = await readCalls(te, raw);
|
|
284
311
|
|
|
285
312
|
// Answered rather than called. Done.
|
|
@@ -334,7 +361,7 @@ export async function runToolLoop(
|
|
|
334
361
|
ok = false;
|
|
335
362
|
content = JSON.stringify({ error: err?.message ?? String(err) });
|
|
336
363
|
}
|
|
337
|
-
recordToolTrace(node, ctx, call.name, args, content, startedAt, ok);
|
|
364
|
+
recordToolTrace(node, ctx, call.name, args, content, startedAt, ok, bridge.kindOf?.(call.name) ?? "tool");
|
|
338
365
|
exchanges.push({ name: call.name, resultContent: content });
|
|
339
366
|
// The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
|
|
340
367
|
// produced it, which is why it cannot be a `from: response` row. PARSED for the
|
|
@@ -32,6 +32,12 @@ export function sniffUsage(payload: any): Record<string, unknown> | undefined {
|
|
|
32
32
|
export interface UsageCollector {
|
|
33
33
|
/** Look at one payload (streamed event or settled body); remember any usage block. */
|
|
34
34
|
see(payload: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* Record a figure the PLATFORM measured, replacing whatever the vendor reported under the same
|
|
37
|
+
* name. A voice call's `seconds` is this: the runtime opened and closed the call, so its clock is
|
|
38
|
+
* the length. A vendor that reports running totals (GPT-Live) would otherwise be summed.
|
|
39
|
+
*/
|
|
40
|
+
set(field: string, value: number): void;
|
|
35
41
|
/** Post the summed usage to the engine. No usage seen, or no execution scope → no-op. */
|
|
36
42
|
save(): void;
|
|
37
43
|
}
|
|
@@ -68,6 +74,10 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
|
|
|
68
74
|
addInto(totals, u);
|
|
69
75
|
model ??= payload?.response?.model ?? payload?.model ?? undefined;
|
|
70
76
|
},
|
|
77
|
+
set(field: string, value: number) {
|
|
78
|
+
seen = true;
|
|
79
|
+
totals[field] = value;
|
|
80
|
+
},
|
|
71
81
|
save() {
|
|
72
82
|
if (!seen) return;
|
|
73
83
|
const { workflowId, executionId, nodeId } = ctx.scope ?? {};
|
package/vendor/base/src/paths.ts
CHANGED
|
@@ -27,10 +27,11 @@ function findHome(): string {
|
|
|
27
27
|
|
|
28
28
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
for (;;) {
|
|
30
|
-
// The in-repo home. Checked for `
|
|
31
|
-
// directory of the same name elsewhere cannot claim it.
|
|
30
|
+
// The in-repo home. Checked for `design` as well as the folder itself, so a stray
|
|
31
|
+
// directory of the same name elsewhere cannot claim it. (`nodes` was the marker until
|
|
32
|
+
// 2026-09-08, when nodes moved under design/marketplace with every other kind.)
|
|
32
33
|
const candidate = path.join(dir, "apps/unoverse");
|
|
33
|
-
if (existsSync(path.join(candidate, "
|
|
34
|
+
if (existsSync(path.join(candidate, "design"))) return candidate;
|
|
34
35
|
const parent = path.dirname(dir);
|
|
35
36
|
if (parent === dir) break;
|
|
36
37
|
dir = parent;
|
|
@@ -41,7 +42,7 @@ function findHome(): string {
|
|
|
41
42
|
// a path under cwd, and anything reading them will fail somewhere obvious.
|
|
42
43
|
console.warn(
|
|
43
44
|
"[unoverse:base] UNOVERSE_HOME is not set and no apps/unoverse was found above this package. " +
|
|
44
|
-
"Falling back to the working directory; set UNOVERSE_HOME to the folder holding
|
|
45
|
+
"Falling back to the working directory; set UNOVERSE_HOME to the folder holding design/.",
|
|
45
46
|
);
|
|
46
47
|
return process.cwd();
|
|
47
48
|
}
|
|
@@ -50,7 +51,7 @@ let HOME = findHome();
|
|
|
50
51
|
|
|
51
52
|
/** Everything the runtime reads off disk, in one place. */
|
|
52
53
|
export interface Paths {
|
|
53
|
-
/** Node
|
|
54
|
+
/** Node packages: the marketplace tier's nodes, under design/marketplace/nodes. */
|
|
54
55
|
nodes: string;
|
|
55
56
|
/** Design data: components, templates, styles. */
|
|
56
57
|
design: string;
|
|
@@ -96,10 +97,17 @@ export function designDir(root: string): string {
|
|
|
96
97
|
|
|
97
98
|
function derive(home: string): Paths {
|
|
98
99
|
return {
|
|
99
|
-
nodes: path.join(home, "nodes"),
|
|
100
100
|
design: designDir(home),
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// THE MARKETPLACE TIER, and it lives under design/ with every other kind.
|
|
102
|
+
//
|
|
103
|
+
// Skills and blocks were authored in a `prompts/` folder beside design/, left from
|
|
104
|
+
// before components, templates, atoms and styles took their marketplace home under
|
|
105
|
+
// `design/marketplace/`. Two roots for one tier is what let a surface read half of
|
|
106
|
+
// it and look right. There is one authoring root now: `design/`. Nodes were the
|
|
107
|
+
// last kind outside it, at `nodes/` beside design/, until 2026-09-08.
|
|
108
|
+
nodes: path.join(designDir(home), "marketplace/nodes"),
|
|
109
|
+
skills: path.join(designDir(home), "marketplace/skills"),
|
|
110
|
+
promptBlocks: path.join(designDir(home), "marketplace/blocks"),
|
|
103
111
|
plugins: path.join(home, "plugins"),
|
|
104
112
|
// DELIBERATELY under the home, not at the repo root.
|
|
105
113
|
//
|
|
@@ -89,6 +89,38 @@ class DistributedAudioManager implements AudioWebSocketManager {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* NAMED AUDIO LANES, beside the browser one.
|
|
94
|
+
*
|
|
95
|
+
* The browser manager above is fetched with no arguments, because for a long time it was the
|
|
96
|
+
* only lane. A phone lane has to PLACE A CALL, so it is built per run from that run's settings
|
|
97
|
+
* and credentials, which is why this is a factory rather than an instance.
|
|
98
|
+
*
|
|
99
|
+
* IT LIVES HERE, in base, for the reason the comment on `getAudioWebSocketManager` in
|
|
100
|
+
* executionContext.ts already gives: a node reaches the platform through two different context
|
|
101
|
+
* builders — the engine's plugin API in-process, and the remote one used by
|
|
102
|
+
* `POST /execute-stream` — and BOTH must arrive at the same lane. Registering it on only one of
|
|
103
|
+
* them is a voice node that finds its phone line locally and finds nothing remotely, which is
|
|
104
|
+
* exactly what happened (2026-09-20): no error, no audio, and a perfectly healthy conversation
|
|
105
|
+
* nobody could hear.
|
|
106
|
+
*/
|
|
107
|
+
let audioLaneFactory: ((spec: any) => any) | null = null;
|
|
108
|
+
|
|
109
|
+
export function setAudioLaneFactory(factory: (spec: any) => any): void {
|
|
110
|
+
audioLaneFactory = factory;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A lane by name, built for this run. `null` when the platform has no such lane. */
|
|
114
|
+
export function getAudioLane(spec: any): any {
|
|
115
|
+
if (!audioLaneFactory) return null;
|
|
116
|
+
try {
|
|
117
|
+
return audioLaneFactory(spec);
|
|
118
|
+
} catch (error: any) {
|
|
119
|
+
console.error(`[audio] lane "${spec?.lane}" could not be built: ${error?.message ?? error}`);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
// Singleton instance
|
|
93
125
|
let distributedAudioManager: DistributedAudioManager | null = null;
|
|
94
126
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
deliverToNodeInWorkflow,
|
|
12
12
|
} from "./serviceCalls.js";
|
|
13
13
|
import { getRedisClient } from "./redis.js";
|
|
14
|
+
import { getAudioLane } from "./audioManager.js";
|
|
14
15
|
import { getDistributedAudioManager } from "./audioManager.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -49,6 +50,12 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
|
|
|
49
50
|
abortSignal: context.abortSignal,
|
|
50
51
|
logger: console,
|
|
51
52
|
auth: context.auth,
|
|
53
|
+
/**
|
|
54
|
+
* A CANVAS TEST RUN. `debugMode` is the word that crosses the remote boundary
|
|
55
|
+
* (CallbackNodeActor's context whitelist); everything that withholds an effect with no
|
|
56
|
+
* inverse reads it. Dropping it here made a phone lane think every run was real.
|
|
57
|
+
*/
|
|
58
|
+
debugMode: context.debugMode === true,
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
// Build API with callService that routes to workflow-service
|
|
@@ -97,6 +104,13 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
|
|
|
97
104
|
* Same accessor `pluginAPI.ts` uses, so both paths reach one manager rather than two.
|
|
98
105
|
*/
|
|
99
106
|
getAudioWebSocketManager: () => getDistributedAudioManager(),
|
|
107
|
+
/**
|
|
108
|
+
* A lane BY NAME (DECLARATIVE_NODES.md §14, VOICE_STREAMING_GUIDE.md). Same registry the
|
|
109
|
+
* plugin API reads, for the same reason as the line above: a voice node runs through
|
|
110
|
+
* whichever of these two context builders its host chose, and a lane registered on only one
|
|
111
|
+
* of them is a phone that works in one place and silently does not in the other.
|
|
112
|
+
*/
|
|
113
|
+
getAudioLane: (spec: any) => getAudioLane(spec),
|
|
100
114
|
callService: async (method: string, params: any) => {
|
|
101
115
|
return callServiceViaWorkflow(method, params, executionContext);
|
|
102
116
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE INPUT: a value arriving on a node's `CONTINUE` input while that node is still running.
|
|
3
|
+
*
|
|
4
|
+
* A node that holds a conversation open (a duplex voice session) cannot be started again for each
|
|
5
|
+
* input the way a settle-once node can, because starting it again would open a second
|
|
6
|
+
* conversation. So the running node registers here, and the engine hands a changed `CONTINUE`
|
|
7
|
+
* input to it instead of dropping it.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by `executionId:nodeId`, the two ids the engine and the running node both hold for the
|
|
10
|
+
* same run.
|
|
11
|
+
*
|
|
12
|
+
* ON globalThis, not a module-level Map. The engine and the node runtime share one process but
|
|
13
|
+
* may load this module under two specifiers (the package name, a relative path), and two copies
|
|
14
|
+
* of a Map would each be empty to the other: the engine would find no running node and drop the
|
|
15
|
+
* input exactly as before, with nothing to say why.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Hand one input to the running node. Resolves false when the node could not take it. */
|
|
19
|
+
export type LiveInputHandler = (handle: string, value: unknown) => Promise<boolean> | boolean;
|
|
20
|
+
|
|
21
|
+
const REGISTRY = Symbol.for("unoverse.liveInput");
|
|
22
|
+
|
|
23
|
+
function registry(): Map<string, LiveInputHandler> {
|
|
24
|
+
const g = globalThis as any;
|
|
25
|
+
return (g[REGISTRY] ??= new Map<string, LiveInputHandler>());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The key for one node in one run. Undefined when either id is missing: nothing to address. */
|
|
29
|
+
export function liveInputKey(executionId?: string, nodeId?: string): string | undefined {
|
|
30
|
+
return executionId && nodeId ? `${executionId}:${nodeId}` : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register the running node. Returns the unregister function.
|
|
35
|
+
*
|
|
36
|
+
* Unregistering removes only THIS handler, so a node that ended after a newer one registered
|
|
37
|
+
* under the same key cannot take the newer one with it.
|
|
38
|
+
*/
|
|
39
|
+
export function registerLiveInput(key: string, handler: LiveInputHandler): () => void {
|
|
40
|
+
const map = registry();
|
|
41
|
+
map.set(key, handler);
|
|
42
|
+
return () => {
|
|
43
|
+
if (map.get(key) === handler) map.delete(key);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deliver to the running node. False when nothing is registered under the key. */
|
|
48
|
+
export async function deliverLiveInput(key: string, handle: string, value: unknown): Promise<boolean> {
|
|
49
|
+
const handler = registry().get(key);
|
|
50
|
+
if (!handler) return false;
|
|
51
|
+
return (await handler(handle, value)) !== false;
|
|
52
|
+
}
|
|
@@ -39,7 +39,7 @@ function scanPluginDir(dir: string, scopes: string[]): string[] {
|
|
|
39
39
|
return plugins;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/** The marketplace tier's node packages (apps/unoverse/design/marketplace/nodes). */
|
|
43
43
|
export function discoverNodesHome(): string[] {
|
|
44
44
|
return scanPluginDir(NODES_HOME, ["@unoverse-platform/"]);
|
|
45
45
|
}
|
|
@@ -521,6 +521,12 @@ const SUPPORTED = new Set([
|
|
|
521
521
|
"BlockStatement", "CallExpression", "ConditionalExpression", "EmptyStatement",
|
|
522
522
|
"ExpressionStatement", "Identifier", "IfStatement", "Literal", "LogicalExpression",
|
|
523
523
|
"MemberExpression", "ObjectExpression", "Program", "Property", "ReturnStatement",
|
|
524
|
+
// `...x` in an object or an array. evalNode implements BOTH (ObjectExpression assigns the
|
|
525
|
+
// spread in, ArrayExpression pushes it), so leaving it out of this list made the static
|
|
526
|
+
// check refuse a form the interpreter runs — the exact inversion of what this list is for.
|
|
527
|
+
// It is only reached at run time by makeHelpers, so the three manifests that spread were
|
|
528
|
+
// never actually throwing; they were failing the guard that is supposed to mirror evalNode.
|
|
529
|
+
"SpreadElement",
|
|
524
530
|
"TemplateLiteral", "TemplateElement", "UnaryExpression", "VariableDeclaration",
|
|
525
531
|
"VariableDeclarator",
|
|
526
532
|
]);
|