mixdog 0.9.151 → 0.9.152
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/README.md +110 -68
- package/package.json +3 -2
- package/scripts/tool-stress.mjs +0 -2
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/runtime/agent/orchestrator/config.mjs +6 -66
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/browser-bridge/client.mjs +103 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
- package/src/runtime/computer-bridge/client.mjs +101 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-surface.mjs +18 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/goal-runtime.mjs +721 -0
- package/src/session-runtime/goal-runtime.test.mjs +108 -0
- package/src/session-runtime/lifecycle-api.mjs +18 -4
- package/src/session-runtime/lifecycle-api.test.mjs +21 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/resource-api-global.test.mjs +69 -0
- package/src/session-runtime/resource-api.mjs +173 -58
- package/src/session-runtime/runtime-core.mjs +148 -20
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-turn-api.mjs +22 -11
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +4 -13
- package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
- package/src/session-runtime/tool-surface.mjs +12 -0
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/notify.mjs +6 -8
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/daemon.mjs +31 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +696 -58
- package/src/standalone/session-runtime-inline-host.mjs +351 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +476 -18
- package/src/standalone/session-service.mjs +81 -6
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +163 -44
- package/src/tui/session/context-state.mjs +1 -0
- package/src/tui/session/goal-continuation.mjs +110 -0
- package/src/tui/session/goal-continuation.test.mjs +84 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +108 -0
- package/src/tui/session/session-api-ext.mjs +39 -8
- package/src/tui/session/session-api.mjs +58 -0
- package/src/tui/session/session-flow.mjs +6 -0
- package/src/tui/session/turn.mjs +1 -0
- package/src/tui/session-local.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { availableParallelism } from 'node:os';
|
|
2
2
|
import { Worker } from 'node:worker_threads';
|
|
3
3
|
import { currentProviderAdmissionOwner } from './admission-scheduler.mjs';
|
|
4
|
+
import { frameAndParseSse } from './lib/sse-framing.mjs';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_MIN_BATCH_BYTES = 32 * 1024;
|
|
7
|
+
// Soft cap on IDLE owner-affinity metadata. Owners with in-flight work are
|
|
8
|
+
// never evicted, so the live-stream count (not this number) bounds the map.
|
|
9
|
+
const MAX_IDLE_OWNER_AFFINITIES = 4096;
|
|
10
|
+
// After this many consecutive worker-construction failures the pool stops
|
|
11
|
+
// trying to spawn and stays inline. This is a failure latch, not a throttle:
|
|
12
|
+
// it never limits concurrent streams, it only stops re-throwing constructors.
|
|
13
|
+
const MAX_SPAWN_FAILURES = 3;
|
|
6
14
|
|
|
7
15
|
function positiveInt(value, fallback) {
|
|
8
16
|
const parsed = Math.floor(Number(value));
|
|
@@ -16,6 +24,10 @@ function configuredWorkerCount(env = process.env) {
|
|
|
16
24
|
return Math.max(1, Math.min(4, availableParallelism() - 1));
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
function normalizeOwner(ownerKey) {
|
|
28
|
+
return String(ownerKey || '').trim().slice(0, 240);
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
function abortError(signal) {
|
|
20
32
|
return signal?.reason instanceof Error
|
|
21
33
|
? signal.reason
|
|
@@ -29,12 +41,18 @@ function syntaxError(details) {
|
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/**
|
|
32
|
-
* Reusable worker pool for the CPU part of provider
|
|
44
|
+
* Reusable worker pool for the CPU part of provider stream handling.
|
|
45
|
+
*
|
|
46
|
+
* Two units of work share it:
|
|
47
|
+
* - `parseBatch(payloads)` — a batch of JSON payloads (Gemini/OpenAI).
|
|
48
|
+
* - `frameSse(chunk)` — a whole SSE network chunk: line framing plus
|
|
49
|
+
* per-record JSON parsing, so a chunk never costs one task (or one
|
|
50
|
+
* microtask) per event on the shared event loop.
|
|
33
51
|
*
|
|
34
|
-
* Small
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
52
|
+
* Small work stays inline because a Worker round-trip costs more than doing
|
|
53
|
+
* it; large chunks are framed/parsed off the daemon event loop. Calls are
|
|
54
|
+
* never admission-capped: every request is posted immediately and workers
|
|
55
|
+
* consume their independent message queues.
|
|
38
56
|
*/
|
|
39
57
|
export function createStreamJsonPool({
|
|
40
58
|
maxWorkers = configuredWorkerCount(),
|
|
@@ -42,22 +60,45 @@ export function createStreamJsonPool({
|
|
|
42
60
|
|| DEFAULT_MIN_BATCH_BYTES,
|
|
43
61
|
maxPendingBytes = (Number(process.env.MIXDOG_PROVIDER_STREAM_PENDING_MB) || 32)
|
|
44
62
|
* 1024 * 1024,
|
|
63
|
+
maxIdleOwnerAffinities = MAX_IDLE_OWNER_AFFINITIES,
|
|
45
64
|
WorkerImpl = Worker,
|
|
46
65
|
} = {}) {
|
|
47
66
|
const workerMax = Math.max(0, Math.floor(Number(maxWorkers) || 0));
|
|
48
67
|
const inlineBelowBytes = Math.max(0, Math.floor(Number(minBatchBytes) || 0));
|
|
68
|
+
const idleAffinityMax = positiveInt(maxIdleOwnerAffinities, MAX_IDLE_OWNER_AFFINITIES);
|
|
49
69
|
const slots = [];
|
|
70
|
+
// owner -> { slot, active }. `active` counts the in-flight tasks holding
|
|
71
|
+
// this affinity; an owner with work in flight is NEVER evicted, because
|
|
72
|
+
// moving a live stream to another worker is exactly what reorders it.
|
|
50
73
|
const ownerAffinities = new Map();
|
|
74
|
+
// Per-stream FIFO tail: streamKey -> { tail, pending }. One stream can mix
|
|
75
|
+
// routes (inline below the threshold, offloaded above it, inline again
|
|
76
|
+
// after backpressure or a lost worker); chaining every submission on its
|
|
77
|
+
// stream tail keeps chunk N+1 settling after chunk N no matter which route
|
|
78
|
+
// each took. Entries are refcounted and self-delete at pending === 0, so
|
|
79
|
+
// only idle metadata is ever dropped: an ACTIVE stream can never lose its
|
|
80
|
+
// ordering state to a capacity bound (the live-stream count bounds it).
|
|
81
|
+
const streamTails = new Map();
|
|
82
|
+
// Explicit transport-lifetime holds. A stream can be active while waiting
|
|
83
|
+
// for its next network chunk, when no pool task exists to carry the normal
|
|
84
|
+
// per-submission hold. The transport retains once and releases in finally.
|
|
85
|
+
const retainedStreamAffinities = new Map();
|
|
51
86
|
const waiting = [];
|
|
52
87
|
const pendingByteMax = Math.max(1024 * 1024, Math.floor(Number(maxPendingBytes) || 0));
|
|
53
88
|
let pendingBytes = 0;
|
|
54
89
|
let waitingBytes = 0;
|
|
55
90
|
let sequence = 0;
|
|
91
|
+
let spawnFailures = 0;
|
|
56
92
|
let closed = false;
|
|
57
93
|
const stats = {
|
|
58
94
|
inlineBatches: 0,
|
|
59
95
|
offloadedBatches: 0,
|
|
60
96
|
fallbackBatches: 0,
|
|
97
|
+
inlineChunks: 0,
|
|
98
|
+
offloadedChunks: 0,
|
|
99
|
+
framedEvents: 0,
|
|
100
|
+
peakOrderedStreams: 0,
|
|
101
|
+
spawnFailures: 0,
|
|
61
102
|
parsedBytes: 0,
|
|
62
103
|
};
|
|
63
104
|
|
|
@@ -65,11 +106,146 @@ export function createStreamJsonPool({
|
|
|
65
106
|
return payloads.map((payload) => JSON.parse(payload));
|
|
66
107
|
}
|
|
67
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Re-run a task's work on the owner thread. Every task kind carries an
|
|
111
|
+
* `inline()` that is the exact equivalent of the worker computation, so a
|
|
112
|
+
* dead worker, a failed postMessage or a closing pool degrades to local
|
|
113
|
+
* CPU instead of failing a live stream.
|
|
114
|
+
*/
|
|
115
|
+
function settleInline(task) {
|
|
116
|
+
stats.fallbackBatches += 1;
|
|
117
|
+
try { task.resolve(task.inline()); }
|
|
118
|
+
catch (error) { task.reject(error); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Serialize one stream's submissions. Returns the raw (possibly
|
|
123
|
+
* synchronous) result when the stream has nothing in flight, so the common
|
|
124
|
+
* inline path stays free of promise/microtask overhead.
|
|
125
|
+
*/
|
|
126
|
+
function withStreamOrder(streamKey, run) {
|
|
127
|
+
if (!streamKey) return run();
|
|
128
|
+
let entry = streamTails.get(streamKey) || null;
|
|
129
|
+
const started = entry ? entry.tail.then(run, run) : run();
|
|
130
|
+
if (!started || typeof started.then !== 'function') {
|
|
131
|
+
// Nothing was in flight for this stream and the work completed
|
|
132
|
+
// synchronously: there is no ordering state to retain.
|
|
133
|
+
return started;
|
|
134
|
+
}
|
|
135
|
+
const settled = started.then(() => {}, () => {});
|
|
136
|
+
if (entry) {
|
|
137
|
+
entry.tail = settled;
|
|
138
|
+
entry.pending += 1;
|
|
139
|
+
} else {
|
|
140
|
+
entry = { tail: settled, pending: 1 };
|
|
141
|
+
streamTails.set(streamKey, entry);
|
|
142
|
+
}
|
|
143
|
+
if (streamTails.size > stats.peakOrderedStreams) {
|
|
144
|
+
stats.peakOrderedStreams = streamTails.size;
|
|
145
|
+
}
|
|
146
|
+
const owned = entry;
|
|
147
|
+
settled.then(() => {
|
|
148
|
+
if (streamTails.get(streamKey) !== owned) return;
|
|
149
|
+
owned.pending = Math.max(0, owned.pending - 1);
|
|
150
|
+
// A stream releases ONLY its own slot, and only once nothing of
|
|
151
|
+
// that stream is in flight. There is no cross-stream eviction, so
|
|
152
|
+
// an active stream can never lose the tail that orders its chunks.
|
|
153
|
+
if (owned.pending === 0) streamTails.delete(streamKey);
|
|
154
|
+
});
|
|
155
|
+
return started;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Affinity refcount.
|
|
160
|
+
*
|
|
161
|
+
* A hold is taken when a submission ENTERS the pool — before it waits
|
|
162
|
+
* behind its stream's FIFO tail, before it is queued for backpressure and
|
|
163
|
+
* before any worker owns it — and released when that submission settles.
|
|
164
|
+
* An owner with queued OR in-flight work therefore has `active > 0` for
|
|
165
|
+
* the whole window, so neither pruning nor a drainWaiting() burst can move
|
|
166
|
+
* a live stream to a different worker between its chunks.
|
|
167
|
+
*/
|
|
168
|
+
function acquireAffinity(owner) {
|
|
169
|
+
if (!owner) return null;
|
|
170
|
+
let entry = ownerAffinities.get(owner);
|
|
171
|
+
if (!entry) {
|
|
172
|
+
pruneOwnerAffinities();
|
|
173
|
+
entry = { slot: null, active: 0 };
|
|
174
|
+
ownerAffinities.set(owner, entry);
|
|
175
|
+
}
|
|
176
|
+
entry.active += 1;
|
|
177
|
+
return entry;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function releaseAffinity(entry) {
|
|
181
|
+
if (!entry) return;
|
|
182
|
+
entry.active = Math.max(0, entry.active - 1);
|
|
183
|
+
// Settlement is itself a pruning opportunity: metadata that just went
|
|
184
|
+
// idle is reclaimed here, so a finished fan-out burst does not leave
|
|
185
|
+
// the map above its cap until some unrelated owner happens to arrive.
|
|
186
|
+
if (entry.active === 0) pruneOwnerAffinities();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function releaseAffinityWhenSettled(entry, result) {
|
|
190
|
+
if (!entry) return result;
|
|
191
|
+
if (!result || typeof result.then !== 'function') {
|
|
192
|
+
releaseAffinity(entry);
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
result.then(() => releaseAffinity(entry), () => releaseAffinity(entry));
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Run one submission under an affinity hold spanning its whole lifetime. */
|
|
200
|
+
function underAffinityHold(owner, needsHold, produce) {
|
|
201
|
+
const entry = needsHold ? acquireAffinity(owner) : null;
|
|
202
|
+
if (!entry) return produce();
|
|
203
|
+
let result;
|
|
204
|
+
try { result = produce(); }
|
|
205
|
+
catch (error) { releaseAffinity(entry); throw error; }
|
|
206
|
+
return releaseAffinityWhenSettled(entry, result);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Drop only IDLE affinity entries when the soft cap is reached. */
|
|
210
|
+
function pruneOwnerAffinities() {
|
|
211
|
+
if (ownerAffinities.size < idleAffinityMax) return;
|
|
212
|
+
for (const [owner, entry] of ownerAffinities) {
|
|
213
|
+
if (entry.active > 0) continue;
|
|
214
|
+
ownerAffinities.delete(owner);
|
|
215
|
+
if (ownerAffinities.size < idleAffinityMax) return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function retainStream(streamKey, ownerKey = currentProviderAdmissionOwner()) {
|
|
220
|
+
if (!streamKey) return false;
|
|
221
|
+
const key = String(streamKey).slice(0, 240);
|
|
222
|
+
if (!key || retainedStreamAffinities.has(key)) return !!key;
|
|
223
|
+
const entry = acquireAffinity(normalizeOwner(ownerKey) || key);
|
|
224
|
+
if (!entry) return false;
|
|
225
|
+
retainedStreamAffinities.set(key, entry);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A worker keeps the event loop alive only while it owes an answer: idle
|
|
231
|
+
* workers stay unref'd (no process is held open by the pool), busy workers
|
|
232
|
+
* are ref'd (an in-flight chunk can never be lost to an early exit).
|
|
233
|
+
*/
|
|
234
|
+
function syncSlotRef(slot) {
|
|
235
|
+
try {
|
|
236
|
+
if (slot.tasks.size > 0) slot.worker.ref?.();
|
|
237
|
+
else slot.worker.unref?.();
|
|
238
|
+
} catch { /* ref/unref is best-effort */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
68
241
|
function removeSlot(slot) {
|
|
69
242
|
const index = slots.indexOf(slot);
|
|
70
243
|
if (index >= 0) slots.splice(index, 1);
|
|
71
|
-
for (const
|
|
72
|
-
|
|
244
|
+
for (const entry of ownerAffinities.values()) {
|
|
245
|
+
// Keep the entry — its refcount tracks live queued/in-flight work.
|
|
246
|
+
// Only the dead slot pointer is dropped, so the next chunk re-picks
|
|
247
|
+
// a worker while the owner's hold stays intact.
|
|
248
|
+
if (entry.slot === slot) entry.slot = null;
|
|
73
249
|
}
|
|
74
250
|
}
|
|
75
251
|
|
|
@@ -81,29 +257,63 @@ export function createStreamJsonPool({
|
|
|
81
257
|
pendingBytes = Math.max(0, pendingBytes - task.bytes);
|
|
82
258
|
task.detach();
|
|
83
259
|
if (task.aborted) continue;
|
|
84
|
-
|
|
85
|
-
try { task.resolve(parseInline(task.payloads)); }
|
|
86
|
-
catch (error) { task.reject(error); }
|
|
260
|
+
settleInline(task);
|
|
87
261
|
}
|
|
88
262
|
slot.tasks.clear();
|
|
89
263
|
try { slot.worker.terminate(); } catch {}
|
|
90
264
|
drainWaiting();
|
|
91
265
|
}
|
|
92
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Spawn a worker slot.
|
|
269
|
+
*
|
|
270
|
+
* Construction and listener wiring can throw (missing worker file, thread
|
|
271
|
+
* limit, restricted runtime). That must never reach a live stream, so a
|
|
272
|
+
* failure is latched and reported as `null` — "no worker available" — and
|
|
273
|
+
* the caller runs the same work inline instead.
|
|
274
|
+
*/
|
|
93
275
|
function createSlot() {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
276
|
+
let worker;
|
|
277
|
+
try {
|
|
278
|
+
worker = new WorkerImpl(new URL('./stream-json-worker.mjs', import.meta.url), {
|
|
279
|
+
execArgv: [],
|
|
280
|
+
});
|
|
281
|
+
} catch {
|
|
282
|
+
spawnFailures += 1;
|
|
283
|
+
stats.spawnFailures += 1;
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
98
286
|
const slot = { worker, tasks: new Map(), failed: false };
|
|
287
|
+
try {
|
|
288
|
+
worker.unref?.();
|
|
289
|
+
wireSlot(slot);
|
|
290
|
+
} catch {
|
|
291
|
+
spawnFailures += 1;
|
|
292
|
+
stats.spawnFailures += 1;
|
|
293
|
+
try { worker.terminate?.(); } catch {}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
spawnFailures = 0;
|
|
297
|
+
slots.push(slot);
|
|
298
|
+
return slot;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function spawnAllowed() {
|
|
302
|
+
return !closed && workerMax > 0 && spawnFailures < MAX_SPAWN_FAILURES;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function wireSlot(slot) {
|
|
306
|
+
const worker = slot.worker;
|
|
99
307
|
worker.on('message', (message) => {
|
|
100
308
|
const task = slot.tasks.get(Number(message?.id));
|
|
101
309
|
if (!task) return;
|
|
102
310
|
slot.tasks.delete(Number(message.id));
|
|
311
|
+
syncSlotRef(slot);
|
|
103
312
|
pendingBytes = Math.max(0, pendingBytes - task.bytes);
|
|
104
313
|
task.detach();
|
|
105
314
|
if (task.aborted) return;
|
|
106
|
-
if (message?.ok === true) task.resolve(message
|
|
315
|
+
if (message?.ok === true) task.resolve(task.decode(message));
|
|
316
|
+
else if (task.kind === 'sse') settleInline(task);
|
|
107
317
|
else task.reject(syntaxError(message?.error));
|
|
108
318
|
drainWaiting();
|
|
109
319
|
});
|
|
@@ -112,25 +322,32 @@ export function createStreamJsonPool({
|
|
|
112
322
|
if (!closed) failSlot(slot);
|
|
113
323
|
else removeSlot(slot);
|
|
114
324
|
});
|
|
115
|
-
|
|
116
|
-
|
|
325
|
+
// Attaching the message listener starts (and refs) the public port, so
|
|
326
|
+
// the pre-listener unref() above is not enough: an idle worker must not
|
|
327
|
+
// hold a short-lived process open until the pool is closed.
|
|
328
|
+
syncSlotRef(slot);
|
|
117
329
|
}
|
|
118
330
|
|
|
119
|
-
|
|
331
|
+
/** Pick a worker slot for `owner`, or null when none can be provided. */
|
|
332
|
+
function pickSlot(owner) {
|
|
333
|
+
const entry = owner ? ownerAffinities.get(owner) : null;
|
|
334
|
+
if (entry?.slot && !entry.slot.failed) return entry.slot;
|
|
120
335
|
const ready = slots.filter((slot) => !slot.failed);
|
|
121
|
-
const owner = String(ownerKey || '').trim().slice(0, 240);
|
|
122
|
-
const assigned = owner ? ownerAffinities.get(owner) : null;
|
|
123
|
-
if (assigned && !assigned.failed) return assigned;
|
|
124
336
|
const least = ready.sort((left, right) => left.tasks.size - right.tasks.size)[0] || null;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
337
|
+
const wantsNew = ready.length < workerMax && (!least || least.tasks.size > 0);
|
|
338
|
+
let slot;
|
|
339
|
+
if (wantsNew) {
|
|
340
|
+
// This task asked for its OWN worker. When the spawn fails (or the
|
|
341
|
+
// failure latch is set) it settles INLINE instead of being queued
|
|
342
|
+
// behind a worker it deliberately avoided: a broken spawn must
|
|
343
|
+
// never serialize unrelated offload work onto one thread.
|
|
344
|
+
slot = spawnAllowed() ? createSlot() : null;
|
|
345
|
+
if (!slot) return null;
|
|
346
|
+
} else {
|
|
347
|
+
slot = least;
|
|
348
|
+
if (!slot) return null;
|
|
133
349
|
}
|
|
350
|
+
if (entry) entry.slot = slot;
|
|
134
351
|
return slot;
|
|
135
352
|
}
|
|
136
353
|
|
|
@@ -143,19 +360,36 @@ export function createStreamJsonPool({
|
|
|
143
360
|
}
|
|
144
361
|
|
|
145
362
|
function postTask(task) {
|
|
146
|
-
const
|
|
363
|
+
const owner = normalizeOwner(task.ownerKey);
|
|
364
|
+
let slot = null;
|
|
365
|
+
try {
|
|
366
|
+
slot = pickSlot(owner);
|
|
367
|
+
} catch {
|
|
368
|
+
// A WorkerImpl whose constructor (or wiring) throws must not fail
|
|
369
|
+
// the caller: treat it as "no worker available".
|
|
370
|
+
spawnFailures += 1;
|
|
371
|
+
stats.spawnFailures += 1;
|
|
372
|
+
slot = null;
|
|
373
|
+
}
|
|
374
|
+
if (!slot) {
|
|
375
|
+
// Deterministic inline fallback: same computation, same result,
|
|
376
|
+
// no rejection, and abort/retry semantics are untouched.
|
|
377
|
+
task.detach();
|
|
378
|
+
if (!task.aborted) settleInline(task);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
147
381
|
task.slot = slot;
|
|
148
382
|
slot.tasks.set(task.id, task);
|
|
149
383
|
pendingBytes += task.bytes;
|
|
384
|
+
syncSlotRef(slot);
|
|
150
385
|
try {
|
|
151
|
-
slot.worker.postMessage(
|
|
386
|
+
slot.worker.postMessage(task.message);
|
|
152
387
|
} catch {
|
|
153
388
|
slot.tasks.delete(task.id);
|
|
389
|
+
syncSlotRef(slot);
|
|
154
390
|
pendingBytes = Math.max(0, pendingBytes - task.bytes);
|
|
155
391
|
task.detach();
|
|
156
|
-
|
|
157
|
-
try { task.resolve(parseInline(task.payloads)); }
|
|
158
|
-
catch (error) { task.reject(error); }
|
|
392
|
+
if (!task.aborted) settleInline(task);
|
|
159
393
|
drainWaiting();
|
|
160
394
|
}
|
|
161
395
|
}
|
|
@@ -195,12 +429,17 @@ export function createStreamJsonPool({
|
|
|
195
429
|
|
|
196
430
|
const id = ++sequence;
|
|
197
431
|
stats.offloadedBatches += 1;
|
|
198
|
-
|
|
432
|
+
// The hold spans the backpressure queue too, so a batch waiting for
|
|
433
|
+
// pending-byte headroom keeps its owner's worker affinity.
|
|
434
|
+
return underAffinityHold(normalizeOwner(ownerKey), true, () => new Promise((resolve, reject) => {
|
|
199
435
|
const task = {
|
|
200
436
|
id,
|
|
437
|
+
kind: 'batch',
|
|
201
438
|
ownerKey,
|
|
202
439
|
bytes,
|
|
203
|
-
payloads,
|
|
440
|
+
message: { id, payloads },
|
|
441
|
+
inline: () => parseInline(payloads),
|
|
442
|
+
decode: (result) => result.values,
|
|
204
443
|
resolve,
|
|
205
444
|
reject,
|
|
206
445
|
aborted: false,
|
|
@@ -239,7 +478,108 @@ export function createStreamJsonPool({
|
|
|
239
478
|
return;
|
|
240
479
|
}
|
|
241
480
|
postTask(task);
|
|
242
|
-
});
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Frame + parse ONE SSE network chunk as a single unit of work.
|
|
486
|
+
*
|
|
487
|
+
* The caller hands over the complete-record region of its decode buffer;
|
|
488
|
+
* line framing, per-record JSON parsing and per-record error isolation all
|
|
489
|
+
* happen in one place — inside a worker once the chunk is worth the
|
|
490
|
+
* round-trip, otherwise inline. The inline route returns a plain object
|
|
491
|
+
* synchronously (no promise, no microtask), which is what removes the
|
|
492
|
+
* former one-await-per-SSE-event amplification from the shared event loop.
|
|
493
|
+
*
|
|
494
|
+
* Ordering: submissions carrying the same `streamKey` settle in submission
|
|
495
|
+
* order. `currentEvent` is the caller's carry at submission time, so a
|
|
496
|
+
* caller that pipelines chunks must keep feeding the carry it already
|
|
497
|
+
* holds (the Anthropic reader submits one chunk at a time and threads the
|
|
498
|
+
* returned carry forward).
|
|
499
|
+
*/
|
|
500
|
+
function frameSse(text, {
|
|
501
|
+
currentEvent = '',
|
|
502
|
+
ownerKey = currentProviderAdmissionOwner(),
|
|
503
|
+
streamKey = null,
|
|
504
|
+
} = {}) {
|
|
505
|
+
const region = typeof text === 'string' ? text : String(text ?? '');
|
|
506
|
+
const carry = typeof currentEvent === 'string' ? currentEvent : String(currentEvent ?? '');
|
|
507
|
+
const key = streamKey ? String(streamKey).slice(0, 240) : '';
|
|
508
|
+
const owner = normalizeOwner(ownerKey);
|
|
509
|
+
const affinityOwner = owner || key;
|
|
510
|
+
// A chunk that must wait behind its stream's tail is already "queued
|
|
511
|
+
// work" for this owner, so it takes an affinity hold even when it is
|
|
512
|
+
// framed inline — that is the window in which a drainWaiting() burst
|
|
513
|
+
// used to prune the owner and migrate the stream to another worker.
|
|
514
|
+
const chained = key !== '' && streamTails.has(key);
|
|
515
|
+
if (!region) {
|
|
516
|
+
return underAffinityHold(affinityOwner, chained, () =>
|
|
517
|
+
withStreamOrder(key, () => ({ events: [], currentEvent: carry })));
|
|
518
|
+
}
|
|
519
|
+
const bytes = Buffer.byteLength(region);
|
|
520
|
+
stats.parsedBytes += bytes;
|
|
521
|
+
// Bounded and failure-safe by construction: an oversized chunk or a
|
|
522
|
+
// full in-flight budget runs inline instead of queueing or failing, so
|
|
523
|
+
// pending worker bytes stay capped and no live stream is ever dropped
|
|
524
|
+
// for resource pressure.
|
|
525
|
+
const offloadable = workerMax > 0
|
|
526
|
+
&& !closed
|
|
527
|
+
&& bytes >= inlineBelowBytes
|
|
528
|
+
&& bytes <= pendingByteMax
|
|
529
|
+
&& (pendingBytes === 0 || pendingBytes + bytes <= pendingByteMax);
|
|
530
|
+
if (!offloadable) {
|
|
531
|
+
stats.inlineChunks += 1;
|
|
532
|
+
return underAffinityHold(affinityOwner, chained, () => withStreamOrder(key, () => {
|
|
533
|
+
const framed = frameAndParseSse(region, carry);
|
|
534
|
+
stats.framedEvents += framed.events.length;
|
|
535
|
+
return framed;
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
stats.offloadedChunks += 1;
|
|
539
|
+
return underAffinityHold(affinityOwner, true, () => withStreamOrder(key, () => new Promise((resolve, reject) => {
|
|
540
|
+
const id = ++sequence;
|
|
541
|
+
const task = {
|
|
542
|
+
id,
|
|
543
|
+
kind: 'sse',
|
|
544
|
+
// Owner affinity first (one agent's streams share a worker and
|
|
545
|
+
// its parser caches); the stream key only stands in when the
|
|
546
|
+
// call runs outside a provider admission scope.
|
|
547
|
+
ownerKey: affinityOwner,
|
|
548
|
+
bytes,
|
|
549
|
+
message: { id, kind: 'sse', text: region, event: carry },
|
|
550
|
+
inline: () => frameAndParseSse(region, carry),
|
|
551
|
+
decode: (result) => ({
|
|
552
|
+
events: Array.isArray(result?.events) ? result.events : [],
|
|
553
|
+
currentEvent: String(result?.event || ''),
|
|
554
|
+
}),
|
|
555
|
+
resolve: (value) => {
|
|
556
|
+
stats.framedEvents += Array.isArray(value?.events) ? value.events.length : 0;
|
|
557
|
+
resolve(value);
|
|
558
|
+
},
|
|
559
|
+
reject,
|
|
560
|
+
aborted: false,
|
|
561
|
+
onAbort: null,
|
|
562
|
+
detach() {},
|
|
563
|
+
};
|
|
564
|
+
postTask(task);
|
|
565
|
+
})));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Drop a finished stream's ordering slot. An entry that still has work in
|
|
570
|
+
* flight is left alone — it self-deletes once its last chunk settles — so
|
|
571
|
+
* an early/late release can never unorder a stream that is still running.
|
|
572
|
+
*/
|
|
573
|
+
function releaseStream(streamKey) {
|
|
574
|
+
if (!streamKey) return;
|
|
575
|
+
const key = String(streamKey).slice(0, 240);
|
|
576
|
+
const retainedAffinity = retainedStreamAffinities.get(key);
|
|
577
|
+
if (retainedAffinity) {
|
|
578
|
+
retainedStreamAffinities.delete(key);
|
|
579
|
+
releaseAffinity(retainedAffinity);
|
|
580
|
+
}
|
|
581
|
+
const entry = streamTails.get(key);
|
|
582
|
+
if (entry && entry.pending === 0) streamTails.delete(key);
|
|
243
583
|
}
|
|
244
584
|
|
|
245
585
|
async function close(reason = 'provider stream JSON pool closed') {
|
|
@@ -255,12 +595,18 @@ export function createStreamJsonPool({
|
|
|
255
595
|
for (const slot of slots.splice(0)) {
|
|
256
596
|
for (const task of slot.tasks.values()) {
|
|
257
597
|
task.detach();
|
|
258
|
-
if (
|
|
598
|
+
if (task.aborted) continue;
|
|
599
|
+
// A live provider stream must not fail because the pool is
|
|
600
|
+
// shutting down: finish its chunk inline instead.
|
|
601
|
+
if (task.kind === 'sse') settleInline(task);
|
|
602
|
+
else task.reject(error);
|
|
259
603
|
}
|
|
260
604
|
slot.tasks.clear();
|
|
261
605
|
workers.push(Promise.resolve(slot.worker.terminate()).catch(() => {}));
|
|
262
606
|
}
|
|
607
|
+
retainedStreamAffinities.clear();
|
|
263
608
|
ownerAffinities.clear();
|
|
609
|
+
streamTails.clear();
|
|
264
610
|
await Promise.all(workers);
|
|
265
611
|
}
|
|
266
612
|
|
|
@@ -274,18 +620,32 @@ export function createStreamJsonPool({
|
|
|
274
620
|
waitingBytes,
|
|
275
621
|
maxPendingBytes: pendingByteMax,
|
|
276
622
|
ownerAffinities: ownerAffinities.size,
|
|
623
|
+
retainedStreams: retainedStreamAffinities.size,
|
|
624
|
+
orderedStreams: streamTails.size,
|
|
625
|
+
peakOrderedStreams: stats.peakOrderedStreams,
|
|
626
|
+
spawnFailures: stats.spawnFailures,
|
|
627
|
+
workerSpawnDisabled: !spawnAllowed(),
|
|
277
628
|
inlineBatches: stats.inlineBatches,
|
|
278
629
|
offloadedBatches: stats.offloadedBatches,
|
|
279
630
|
fallbackBatches: stats.fallbackBatches,
|
|
631
|
+
inlineChunks: stats.inlineChunks,
|
|
632
|
+
offloadedChunks: stats.offloadedChunks,
|
|
633
|
+
framedEvents: stats.framedEvents,
|
|
280
634
|
parsedBytes: stats.parsedBytes,
|
|
281
635
|
};
|
|
282
636
|
}
|
|
283
637
|
|
|
284
|
-
return { parseBatch, close, snapshot };
|
|
638
|
+
return { parseBatch, frameSse, retainStream, releaseStream, close, snapshot };
|
|
285
639
|
}
|
|
286
640
|
|
|
287
641
|
export const providerStreamJsonPool = createStreamJsonPool();
|
|
288
642
|
export const parseProviderJsonBatch = (payloads, options) =>
|
|
289
643
|
providerStreamJsonPool.parseBatch(payloads, options);
|
|
644
|
+
export const frameProviderSseChunk = (text, options) =>
|
|
645
|
+
providerStreamJsonPool.frameSse(text, options);
|
|
646
|
+
export const retainProviderSseStream = (streamKey, ownerKey) =>
|
|
647
|
+
providerStreamJsonPool.retainStream(streamKey, ownerKey);
|
|
648
|
+
export const releaseProviderSseStream = (streamKey) =>
|
|
649
|
+
providerStreamJsonPool.releaseStream(streamKey);
|
|
290
650
|
export const providerStreamJsonSnapshot = () => providerStreamJsonPool.snapshot();
|
|
291
651
|
export const closeProviderStreamJsonPool = (reason) => providerStreamJsonPool.close(reason);
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { frameAndParseSse } from './lib/sse-framing.mjs';
|
|
2
3
|
|
|
3
4
|
if (!parentPort) throw new Error('provider stream JSON worker requires parentPort');
|
|
4
5
|
|
|
5
6
|
parentPort.on('message', (message) => {
|
|
6
7
|
const id = Number(message?.id);
|
|
8
|
+
if (message?.kind === 'sse') {
|
|
9
|
+
// Whole-chunk SSE work: line framing AND per-record JSON parsing run
|
|
10
|
+
// here, so the owner event loop only receives the ordered event list.
|
|
11
|
+
try {
|
|
12
|
+
const framed = frameAndParseSse(String(message.text || ''), String(message.event || ''));
|
|
13
|
+
parentPort.postMessage({
|
|
14
|
+
id,
|
|
15
|
+
ok: true,
|
|
16
|
+
kind: 'sse',
|
|
17
|
+
events: framed.events,
|
|
18
|
+
event: framed.currentEvent,
|
|
19
|
+
});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
parentPort.postMessage({
|
|
22
|
+
id,
|
|
23
|
+
ok: false,
|
|
24
|
+
kind: 'sse',
|
|
25
|
+
error: {
|
|
26
|
+
name: String(error?.name || 'Error'),
|
|
27
|
+
message: String(error?.message || error || 'sse framing failed'),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
7
33
|
const payloads = Array.isArray(message?.payloads) ? message.payloads : [];
|
|
8
34
|
try {
|
|
9
35
|
const values = payloads.map((payload) => JSON.parse(String(payload)));
|