@oomol-lab/open-flow 0.1.0-beta.75 → 0.1.0-beta.76
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 +2 -1
- package/dist/browser/{ContentIcon-FCpKiwjp.js → ContentIcon-Cm1hbVzW.js} +2 -2
- package/dist/browser/{FlowCanvas-dzRBnlUT.js → FlowCanvas-NzKtla_c.js} +62 -62
- package/dist/browser/{createResourceDialog-BeYDdUl-.js → createResourceDialog-BgdedQqu.js} +3 -3
- package/dist/browser/{dialog-ZtpsFbl4.js → dialog-aSElFFw2.js} +49 -24
- package/dist/browser/{exportSvg-DqXw1yf-.js → exportSvg-Dcz4HTo_.js} +2 -2
- package/dist/browser/{flowChanges-b_5UobFj.js → flowChanges-NZyS7HSp.js} +207 -396
- package/dist/browser/{flowWorkspace-ItojZbO4.js → flowWorkspace-Bl0SDehy.js} +90 -13
- package/dist/browser/preview.css +515 -649
- package/dist/browser/preview.js +454 -545
- package/dist/browser/theme.css +114 -262
- package/dist/browser/{tooltip-wZJRB9oP.js → tooltip-3XMK9jkr.js} +1 -1
- package/dist/browser/{typeScriptSession-CSRwWmZm.js → typeScriptSession-C9sBGOKZ.js} +1 -1
- package/dist/browser/ui.css +354 -460
- package/dist/browser/ui.js +1 -1
- package/dist/browser/workbench.css +515 -649
- package/dist/browser/workbench.js +7 -7
- package/dist/browser/{workbenchSelect-M441Lz0d.js → workbenchSelect-DBGP7Y4Z.js} +2 -2
- package/dist/command/index.js +33 -35
- package/dist/common/authoring.d.ts +3 -3
- package/dist/common/authoringSchema.d.ts +3 -0
- package/dist/common/connector-proxy.d.ts +1 -1
- package/dist/common/control-api-conformance.js +197 -136
- package/dist/common/control-api.d.ts +3 -2
- package/dist/common/control-api.js +192 -131
- package/dist/common/control-requests.js +126 -24
- package/dist/common/cron-trigger.d.ts +2 -1
- package/dist/common/decision.d.ts +4 -4
- package/dist/common/decision.js +5 -2
- package/dist/common/engine-contract.d.ts +5 -1
- package/dist/common/event-source-definition.d.ts +1 -1
- package/dist/common/event-source-view.d.ts +22 -0
- package/dist/common/event-source.d.ts +1 -1
- package/dist/common/event-source.js +24 -65
- package/dist/common/event-sources.d.ts +0 -21
- package/dist/common/flow-authoring.js +40 -18
- package/dist/common/flow-change-schema.d.ts +9 -1
- package/dist/common/flow-change.d.ts +7 -201
- package/dist/common/flow-change.js +46 -22
- package/dist/common/flow-encoding.js +40 -20
- package/dist/common/flow-input-value.d.ts +1 -1
- package/dist/common/flow-model.d.ts +121 -0
- package/dist/common/flow-notifications.d.ts +1 -1
- package/dist/common/flow-retry.d.ts +2 -4
- package/dist/common/flow-semantics.js +83 -86
- package/dist/common/flow-workflowReferences.d.ts +8 -0
- package/dist/common/flowInspection.d.ts +1 -1
- package/dist/common/http-hosts.d.ts +6 -0
- package/dist/common/http-request.d.ts +13 -5
- package/dist/common/http-request.js +23 -5
- package/dist/common/integration-trigger.d.ts +2 -1
- package/dist/common/mcp.js +195 -134
- package/dist/common/openapi.d.ts +4 -4
- package/dist/common/openapi.js +4 -1
- package/dist/common/poll-trigger.d.ts +2 -1
- package/dist/common/runtime-contract.d.ts +23 -1
- package/dist/common/runtime-contract.js +604 -72
- package/dist/common/runtime-nodejs-compat.d.ts +44 -0
- package/dist/common/runtime-nodejs-compat.js +5870 -0
- package/dist/common/scheduler-checkpoint.d.ts +1 -1
- package/dist/common/scheduler-node-execution.d.ts +1 -1
- package/dist/common/scheduler.d.ts +1 -1
- package/dist/common/scheduler.js +25 -4
- package/dist/common/trigger-catalog.d.ts +1 -7
- package/dist/common/trigger-config-options.d.ts +1 -1
- package/dist/common/trigger-config.d.ts +1 -1
- package/dist/common/trigger-contract.d.ts +2 -1
- package/dist/common/trigger-localization.d.ts +8 -2
- package/dist/common/trigger-model.d.ts +101 -0
- package/dist/common/webhook-response.d.ts +1 -1
- package/dist/common/webhook-trigger.d.ts +3 -1
- package/dist/common/workflow.d.ts +3 -12
- package/dist/common/workflow.js +25 -23
- package/dist/index.d.ts +41 -14
- package/package.json +5 -1
- package/src/base/common/json.ts +1 -1
- package/src/connector/common/proxy.ts +1 -1
- package/src/control/common/api.ts +6 -5
- package/src/control/common/authoringSchema.ts +9 -2
- package/src/control/common/authoringTools.ts +1 -1
- package/src/control/common/conformance.ts +1 -1
- package/src/control/common/connectorDecoders.ts +1 -1
- package/src/control/common/eventSources.ts +2 -29
- package/src/control/common/triggerCatalog.ts +1 -8
- package/src/decision/common/decision.ts +15 -7
- package/src/event-source/common/definition.ts +1 -1
- package/src/event-source/common/index.ts +1 -1
- package/src/event-source/common/view.ts +20 -0
- package/src/execution/common/checkpoint.ts +1 -1
- package/src/execution/common/node-execution.ts +1 -1
- package/src/execution/common/scheduler.ts +1 -1
- package/src/flow/common/change.ts +58 -192
- package/src/flow/common/changeSchema.ts +29 -4
- package/src/flow/common/connectionUsage.ts +1 -1
- package/src/flow/common/inputValue.ts +1 -1
- package/src/flow/common/model.ts +130 -0
- package/src/flow/common/modules.ts +1 -1
- package/src/flow/common/portChanges.ts +1 -1
- package/src/flow/common/retry.ts +2 -4
- package/src/flow/common/semantics.ts +11 -4
- package/src/flow/common/webhookResponse.ts +1 -1
- package/src/flow/common/workflowReferences.ts +31 -0
- package/src/http/common/authBindings.ts +6 -9
- package/src/http/common/hosts.ts +13 -0
- package/src/http/common/httpRequest.ts +30 -15
- package/src/openapi/common/openapi.ts +9 -5
- package/src/{execution → runtime}/common/engineContract.ts +20 -15
- package/src/runtime/common/nodejsCompat.ts +275 -0
- package/src/runtime/common/nodejsGuest.generated.json +47 -0
- package/src/runtime/common/runtime.ts +1138 -0
- package/src/trigger/common/config.ts +1 -1
- package/src/trigger/common/configOptions.ts +1 -1
- package/src/trigger/common/contract.ts +2 -1
- package/src/trigger/common/cron.ts +2 -1
- package/src/trigger/common/integration.ts +2 -1
- package/src/trigger/common/model.ts +87 -0
- package/src/trigger/common/poll.ts +2 -1
- package/src/trigger/common/webhook.ts +3 -1
- package/src/trigger/providers/fieldDescriptions.ts +1 -1
- package/src/trigger/providers/localization.ts +9 -2
- package/src/trigger/providers/remote.ts +1 -1
- package/src/types/index.ts +44 -15
- package/src/workflow/common/workflow.ts +3 -44
- package/src/execution/common/runtime.ts +0 -563
- /package/src/{control → connector}/common/providerIconSprite.ts +0 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
import type { CodeModule, ConnectorCapability, TaskCapability, JsonValue } from '../../flow/common/model.ts'
|
|
2
|
+
|
|
3
|
+
import { dequal } from 'dequal/lite'
|
|
4
|
+
import { currentEngineContract } from './engineContract.ts'
|
|
5
|
+
|
|
6
|
+
export { currentEngineContract, nodejsEngineContract, findEngineContract, type EngineContract } from './engineContract.ts'
|
|
7
|
+
|
|
8
|
+
export type RuntimeModule = Pick<CodeModule, 'imports' | 'source'>
|
|
9
|
+
|
|
10
|
+
/** Snapshot a Task result before JSON transport can discard or coerce invalid values. */
|
|
11
|
+
export function snapshotRuntimeResult(value: unknown): JsonValue | undefined {
|
|
12
|
+
// This function is also serialized into the isolated runtime; keep it self-contained.
|
|
13
|
+
const parents = new Set<object>()
|
|
14
|
+
const copy = (item: unknown, depth: number): JsonValue => {
|
|
15
|
+
if (depth > 64) throw new Error('Task result exceeds the maximum JSON depth.')
|
|
16
|
+
if (item === null || typeof item == 'string' || typeof item == 'boolean') return item
|
|
17
|
+
if (typeof item == 'number' && Number.isFinite(item)) return item
|
|
18
|
+
if (
|
|
19
|
+
typeof item != 'object' ||
|
|
20
|
+
parents.has(item) ||
|
|
21
|
+
(!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) ||
|
|
22
|
+
Object.getOwnPropertySymbols(item).length > 0
|
|
23
|
+
)
|
|
24
|
+
throw new Error('Task result must contain only JSON values.')
|
|
25
|
+
parents.add(item)
|
|
26
|
+
const result = Array.isArray(item)
|
|
27
|
+
? Array.from(item, (child) => copy(child, depth + 1))
|
|
28
|
+
: Object.fromEntries(
|
|
29
|
+
Object.entries(item)
|
|
30
|
+
.filter(([, child]) => depth != 0 || child !== undefined)
|
|
31
|
+
.map(([key, child]) => [key, copy(child, depth + 1)]),
|
|
32
|
+
)
|
|
33
|
+
parents.delete(item)
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
return value === undefined ? undefined : copy(value, 0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const nodejsRuntimeConformanceCases: readonly RuntimeConformanceCase[] = [
|
|
40
|
+
{
|
|
41
|
+
name: 'supports Node builtin imports and asynchronous virtual file operations',
|
|
42
|
+
async verify(harness) {
|
|
43
|
+
const value = await harness.invoke({
|
|
44
|
+
capability: async () => ({ body: null, status: 403 }),
|
|
45
|
+
input: null,
|
|
46
|
+
invocationId: 'nodejs-files',
|
|
47
|
+
program: program(
|
|
48
|
+
harness,
|
|
49
|
+
`import assert from 'node:assert/strict';
|
|
50
|
+
import { Buffer } from 'node:buffer'; import path from 'node:path'; import fs from 'node:fs/promises';
|
|
51
|
+
export default async () => {
|
|
52
|
+
const file = path.join('/tmp', 'conformance.txt');
|
|
53
|
+
await fs.writeFile(file, Buffer.from('你好'));
|
|
54
|
+
const text = await fs.readFile(file, 'utf8');
|
|
55
|
+
assert.equal(text, '你好');
|
|
56
|
+
await fs.unlink(file);
|
|
57
|
+
return { text, bytes: Buffer.byteLength(text) };
|
|
58
|
+
}`,
|
|
59
|
+
),
|
|
60
|
+
})
|
|
61
|
+
equal(value, { text: '你好', bytes: 6 }, 'Node virtual files')
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'preserves abort reasons and cancels Promise timers',
|
|
66
|
+
async verify(harness) {
|
|
67
|
+
const value = await harness.invoke({
|
|
68
|
+
capability: async () => ({ body: null, status: 403 }),
|
|
69
|
+
input: null,
|
|
70
|
+
invocationId: 'nodejs-abort',
|
|
71
|
+
program: program(
|
|
72
|
+
harness,
|
|
73
|
+
`import { setTimeout } from 'node:timers/promises';
|
|
74
|
+
export default async () => {
|
|
75
|
+
const controller = new AbortController(); const reason = { message: 'stop' };
|
|
76
|
+
const combined = AbortSignal.any([controller.signal]);
|
|
77
|
+
const pending = setTimeout(60000, 'late', { signal: combined });
|
|
78
|
+
controller.abort(reason); controller.abort('ignored');
|
|
79
|
+
let thrown; try { combined.throwIfAborted(); } catch (error) { thrown = error; }
|
|
80
|
+
try { await pending; return false; } catch (error) {
|
|
81
|
+
return { sameReason: combined.reason === reason && thrown === reason, name: error.name };
|
|
82
|
+
}
|
|
83
|
+
}`,
|
|
84
|
+
),
|
|
85
|
+
})
|
|
86
|
+
equal(value, { sameReason: true, name: 'AbortError' }, 'Node cancellation')
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
nodejsCase(
|
|
90
|
+
'resolves prefixed and bare builtin names to the same module and shares globals with their modules',
|
|
91
|
+
`import { Buffer } from 'node:buffer'; import bare from 'buffer'; import processModule from 'node:process';
|
|
92
|
+
import { URL as ImportedURL, URLSearchParams as ImportedParams } from 'node:url'; import { ReadableStream as ImportedStream } from 'node:stream/web';
|
|
93
|
+
export default () => ({ buffer: Buffer === bare.Buffer && Buffer === globalThis.Buffer, process: process === processModule,
|
|
94
|
+
url: URL === ImportedURL && URLSearchParams === ImportedParams, stream: ReadableStream === ImportedStream,
|
|
95
|
+
cwd: process.cwd(), env: Object.keys(process.env) })`,
|
|
96
|
+
{ buffer: true, process: true, url: true, stream: true, cwd: '/tmp', env: [] },
|
|
97
|
+
),
|
|
98
|
+
nodejsCase(
|
|
99
|
+
'supports events, string decoding, paths, query strings, URLs and promisify',
|
|
100
|
+
`import { EventEmitter } from 'node:events'; import { StringDecoder } from 'node:string_decoder'; import { Buffer } from 'node:buffer';
|
|
101
|
+
import path from 'node:path'; import qs from 'node:querystring'; import { promisify } from 'node:util';
|
|
102
|
+
export default async () => {
|
|
103
|
+
const emitter = new EventEmitter(); let count = 0; emitter.once('value', (x) => (count += x)); emitter.emit('value', 7); emitter.emit('value', 9);
|
|
104
|
+
const decoder = new StringDecoder('utf8'); const bytes = Buffer.from('你好');
|
|
105
|
+
const incremental = new TextDecoder(); const partial = incremental.decode(new Uint8Array([0xe4, 0xbd]), { stream: true });
|
|
106
|
+
const url = new URL('../文件?a=1', 'https://example.com/a/b'); url.searchParams.append('b', 'x y');
|
|
107
|
+
return { count, text: decoder.write(bytes.subarray(0, 2)) + decoder.end(bytes.subarray(2)), partial, rest: incremental.decode(new Uint8Array([0xa0])),
|
|
108
|
+
path: path.join('/tmp', 'a', '..', 'b'), query: qs.parse('a=1&a=2').a, url: url.href, canParse: URL.canParse('not a url'),
|
|
109
|
+
value: await promisify((callback) => callback(null, 42))() };
|
|
110
|
+
}`,
|
|
111
|
+
{
|
|
112
|
+
count: 7,
|
|
113
|
+
text: '你好',
|
|
114
|
+
partial: '',
|
|
115
|
+
rest: '你',
|
|
116
|
+
path: '/tmp/b',
|
|
117
|
+
query: ['1', '2'],
|
|
118
|
+
url: 'https://example.com/%E6%96%87%E4%BB%B6?a=1&b=x+y',
|
|
119
|
+
canParse: false,
|
|
120
|
+
value: 42,
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
nodejsCase(
|
|
124
|
+
'encodes Buffers and computes hashes, HMACs, gzip and random values',
|
|
125
|
+
`import { Buffer } from 'node:buffer'; import { createHash, createHmac, randomBytes } from 'node:crypto'; import { gzipSync, gunzipSync, deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
126
|
+
export default () => ({ hex: Buffer.from('你好').toString('hex'), base64: Buffer.from('abc').toString('base64'), latin1: Buffer.from([0xe9]).toString('latin1'),
|
|
127
|
+
utf16: Buffer.from('a', 'utf16le').length, hash: createHash('sha256').update('a').update('bc').digest('hex'),
|
|
128
|
+
md5: createHash('md5').update('abc').digest('base64'), hmac: createHmac('sha256', 'key').update('message').digest('hex'),
|
|
129
|
+
gzip: gunzipSync(gzipSync(Buffer.from('你好'))).toString(), raw: inflateRawSync(deflateRawSync('raw')).toString(),
|
|
130
|
+
random: randomBytes(12).length, uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(crypto.randomUUID()),
|
|
131
|
+
values: crypto.getRandomValues(new Uint8Array(4)).length })`,
|
|
132
|
+
{
|
|
133
|
+
hex: 'e4bda0e5a5bd',
|
|
134
|
+
base64: 'YWJj',
|
|
135
|
+
latin1: 'é',
|
|
136
|
+
utf16: 2,
|
|
137
|
+
hash: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
|
138
|
+
md5: 'kAFQmDzST7DWlj99KOF/cg==',
|
|
139
|
+
hmac: '6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a',
|
|
140
|
+
gzip: '你好',
|
|
141
|
+
raw: 'raw',
|
|
142
|
+
random: 12,
|
|
143
|
+
uuid: true,
|
|
144
|
+
values: 4,
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
nodejsCase(
|
|
148
|
+
'provides an invocation-local filesystem with Node error codes',
|
|
149
|
+
`import fs from 'node:fs'; import fsp from 'node:fs/promises';
|
|
150
|
+
export default async () => {
|
|
151
|
+
await fsp.mkdir('/tmp/nested', { recursive: true });
|
|
152
|
+
fs.writeFileSync('/tmp/nested/data', '你好');
|
|
153
|
+
const handle = await fsp.open('/tmp/nested/data', 'r'); const data = await handle.readFile('utf8'); await handle.close();
|
|
154
|
+
await fsp.rename('/tmp/nested/data', '/tmp/nested/moved');
|
|
155
|
+
let missing; try { fs.readFileSync('/etc/passwd'); } catch (error) { missing = error.code; }
|
|
156
|
+
return { data, size: fs.statSync('/tmp/nested/moved').size, entries: fs.readdirSync('/tmp/nested'), missing };
|
|
157
|
+
}`,
|
|
158
|
+
{ data: '你好', size: 6, entries: ['moved'], missing: 'ENOENT' },
|
|
159
|
+
),
|
|
160
|
+
nodejsCase(
|
|
161
|
+
'runs timers, Node streams, pipelines and Web Streams',
|
|
162
|
+
`import { setTimeout as delay } from 'node:timers/promises'; import { Readable, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises';
|
|
163
|
+
export default async () => {
|
|
164
|
+
let count = 0; const timer = setInterval(() => { count++; clearInterval(timer) }, 1); const removed = setTimeout(() => { count += 100 }, 1); clearTimeout(removed);
|
|
165
|
+
await delay(10);
|
|
166
|
+
const data = []; for await (const chunk of Readable.from(['a', 'b'])) data.push(chunk);
|
|
167
|
+
let piped = ''; await pipeline(Readable.from(['x', 'y']), new Writable({ write(chunk, _encoding, callback) { piped += chunk.toString(); callback() } }));
|
|
168
|
+
const reader = new ReadableStream({ start(controller) { controller.enqueue('web'); controller.close() } }).getReader();
|
|
169
|
+
return { count, data, piped, web: (await reader.read()).value };
|
|
170
|
+
}`,
|
|
171
|
+
{ count: 1, data: ['a', 'b'], piped: 'xy', web: 'web' },
|
|
172
|
+
),
|
|
173
|
+
nodejsCase(
|
|
174
|
+
'supports AbortSignal timeout and pre-aborted signals',
|
|
175
|
+
`import { setTimeout as delay } from 'node:timers/promises';
|
|
176
|
+
export default async () => { const timeout = AbortSignal.timeout(1); await delay(10); return { timeout: timeout.reason.name, aborted: AbortSignal.abort().aborted } }`,
|
|
177
|
+
{ timeout: 'TimeoutError', aborted: true },
|
|
178
|
+
),
|
|
179
|
+
nodejsCase(
|
|
180
|
+
'encodes and decodes text larger than one builtin transfer and creates many decoders',
|
|
181
|
+
`export default () => {
|
|
182
|
+
const text = '你😀a'.repeat(400000); const bytes = new TextEncoder().encode(text);
|
|
183
|
+
const decoder = new TextDecoder(); let streamed = '';
|
|
184
|
+
for (let offset = 0; offset < bytes.length; offset += 1000003) streamed += decoder.decode(bytes.subarray(offset, offset + 1000003), { stream: true });
|
|
185
|
+
streamed += decoder.decode();
|
|
186
|
+
let decoded = 0; for (let index = 0; index < 300; index++) decoded += new TextDecoder().decode(new Uint8Array([0x61])).length;
|
|
187
|
+
return { bytes: bytes.length, whole: new TextDecoder().decode(bytes) === text, streamed: streamed === text, decoded };
|
|
188
|
+
}`,
|
|
189
|
+
{ bytes: 3_200_000, whole: true, streamed: true, decoded: 300 },
|
|
190
|
+
),
|
|
191
|
+
{
|
|
192
|
+
name: 'isolates files, globals and builtin module state between Task invocations',
|
|
193
|
+
async verify(harness) {
|
|
194
|
+
const invoke = async (invocationId: string, source: string) =>
|
|
195
|
+
await harness.invoke({
|
|
196
|
+
capability: async () => ({ body: null, status: 403 }),
|
|
197
|
+
input: null,
|
|
198
|
+
invocationId,
|
|
199
|
+
program: program(harness, source),
|
|
200
|
+
})
|
|
201
|
+
await invoke(
|
|
202
|
+
'nodejs-isolation-write',
|
|
203
|
+
`import fs from 'node:fs'; import crypto from 'node:crypto';
|
|
204
|
+
export default () => { fs.writeFileSync('/tmp/marker', 'x'); URL.prototype.taskMarker = true; crypto.constants.taskMarker = true; globalThis.taskMarker = true }`,
|
|
205
|
+
)
|
|
206
|
+
const value = await invoke(
|
|
207
|
+
'nodejs-isolation-read',
|
|
208
|
+
`import fs from 'node:fs'; import crypto from 'node:crypto';
|
|
209
|
+
export default () => ({ file: fs.existsSync('/tmp/marker'), url: 'taskMarker' in URL.prototype, module: 'taskMarker' in crypto.constants, global: 'taskMarker' in globalThis })`,
|
|
210
|
+
)
|
|
211
|
+
equal(value, { file: false, url: false, module: false, global: false }, 'Task isolation')
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
/** Reference Artifact Host for the guest chunk protocol; deployments own storage, identity and limits. */
|
|
217
|
+
function artifactHost() {
|
|
218
|
+
const chunkBytes = 512 * 1024
|
|
219
|
+
const uploads = new Map<string, Uint8Array[]>()
|
|
220
|
+
const artifacts = new Map<string, Uint8Array>()
|
|
221
|
+
const kinds: string[] = []
|
|
222
|
+
let nextUpload = 0
|
|
223
|
+
const capability = async (call: RuntimeCapabilityCall): Promise<RuntimeCapabilityResponse> => {
|
|
224
|
+
kinds.push(call.kind)
|
|
225
|
+
const payload = call.payload as Readonly<Record<string, JsonValue>>
|
|
226
|
+
if (call.kind == 'artifact.write') {
|
|
227
|
+
const upload = typeof payload.upload == 'string' ? payload.upload : `u${++nextUpload}`
|
|
228
|
+
uploads.set(upload, [...(uploads.get(upload) ?? []), decodeBase64(payload.data!)])
|
|
229
|
+
return { body: { upload }, status: 200 }
|
|
230
|
+
}
|
|
231
|
+
if (call.kind == 'artifact.put') {
|
|
232
|
+
const parts = [...(typeof payload.upload == 'string' ? (uploads.get(payload.upload) ?? []) : []), decodeBase64(payload.data!)]
|
|
233
|
+
const bytes = new Uint8Array(parts.reduce((size, part) => size + part.length, 0))
|
|
234
|
+
parts.reduce((offset, part) => (bytes.set(part, offset), offset + part.length), 0)
|
|
235
|
+
const id = `a${artifacts.size + 1}`
|
|
236
|
+
artifacts.set(id, bytes)
|
|
237
|
+
return {
|
|
238
|
+
body: {
|
|
239
|
+
kind: 'artifact',
|
|
240
|
+
id,
|
|
241
|
+
name: payload.name!,
|
|
242
|
+
size: bytes.length,
|
|
243
|
+
digest: artifactDigest(id),
|
|
244
|
+
...(payload.mediaType == null ? {} : { mediaType: payload.mediaType }),
|
|
245
|
+
},
|
|
246
|
+
status: 200,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (call.kind == 'artifact.read') {
|
|
250
|
+
const bytes = artifacts.get(String(payload.id))!
|
|
251
|
+
const offset = Number(payload.offset)
|
|
252
|
+
return { body: { data: encodeBase64(bytes.subarray(offset, offset + chunkBytes)), size: bytes.length }, status: 200 }
|
|
253
|
+
}
|
|
254
|
+
throw new Error(`Unexpected Capability ${call.kind}.`)
|
|
255
|
+
}
|
|
256
|
+
return { capability, digest: artifactDigest, kinds: () => kinds }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function decodeBase64(text: JsonValue): Uint8Array {
|
|
260
|
+
return Uint8Array.from(atob(String(text)), (character) => character.charCodeAt(0))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
264
|
+
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function artifactDigest(id: string): string {
|
|
268
|
+
return `sha256:${id.slice(1).padStart(64, '0')}`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function nodejsCase(name: string, source: string, expected: JsonValue): RuntimeConformanceCase {
|
|
272
|
+
return {
|
|
273
|
+
name,
|
|
274
|
+
async verify(harness) {
|
|
275
|
+
const value = await harness.invoke({
|
|
276
|
+
capability: async () => ({ body: null, status: 403 }),
|
|
277
|
+
input: null,
|
|
278
|
+
invocationId: `nodejs-${name}`,
|
|
279
|
+
program: program(harness, source),
|
|
280
|
+
})
|
|
281
|
+
equal(value, expected, name)
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface RuntimeProgram {
|
|
287
|
+
readonly engineContract: string
|
|
288
|
+
readonly engineDigest: string
|
|
289
|
+
readonly entryModuleId: string
|
|
290
|
+
readonly modules: Readonly<Record<string, RuntimeModule>>
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface RuntimeCapabilityCall {
|
|
294
|
+
readonly nodeId?: string
|
|
295
|
+
readonly jobId?: string
|
|
296
|
+
readonly callId: string
|
|
297
|
+
readonly invocationId: string
|
|
298
|
+
readonly kind: string
|
|
299
|
+
readonly payload: JsonValue
|
|
300
|
+
readonly signal: AbortSignal
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface RuntimeCapabilityResponse {
|
|
304
|
+
readonly body: JsonValue
|
|
305
|
+
readonly status: number
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface LlmTaskInvocation {
|
|
309
|
+
readonly input: Readonly<Record<string, JsonValue>>
|
|
310
|
+
readonly invocationId: string
|
|
311
|
+
readonly mode: 'chat' | 'json'
|
|
312
|
+
readonly signal: AbortSignal
|
|
313
|
+
readonly version: 1
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export type LlmTaskResult =
|
|
317
|
+
| { readonly kind: 'completed'; readonly value: JsonValue; readonly version: 1 }
|
|
318
|
+
| {
|
|
319
|
+
readonly retryable?: boolean
|
|
320
|
+
readonly code: 'llm.output-invalid' | 'llm.unavailable'
|
|
321
|
+
readonly kind: 'failed'
|
|
322
|
+
readonly message: string
|
|
323
|
+
readonly version: 1
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type InvokeLlmTask = (invocation: LlmTaskInvocation) => Promise<LlmTaskResult>
|
|
327
|
+
|
|
328
|
+
export interface AgentCheckpoint {
|
|
329
|
+
readonly state: JsonValue
|
|
330
|
+
readonly callId: string
|
|
331
|
+
readonly toolId: string
|
|
332
|
+
readonly input: Readonly<Record<string, JsonValue>>
|
|
333
|
+
readonly rounds: number
|
|
334
|
+
readonly version: 1
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type AgentResult = { readonly kind: 'completed'; readonly output: JsonValue } | { readonly kind: 'suspended'; readonly checkpoint: AgentCheckpoint }
|
|
338
|
+
|
|
339
|
+
export interface AgentInvocation {
|
|
340
|
+
readonly input: Readonly<Record<string, JsonValue>>
|
|
341
|
+
readonly invocationId: string
|
|
342
|
+
readonly signal: AbortSignal
|
|
343
|
+
readonly resume?: { readonly action: 'approve' | 'reject'; readonly checkpoint: AgentCheckpoint }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface RuntimeInvocation {
|
|
347
|
+
readonly capabilities?: readonly TaskCapability[]
|
|
348
|
+
readonly capability: (call: RuntimeCapabilityCall) => Promise<RuntimeCapabilityResponse>
|
|
349
|
+
readonly input: JsonValue
|
|
350
|
+
readonly invocationId: string
|
|
351
|
+
readonly program: RuntimeProgram
|
|
352
|
+
readonly signal?: AbortSignal
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface RuntimeHarness {
|
|
356
|
+
readonly engineDigest: string
|
|
357
|
+
invoke(invocation: RuntimeInvocation): Promise<JsonValue | undefined>
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface RuntimeConformanceCase {
|
|
361
|
+
readonly name: string
|
|
362
|
+
verify(harness: RuntimeHarness): Promise<void>
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function program(harness: RuntimeHarness, source: string, modules: Readonly<Record<string, RuntimeModule>> = {}): RuntimeProgram {
|
|
366
|
+
return {
|
|
367
|
+
engineContract: currentEngineContract,
|
|
368
|
+
engineDigest: harness.engineDigest,
|
|
369
|
+
entryModuleId: 'main',
|
|
370
|
+
modules: {
|
|
371
|
+
...modules,
|
|
372
|
+
main: { imports: Object.keys(modules), source },
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function equal(actual: unknown, expected: unknown, message: string): void {
|
|
378
|
+
if (!dequal(actual, expected)) {
|
|
379
|
+
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function rejects(operation: Promise<unknown>, pattern: RegExp, message: string): Promise<void> {
|
|
384
|
+
try {
|
|
385
|
+
await operation
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (pattern.test(error instanceof Error ? error.message : String(error))) return
|
|
388
|
+
throw new Error(`${message}: received ${error instanceof Error ? error.message : String(error)}.`, { cause: error })
|
|
389
|
+
}
|
|
390
|
+
throw new Error(`${message}: operation unexpectedly succeeded.`)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export const runtimeConformanceCases: readonly RuntimeConformanceCase[] = [
|
|
394
|
+
{
|
|
395
|
+
name: 'rejects Task results that JSON transport would discard or coerce',
|
|
396
|
+
async verify(harness) {
|
|
397
|
+
for (const expression of [
|
|
398
|
+
'{ value: () => 42 }',
|
|
399
|
+
'{ value: Symbol("value") }',
|
|
400
|
+
'{ value: NaN }',
|
|
401
|
+
'{ value: Infinity }',
|
|
402
|
+
'{ value: 1n }',
|
|
403
|
+
'{ value: { nested: undefined } }',
|
|
404
|
+
'{ value: [undefined] }',
|
|
405
|
+
'{ value: Array(1) }',
|
|
406
|
+
'{ value: new Date() }',
|
|
407
|
+
'{ value: { toJSON() { return "hidden" } } }',
|
|
408
|
+
'{ [Symbol("key")]: 1 }',
|
|
409
|
+
'(() => { const value = {}; value.self = value; return { value } })()',
|
|
410
|
+
]) {
|
|
411
|
+
await rejects(
|
|
412
|
+
harness.invoke({
|
|
413
|
+
capability: async () => ({ body: null, status: 200 }),
|
|
414
|
+
input: null,
|
|
415
|
+
invocationId: 'invalid-task-result',
|
|
416
|
+
program: program(harness, `export default () => (${expression})`),
|
|
417
|
+
}),
|
|
418
|
+
/Task result must contain only JSON values/,
|
|
419
|
+
`Invalid Task result: ${expression}`,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'snapshots JSON results while preserving missing ports and shared values',
|
|
426
|
+
async verify(harness) {
|
|
427
|
+
const value = await harness.invoke({
|
|
428
|
+
capability: async () => ({ body: null, status: 200 }),
|
|
429
|
+
input: null,
|
|
430
|
+
invocationId: 'json-task-result',
|
|
431
|
+
program: program(
|
|
432
|
+
harness,
|
|
433
|
+
`export default () => {
|
|
434
|
+
const shared = { text: 'hello', values: [1, true, null] };
|
|
435
|
+
return { missing: undefined, nullable: null, first: shared, second: shared };
|
|
436
|
+
}`,
|
|
437
|
+
),
|
|
438
|
+
})
|
|
439
|
+
const shared = { text: 'hello', values: [1, true, null] }
|
|
440
|
+
equal(value, { nullable: null, first: shared, second: shared }, 'JSON Task result')
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'preserves Action error codes and fixed per-call Connection selection',
|
|
445
|
+
async verify(harness) {
|
|
446
|
+
const declarations: readonly ConnectorCapability[] = [
|
|
447
|
+
{
|
|
448
|
+
kind: 'connector',
|
|
449
|
+
action: 'example.echo',
|
|
450
|
+
connections: [{ connectionId: 'work', alias: 'office' }, { connectionId: 'home' }],
|
|
451
|
+
},
|
|
452
|
+
]
|
|
453
|
+
const result = await harness.invoke({
|
|
454
|
+
capabilities: declarations,
|
|
455
|
+
capability: async ({ payload }) => {
|
|
456
|
+
const selected = resolveAction(declarations, payload)
|
|
457
|
+
if (selected.input.fail) throw Object.assign(new Error('Provider unavailable.'), { code: 'connector.unavailable' })
|
|
458
|
+
return { body: selected.connectionId ?? null, status: 200 }
|
|
459
|
+
},
|
|
460
|
+
input: null,
|
|
461
|
+
invocationId: 'action-accounts',
|
|
462
|
+
program: program(
|
|
463
|
+
harness,
|
|
464
|
+
`export default async (_, context) => {
|
|
465
|
+
const values = await Promise.all([
|
|
466
|
+
context.actions.example.echo({}, { connectionAlias: 'office' }),
|
|
467
|
+
context.actions['example.echo']({}, { connectionId: 'home' }),
|
|
468
|
+
])
|
|
469
|
+
try { await context.actions.example.echo({ fail: true }, { connectionId: 'work' }) }
|
|
470
|
+
catch (error) { values.push(error.code) }
|
|
471
|
+
return values
|
|
472
|
+
}`,
|
|
473
|
+
),
|
|
474
|
+
})
|
|
475
|
+
equal(result, ['work', 'home', 'connector.unavailable'], 'Action selection and recovery')
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
name: 'shares Action entries and assigns independent concurrent call identities',
|
|
480
|
+
async verify(harness) {
|
|
481
|
+
const calls: RuntimeCapabilityCall[] = []
|
|
482
|
+
const value = await harness.invoke({
|
|
483
|
+
capabilities: [{ kind: 'connector', action: 'example.echo', connections: [] }],
|
|
484
|
+
capability: async (call) => {
|
|
485
|
+
calls.push(call)
|
|
486
|
+
return { body: call.payload, status: 200 }
|
|
487
|
+
},
|
|
488
|
+
input: null,
|
|
489
|
+
invocationId: 'action-calls',
|
|
490
|
+
program: program(
|
|
491
|
+
harness,
|
|
492
|
+
`export default async (_input, context) => {
|
|
493
|
+
const call = context.actions.example.echo
|
|
494
|
+
return { same: call === context.actions['example.echo'], values: await Promise.all([call({ value: 1 }), call({ value: 2 }), call({ value: 3 })]) }
|
|
495
|
+
}`,
|
|
496
|
+
),
|
|
497
|
+
})
|
|
498
|
+
equal(value, { same: true, values: [1, 2, 3].map((item) => ({ action: 'example.echo', input: { value: item } })) }, 'Action results')
|
|
499
|
+
equal(new Set(calls.map((call) => call.callId)).size, 3, 'Independent Action identities')
|
|
500
|
+
equal(
|
|
501
|
+
calls.map((call) => call.invocationId),
|
|
502
|
+
['action-calls', 'action-calls', 'action-calls'],
|
|
503
|
+
'Task identity',
|
|
504
|
+
)
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
{
|
|
509
|
+
name: 'loads the fixed ESM closure and platform module',
|
|
510
|
+
async verify(harness) {
|
|
511
|
+
const value = await harness.invoke({
|
|
512
|
+
capability: async () => {
|
|
513
|
+
throw new Error('Capability must not be called.')
|
|
514
|
+
},
|
|
515
|
+
input: { value: 6 },
|
|
516
|
+
invocationId: 'module-closure',
|
|
517
|
+
program: program(
|
|
518
|
+
harness,
|
|
519
|
+
`import { double } from './helper.mjs'
|
|
520
|
+
import { engineContract, identity } from 'open-flow:platform'
|
|
521
|
+
export default (input) => ({ engineContract, value: identity(double(input.value)) })`,
|
|
522
|
+
{ helper: { imports: [], source: 'export const double = (value) => value * 2' } },
|
|
523
|
+
),
|
|
524
|
+
})
|
|
525
|
+
equal(value, { engineContract: currentEngineContract, value: 12 }, 'ESM closure result')
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: 'rejects modules outside the fixed closure',
|
|
530
|
+
async verify(harness) {
|
|
531
|
+
await rejects(
|
|
532
|
+
harness.invoke({
|
|
533
|
+
capability: async () => ({ body: null, status: 200 }),
|
|
534
|
+
input: null,
|
|
535
|
+
invocationId: 'closed-linker',
|
|
536
|
+
program: program(
|
|
537
|
+
harness,
|
|
538
|
+
`import { execSync } from 'node:child_process'
|
|
539
|
+
export default () => execSync('cat /etc/passwd').toString()`,
|
|
540
|
+
),
|
|
541
|
+
}),
|
|
542
|
+
/invalid|unsupported|cannot import|not part of/i,
|
|
543
|
+
'Closed linker',
|
|
544
|
+
)
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: 'copies asynchronous Capability requests and responses',
|
|
549
|
+
async verify(harness) {
|
|
550
|
+
const calls: RuntimeCapabilityCall[] = []
|
|
551
|
+
const value = await harness.invoke({
|
|
552
|
+
capabilities: [{ kind: 'connector', action: 'example.read', connections: [] }],
|
|
553
|
+
capability: async (call) => {
|
|
554
|
+
calls.push(call)
|
|
555
|
+
return { body: { accepted: true, echoed: call.payload }, status: 200 }
|
|
556
|
+
},
|
|
557
|
+
input: { issue: 42 },
|
|
558
|
+
invocationId: 'async-capability',
|
|
559
|
+
program: program(harness, `export default async (input, context) => context.actions.example.read(input)`),
|
|
560
|
+
})
|
|
561
|
+
equal(value, { accepted: true, echoed: { action: 'example.read', input: { issue: 42 } } }, 'Capability result')
|
|
562
|
+
equal(
|
|
563
|
+
calls.map(({ invocationId, kind, payload }) => ({ invocationId, kind, payload })),
|
|
564
|
+
[{ invocationId: 'async-capability', kind: 'connector', payload: { action: 'example.read', input: { issue: 42 } } }],
|
|
565
|
+
'Capability request',
|
|
566
|
+
)
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: 'stores and reads Artifacts through bounded chunks',
|
|
571
|
+
async verify(harness) {
|
|
572
|
+
const host = artifactHost()
|
|
573
|
+
const value = await harness.invoke({
|
|
574
|
+
capability: host.capability,
|
|
575
|
+
input: null,
|
|
576
|
+
invocationId: 'artifact-roundtrip',
|
|
577
|
+
program: program(
|
|
578
|
+
harness,
|
|
579
|
+
`export default async (_input, context) => {
|
|
580
|
+
const large = new Uint8Array(1200 * 1024).map((_, index) => index % 251)
|
|
581
|
+
const text = await context.artifact.put('你好', { name: 'greeting.txt', mediaType: 'text/plain' })
|
|
582
|
+
const binary = await context.artifact.put(large, { name: 'data.bin' })
|
|
583
|
+
const empty = await context.artifact.put(new Uint8Array(0), { name: 'empty' })
|
|
584
|
+
const read = await context.artifact.open(binary)
|
|
585
|
+
return { text, binary, empty, greeting: new TextDecoder().decode(await context.artifact.open(text)),
|
|
586
|
+
same: read.length == large.length && read.every((byte, index) => byte == large[index]), emptyLength: (await context.artifact.open(empty)).length }
|
|
587
|
+
}`,
|
|
588
|
+
),
|
|
589
|
+
})
|
|
590
|
+
const ref = (id: string, name: string, size: number, mediaType?: string) => ({
|
|
591
|
+
kind: 'artifact',
|
|
592
|
+
id,
|
|
593
|
+
name,
|
|
594
|
+
size,
|
|
595
|
+
digest: host.digest(id),
|
|
596
|
+
...(mediaType == null ? {} : { mediaType }),
|
|
597
|
+
})
|
|
598
|
+
equal(
|
|
599
|
+
value,
|
|
600
|
+
{
|
|
601
|
+
text: ref('a1', 'greeting.txt', 6, 'text/plain'),
|
|
602
|
+
binary: ref('a2', 'data.bin', 1200 * 1024),
|
|
603
|
+
empty: ref('a3', 'empty', 0),
|
|
604
|
+
greeting: '你好',
|
|
605
|
+
same: true,
|
|
606
|
+
emptyLength: 0,
|
|
607
|
+
},
|
|
608
|
+
'Artifact roundtrip',
|
|
609
|
+
)
|
|
610
|
+
equal(
|
|
611
|
+
host.kinds(),
|
|
612
|
+
[
|
|
613
|
+
'artifact.put',
|
|
614
|
+
'artifact.write',
|
|
615
|
+
'artifact.write',
|
|
616
|
+
'artifact.put',
|
|
617
|
+
'artifact.put',
|
|
618
|
+
'artifact.read',
|
|
619
|
+
'artifact.read',
|
|
620
|
+
'artifact.read',
|
|
621
|
+
'artifact.read',
|
|
622
|
+
'artifact.read',
|
|
623
|
+
],
|
|
624
|
+
'Artifact chunks',
|
|
625
|
+
)
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: 'rejects invalid Artifact arguments before Host dispatch',
|
|
630
|
+
async verify(harness) {
|
|
631
|
+
const host = artifactHost()
|
|
632
|
+
const value = await harness.invoke({
|
|
633
|
+
capability: host.capability,
|
|
634
|
+
input: null,
|
|
635
|
+
invocationId: 'artifact-invalid',
|
|
636
|
+
program: program(
|
|
637
|
+
harness,
|
|
638
|
+
`export default async (_input, context) => {
|
|
639
|
+
const codes = []
|
|
640
|
+
for (const call of [() => context.artifact.put({}, { name: 'x' }), () => context.artifact.put('x', {}), () => context.artifact.put('x', { name: 'x', mediaType: 1 }), () => context.artifact.open({ id: 'a1' })]) {
|
|
641
|
+
try { await call() } catch (error) { codes.push(error.code) }
|
|
642
|
+
}
|
|
643
|
+
return codes
|
|
644
|
+
}`,
|
|
645
|
+
),
|
|
646
|
+
})
|
|
647
|
+
equal(
|
|
648
|
+
value,
|
|
649
|
+
Array.from({ length: 4 }, () => 'capability.invalid'),
|
|
650
|
+
'Artifact validation',
|
|
651
|
+
)
|
|
652
|
+
equal(host.kinds(), [], 'Artifact dispatch')
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: 'calls declared HTTP hosts through context.fetch',
|
|
657
|
+
async verify(harness) {
|
|
658
|
+
const calls: JsonValue[] = []
|
|
659
|
+
const value = await harness.invoke({
|
|
660
|
+
capability: async (call) => {
|
|
661
|
+
calls.push({ kind: call.kind, payload: call.payload })
|
|
662
|
+
return {
|
|
663
|
+
status: 200,
|
|
664
|
+
body: {
|
|
665
|
+
status: 201,
|
|
666
|
+
statusText: 'Created',
|
|
667
|
+
url: 'https://api.example.com/items?q=1',
|
|
668
|
+
headers: [
|
|
669
|
+
['content-type', 'application/json'],
|
|
670
|
+
['x-tag', 'a'],
|
|
671
|
+
['x-tag', 'b'],
|
|
672
|
+
],
|
|
673
|
+
body: btoa('{"id":7}'),
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
input: null,
|
|
678
|
+
invocationId: 'code-fetch',
|
|
679
|
+
program: program(
|
|
680
|
+
harness,
|
|
681
|
+
`export default async (_input, context) => {
|
|
682
|
+
const response = await context.fetch(new URL('https://api.example.com/items?q=1'), { method: 'post', headers: [['X-Trace', '1']], body: '{"name":"a"}' })
|
|
683
|
+
const json = await response.json()
|
|
684
|
+
let reused; try { await response.text() } catch (error) { reused = error instanceof TypeError }
|
|
685
|
+
const codes = []
|
|
686
|
+
for (const call of [() => context.fetch('https://api.example.com', { method: 'TRACE' }), () => context.fetch('https://api.example.com', { body: 'x' }),
|
|
687
|
+
() => context.fetch('https://api.example.com', { method: 'POST', body: {} }), () => context.fetch('https://api.example.com', { headers: { 'bad header': 'x' } }),
|
|
688
|
+
() => context.fetch('https://api.example.com', { method: 'PUT', body: new Uint8Array(1024 * 1024 + 1) })]) {
|
|
689
|
+
try { await call() } catch (error) { codes.push(error.code) }
|
|
690
|
+
}
|
|
691
|
+
return { status: response.status, ok: response.ok, statusText: response.statusText, url: response.url, redirected: response.redirected, json, reused,
|
|
692
|
+
tag: response.headers.get('X-Tag'), has: response.headers.has('content-type'), names: [...response.headers.keys()], codes, egress: typeof context.egress }
|
|
693
|
+
}`,
|
|
694
|
+
),
|
|
695
|
+
})
|
|
696
|
+
equal(
|
|
697
|
+
value,
|
|
698
|
+
{
|
|
699
|
+
status: 201,
|
|
700
|
+
ok: true,
|
|
701
|
+
statusText: 'Created',
|
|
702
|
+
url: 'https://api.example.com/items?q=1',
|
|
703
|
+
redirected: false,
|
|
704
|
+
json: { id: 7 },
|
|
705
|
+
reused: true,
|
|
706
|
+
tag: 'a, b',
|
|
707
|
+
has: true,
|
|
708
|
+
names: ['content-type', 'x-tag', 'x-tag'],
|
|
709
|
+
codes: Array.from({ length: 5 }, () => 'capability.invalid'),
|
|
710
|
+
egress: 'undefined',
|
|
711
|
+
},
|
|
712
|
+
'Fetch response',
|
|
713
|
+
)
|
|
714
|
+
equal(
|
|
715
|
+
calls,
|
|
716
|
+
[
|
|
717
|
+
{
|
|
718
|
+
kind: 'http.fetch',
|
|
719
|
+
payload: {
|
|
720
|
+
url: 'https://api.example.com/items?q=1',
|
|
721
|
+
method: 'POST',
|
|
722
|
+
headers: [
|
|
723
|
+
['x-trace', '1'],
|
|
724
|
+
['content-type', 'text/plain;charset=UTF-8'],
|
|
725
|
+
],
|
|
726
|
+
body: btoa('{"name":"a"}'),
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
'Fetch request',
|
|
731
|
+
)
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: 'returns final outputs without exposing intermediate output capability',
|
|
736
|
+
async verify(harness) {
|
|
737
|
+
const value = await harness.invoke({
|
|
738
|
+
capability: async () => {
|
|
739
|
+
throw new Error('Capability must not be called.')
|
|
740
|
+
},
|
|
741
|
+
input: null,
|
|
742
|
+
invocationId: 'final-outputs',
|
|
743
|
+
program: program(
|
|
744
|
+
harness,
|
|
745
|
+
`export default async (_input, context) => ({ available: typeof context.outputs, previous: await context.getPrevious(), value: 1 })`,
|
|
746
|
+
),
|
|
747
|
+
})
|
|
748
|
+
equal(value, { available: 'undefined', previous: [], value: 1 }, 'Final Task outputs')
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
name: 'cancels a pending Capability and the user invocation',
|
|
753
|
+
async verify(harness) {
|
|
754
|
+
const cancellation = new AbortController()
|
|
755
|
+
let capabilityAborted = false
|
|
756
|
+
let capabilityStarted!: () => void
|
|
757
|
+
const started = new Promise<void>((resolve) => {
|
|
758
|
+
capabilityStarted = resolve
|
|
759
|
+
})
|
|
760
|
+
const invoked = harness.invoke({
|
|
761
|
+
capabilities: [{ kind: 'connector', action: 'example.wait', connections: [] }],
|
|
762
|
+
capability: async ({ signal }) => {
|
|
763
|
+
capabilityStarted()
|
|
764
|
+
return await new Promise<RuntimeCapabilityResponse>((_resolve, reject) =>
|
|
765
|
+
signal.addEventListener(
|
|
766
|
+
'abort',
|
|
767
|
+
() => {
|
|
768
|
+
capabilityAborted = true
|
|
769
|
+
reject(signal.reason)
|
|
770
|
+
},
|
|
771
|
+
{ once: true },
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
},
|
|
775
|
+
input: null,
|
|
776
|
+
invocationId: 'canceled-capability',
|
|
777
|
+
program: program(harness, `export default async (_input, context) => context.actions.example.wait({})`),
|
|
778
|
+
signal: cancellation.signal,
|
|
779
|
+
})
|
|
780
|
+
await started
|
|
781
|
+
cancellation.abort(new Error('Invocation canceled.'))
|
|
782
|
+
await rejects(invoked, /cancel|abort|disposed/i, 'Canceled invocation')
|
|
783
|
+
equal(capabilityAborted, true, 'Capability cancellation')
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
]
|
|
787
|
+
|
|
788
|
+
export function createActions(invoke: (payload: JsonValue) => Promise<RuntimeCapabilityResponse>): Readonly<Record<string, unknown>> {
|
|
789
|
+
// Keep validation inside the function serialized into the isolated runtime.
|
|
790
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
791
|
+
const json = (value: unknown, parents: Set<object>): void => {
|
|
792
|
+
if (value === null || typeof value == 'string' || typeof value == 'boolean' || (typeof value == 'number' && Number.isFinite(value))) return
|
|
793
|
+
if (
|
|
794
|
+
typeof value != 'object' ||
|
|
795
|
+
parents.has(value) ||
|
|
796
|
+
(!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ||
|
|
797
|
+
Object.getOwnPropertySymbols(value).length > 0
|
|
798
|
+
)
|
|
799
|
+
throw Object.assign(new Error('Action parameters must contain only JSON values.'), { code: 'capability.invalid' })
|
|
800
|
+
parents.add(value)
|
|
801
|
+
for (const child of Array.isArray(value) ? value : Object.values(value)) json(child, parents)
|
|
802
|
+
parents.delete(value)
|
|
803
|
+
}
|
|
804
|
+
const target: Record<string, unknown> = Object.create(null)
|
|
805
|
+
const calls = new Map<string, (input?: JsonValue, options?: JsonValue) => Promise<JsonValue>>()
|
|
806
|
+
const providers = new Map<string, Readonly<Record<string, unknown>>>()
|
|
807
|
+
const actionCall = (action: string): ((input?: JsonValue, options?: JsonValue) => Promise<JsonValue>) => {
|
|
808
|
+
let call = calls.get(action)
|
|
809
|
+
if (call != null) return call
|
|
810
|
+
call = async (input: JsonValue = {}, options?: JsonValue): Promise<JsonValue> => {
|
|
811
|
+
json(input, new Set())
|
|
812
|
+
if (options !== undefined) json(options, new Set())
|
|
813
|
+
const response = await invoke({ action, input, ...(options === undefined ? {} : { options }) })
|
|
814
|
+
return response.body
|
|
815
|
+
}
|
|
816
|
+
calls.set(action, call)
|
|
817
|
+
return call
|
|
818
|
+
}
|
|
819
|
+
target.call = async (action: JsonValue, input: JsonValue = {}, options?: JsonValue): Promise<JsonValue> => {
|
|
820
|
+
if (typeof action != 'string') throw Object.assign(new Error('Action ID must be a string.'), { code: 'capability.invalid' })
|
|
821
|
+
return actionCall(action)(input, options)
|
|
822
|
+
}
|
|
823
|
+
Object.freeze(target)
|
|
824
|
+
return new Proxy(target, {
|
|
825
|
+
get(object, property) {
|
|
826
|
+
if (typeof property != 'string') return Reflect.get(object, property)
|
|
827
|
+
if (Object.hasOwn(object, property)) return object[property]
|
|
828
|
+
if (property.includes('.')) return actionCall(property)
|
|
829
|
+
let methods = providers.get(property)
|
|
830
|
+
if (methods == null) {
|
|
831
|
+
const provider = property
|
|
832
|
+
const providerTarget = Object.freeze(Object.create(null) as Record<string, unknown>)
|
|
833
|
+
methods = new Proxy(providerTarget, {
|
|
834
|
+
get(_object, name) {
|
|
835
|
+
return typeof name == 'string' ? actionCall(`${provider}.${name}`) : undefined
|
|
836
|
+
},
|
|
837
|
+
})
|
|
838
|
+
providers.set(property, methods)
|
|
839
|
+
}
|
|
840
|
+
return methods
|
|
841
|
+
},
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
export function resolveAction(
|
|
846
|
+
declarations: readonly ConnectorCapability[],
|
|
847
|
+
payload: unknown,
|
|
848
|
+
): { readonly action: string; readonly connectionId?: string; readonly input: Readonly<Record<string, JsonValue>> } {
|
|
849
|
+
const invalid = Object.assign(new Error('The Action request is invalid.'), { code: 'capability.invalid' })
|
|
850
|
+
const denied = Object.assign(new Error('The Action or Connection is unavailable for this Code node.'), { code: 'capability.denied' })
|
|
851
|
+
if (payload == null || typeof payload != 'object' || Array.isArray(payload)) throw invalid
|
|
852
|
+
const source = payload as Record<string, JsonValue>
|
|
853
|
+
if (
|
|
854
|
+
Object.keys(source).some((key) => !['action', 'input', 'options'].includes(key)) ||
|
|
855
|
+
typeof source.action != 'string' ||
|
|
856
|
+
!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_.-]+$/.test(source.action) ||
|
|
857
|
+
source.input == null ||
|
|
858
|
+
typeof source.input != 'object' ||
|
|
859
|
+
Array.isArray(source.input)
|
|
860
|
+
)
|
|
861
|
+
throw invalid
|
|
862
|
+
const permissions = declarations.find((declaration) => 'mode' in declaration)
|
|
863
|
+
const allowed = permissions?.mode == 'independent' ? permissions.actions.find((entry) => entry.action == source.action) : undefined
|
|
864
|
+
if (permissions?.mode == 'independent' && allowed == null) throw denied
|
|
865
|
+
const hints = declarations.flatMap((declaration) => {
|
|
866
|
+
if ('mode' in declaration) return []
|
|
867
|
+
if ('action' in declaration) {
|
|
868
|
+
if (declaration.action != source.action) return []
|
|
869
|
+
return [
|
|
870
|
+
...declaration.connections.flatMap((connection) =>
|
|
871
|
+
connection.alias == null ? [] : [{ action: declaration.action, connectionId: connection.connectionId, alias: connection.alias }],
|
|
872
|
+
),
|
|
873
|
+
...(declaration.connectionId == null ? [] : [{ action: declaration.action, connectionId: declaration.connectionId }]),
|
|
874
|
+
]
|
|
875
|
+
}
|
|
876
|
+
return declaration.connectionHints?.filter((hint) => hint.action == source.action) ?? []
|
|
877
|
+
})
|
|
878
|
+
let connectionId = hints.find((hint) => hint.alias == null)?.connectionId
|
|
879
|
+
if (Object.hasOwn(source, 'options')) {
|
|
880
|
+
if (source.options == null || typeof source.options != 'object' || Array.isArray(source.options)) throw invalid
|
|
881
|
+
const options = source.options as Record<string, JsonValue>
|
|
882
|
+
const keys = Object.keys(options)
|
|
883
|
+
if (keys.length != 1) throw invalid
|
|
884
|
+
if (typeof options.connectionId == 'string' && options.connectionId.length > 0 && keys[0] == 'connectionId') {
|
|
885
|
+
connectionId = options.connectionId
|
|
886
|
+
} else if (typeof options.connectionAlias == 'string' && options.connectionAlias.length > 0 && keys[0] == 'connectionAlias') {
|
|
887
|
+
connectionId = hints.find((item) => item.alias == options.connectionAlias)?.connectionId
|
|
888
|
+
if (connectionId == null) throw denied
|
|
889
|
+
} else throw invalid
|
|
890
|
+
}
|
|
891
|
+
if (permissions?.mode == 'independent') {
|
|
892
|
+
if (connectionId != null && connectionId != allowed?.connectionId) throw denied
|
|
893
|
+
connectionId = allowed?.connectionId
|
|
894
|
+
}
|
|
895
|
+
if (permissions?.mode == 'shared' && Object.hasOwn(source, 'options') && connectionId == null) throw denied
|
|
896
|
+
return { action: source.action, input: source.input as Readonly<Record<string, JsonValue>>, ...(connectionId == null ? {} : { connectionId }) }
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export interface DecisionTaskInvocation {
|
|
900
|
+
readonly questions: readonly import('../../decision/common/decision.ts').DecisionQuestion[]
|
|
901
|
+
readonly state: JsonValue
|
|
902
|
+
readonly invocationId: string
|
|
903
|
+
readonly signal: AbortSignal
|
|
904
|
+
readonly version: 1
|
|
905
|
+
}
|
|
906
|
+
export type InvokeDecisionTask = (invocation: DecisionTaskInvocation) => Promise<LlmTaskResult>
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Serialized into the sandbox before the Capability helpers that use it; keep it self-contained. Encodes in guest
|
|
910
|
+
* JavaScript, because Node compatibility `atob`/`btoa` route through bounded Host primitives.
|
|
911
|
+
*/
|
|
912
|
+
export function createBase64() {
|
|
913
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
914
|
+
const values = new Uint8Array(128).fill(255)
|
|
915
|
+
for (let index = 0; index < alphabet.length; index++) values[alphabet.charCodeAt(index)] = index
|
|
916
|
+
return Object.freeze({
|
|
917
|
+
encode(bytes: Uint8Array): string {
|
|
918
|
+
const parts: string[] = []
|
|
919
|
+
for (let start = 0; start < bytes.length; start += 0x6000) {
|
|
920
|
+
let text = ''
|
|
921
|
+
for (let index = start; index < Math.min(start + 0x6000, bytes.length); index += 3) {
|
|
922
|
+
const value = (bytes[index]! << 16) | ((bytes[index + 1] ?? 0) << 8) | (bytes[index + 2] ?? 0)
|
|
923
|
+
text +=
|
|
924
|
+
alphabet[value >> 18]! +
|
|
925
|
+
alphabet[(value >> 12) & 63]! +
|
|
926
|
+
(index + 1 < bytes.length ? alphabet[(value >> 6) & 63]! : '=') +
|
|
927
|
+
(index + 2 < bytes.length ? alphabet[value & 63]! : '=')
|
|
928
|
+
}
|
|
929
|
+
parts.push(text)
|
|
930
|
+
}
|
|
931
|
+
return parts.join('')
|
|
932
|
+
},
|
|
933
|
+
decode(text: unknown): Uint8Array {
|
|
934
|
+
if (typeof text != 'string' || text.length % 4 != 0) throw new TypeError('Base64 content is invalid.')
|
|
935
|
+
const padding = text.endsWith('==') ? 2 : text.endsWith('=') ? 1 : 0
|
|
936
|
+
const bytes = new Uint8Array((text.length / 4) * 3 - padding)
|
|
937
|
+
for (let index = 0, offset = 0; index < text.length; index += 4) {
|
|
938
|
+
const digits = [0, 1, 2, 3].map((position) => {
|
|
939
|
+
const code = text.charCodeAt(index + position)
|
|
940
|
+
if (code == 61 && index + position >= text.length - padding) return 0
|
|
941
|
+
const value = code < 128 ? values[code]! : 255
|
|
942
|
+
if (value == 255) throw new TypeError('Base64 content is invalid.')
|
|
943
|
+
return value
|
|
944
|
+
})
|
|
945
|
+
const value = (digits[0]! << 18) | (digits[1]! << 12) | (digits[2]! << 6) | digits[3]!
|
|
946
|
+
if (offset < bytes.length) bytes[offset++] = value >> 16
|
|
947
|
+
if (offset < bytes.length) bytes[offset++] = (value >> 8) & 255
|
|
948
|
+
if (offset < bytes.length) bytes[offset++] = value & 255
|
|
949
|
+
}
|
|
950
|
+
return bytes
|
|
951
|
+
},
|
|
952
|
+
})
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Serialized into the sandbox; keep it self-contained. Transfers bytes as base64 chunks through
|
|
957
|
+
* `artifact.write`, `artifact.put` and `artifact.read` so each Capability message stays bounded.
|
|
958
|
+
*/
|
|
959
|
+
export function createArtifacts(
|
|
960
|
+
invoke: (kind: string, payload: JsonValue) => Promise<RuntimeCapabilityResponse>,
|
|
961
|
+
{ decode, encode }: ReturnType<typeof createBase64> = createBase64(),
|
|
962
|
+
) {
|
|
963
|
+
const chunkBytes = 512 * 1024
|
|
964
|
+
// Keep helpers inside the function serialized into the isolated runtime.
|
|
965
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
966
|
+
const invalid = (message: string): Error => Object.assign(new TypeError(message), { code: 'capability.invalid' })
|
|
967
|
+
return Object.freeze({
|
|
968
|
+
async put(data: unknown, options: unknown): Promise<JsonValue> {
|
|
969
|
+
const settings = options as { readonly name?: unknown; readonly mediaType?: unknown } | null
|
|
970
|
+
if (settings == null || typeof settings != 'object' || typeof settings.name != 'string' || settings.name.length == 0)
|
|
971
|
+
throw invalid('Artifact name is required.')
|
|
972
|
+
if (settings.mediaType !== undefined && typeof settings.mediaType != 'string') throw invalid('Artifact mediaType must be a string.')
|
|
973
|
+
const bytes = typeof data == 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : undefined
|
|
974
|
+
if (bytes == null) throw invalid('Artifact data must be a string or Uint8Array.')
|
|
975
|
+
const tail = bytes.length == 0 ? 0 : Math.floor((bytes.length - 1) / chunkBytes) * chunkBytes
|
|
976
|
+
let upload: JsonValue | undefined
|
|
977
|
+
for (let offset = 0; offset < tail; offset += chunkBytes) {
|
|
978
|
+
const response = await invoke('artifact.write', {
|
|
979
|
+
...(upload === undefined ? {} : { upload }),
|
|
980
|
+
data: encode(bytes.subarray(offset, offset + chunkBytes)),
|
|
981
|
+
})
|
|
982
|
+
upload = (response.body as { readonly upload?: JsonValue } | null)?.upload
|
|
983
|
+
}
|
|
984
|
+
const response = await invoke('artifact.put', {
|
|
985
|
+
...(upload === undefined ? {} : { upload }),
|
|
986
|
+
name: settings.name,
|
|
987
|
+
...(settings.mediaType === undefined ? {} : { mediaType: settings.mediaType }),
|
|
988
|
+
data: encode(bytes.subarray(tail)),
|
|
989
|
+
})
|
|
990
|
+
return response.body
|
|
991
|
+
},
|
|
992
|
+
async open(ref: unknown): Promise<Uint8Array> {
|
|
993
|
+
const artifact = ref as { readonly kind?: unknown; readonly id?: unknown } | null
|
|
994
|
+
if (artifact == null || typeof artifact != 'object' || artifact.kind != 'artifact' || typeof artifact.id != 'string')
|
|
995
|
+
throw invalid('An ArtifactRef is required.')
|
|
996
|
+
const parts: Uint8Array[] = []
|
|
997
|
+
let offset = 0
|
|
998
|
+
let size = 0
|
|
999
|
+
do {
|
|
1000
|
+
const body = (await invoke('artifact.read', { id: artifact.id, offset })).body as { readonly data?: unknown; readonly size?: unknown } | null
|
|
1001
|
+
const part = decode(body?.data)
|
|
1002
|
+
if (typeof body?.size != 'number' || !Number.isSafeInteger(body.size) || (part.length == 0 && offset < body.size))
|
|
1003
|
+
throw new TypeError('Artifact content is invalid.')
|
|
1004
|
+
size = body.size
|
|
1005
|
+
parts.push(part)
|
|
1006
|
+
offset += part.length
|
|
1007
|
+
} while (offset < size)
|
|
1008
|
+
const bytes = new Uint8Array(offset)
|
|
1009
|
+
let position = 0
|
|
1010
|
+
for (const part of parts) {
|
|
1011
|
+
bytes.set(part, position)
|
|
1012
|
+
position += part.length
|
|
1013
|
+
}
|
|
1014
|
+
return bytes
|
|
1015
|
+
},
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Serialized into the sandbox; keep it self-contained. `context.fetch` sends one `http.fetch` Capability call per request.
|
|
1021
|
+
* The Host authorizes the URL against the node's `http` Capability, does not follow redirects and adds no credentials.
|
|
1022
|
+
* Request and response bodies are limited to 1 MiB; the response is a read-only subset of the Fetch `Response`.
|
|
1023
|
+
*/
|
|
1024
|
+
export function createFetch(
|
|
1025
|
+
invoke: (kind: string, payload: JsonValue) => Promise<RuntimeCapabilityResponse>,
|
|
1026
|
+
{ decode, encode }: ReturnType<typeof createBase64> = createBase64(),
|
|
1027
|
+
) {
|
|
1028
|
+
const maximumBytes = 1024 * 1024
|
|
1029
|
+
const methods = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])
|
|
1030
|
+
// Keep helpers inside the function serialized into the isolated runtime.
|
|
1031
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
1032
|
+
const invalid = (message: string): Error => Object.assign(new TypeError(message), { code: 'capability.invalid' })
|
|
1033
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
1034
|
+
const headerList = (value: unknown): [string, string][] => {
|
|
1035
|
+
if (value == null) return []
|
|
1036
|
+
const pairs =
|
|
1037
|
+
typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] == 'function'
|
|
1038
|
+
? Array.from(value as Iterable<unknown>, (pair) => {
|
|
1039
|
+
if (!Array.isArray(pair) || pair.length != 2) throw invalid('Headers must be name/value pairs.')
|
|
1040
|
+
return pair
|
|
1041
|
+
})
|
|
1042
|
+
: typeof value == 'object'
|
|
1043
|
+
? Object.entries(value as Record<string, unknown>)
|
|
1044
|
+
: undefined
|
|
1045
|
+
if (pairs == null) throw invalid('Headers must be an object or name/value pairs.')
|
|
1046
|
+
return pairs.map(([name, text]) => {
|
|
1047
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(String(name))) throw invalid(`Invalid header name "${String(name)}".`)
|
|
1048
|
+
return [String(name).toLowerCase(), String(text)]
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
return async function fetch(input: unknown, init: unknown = {}): Promise<unknown> {
|
|
1052
|
+
const options = (init ?? {}) as { readonly method?: unknown; readonly headers?: unknown; readonly body?: unknown; readonly signal?: unknown }
|
|
1053
|
+
if (typeof options != 'object') throw invalid('Fetch options must be an object.')
|
|
1054
|
+
const url = String(input instanceof Object && 'href' in input ? (input as { href: unknown }).href : input)
|
|
1055
|
+
const method = String(options.method ?? 'GET').toUpperCase()
|
|
1056
|
+
if (!methods.has(method)) throw invalid(`Unsupported HTTP method "${method}".`)
|
|
1057
|
+
const headers = headerList(options.headers)
|
|
1058
|
+
const body =
|
|
1059
|
+
options.body == null
|
|
1060
|
+
? undefined
|
|
1061
|
+
: typeof options.body == 'string'
|
|
1062
|
+
? new TextEncoder().encode(options.body)
|
|
1063
|
+
: options.body instanceof Uint8Array
|
|
1064
|
+
? options.body
|
|
1065
|
+
: options.body instanceof ArrayBuffer
|
|
1066
|
+
? new Uint8Array(options.body)
|
|
1067
|
+
: undefined
|
|
1068
|
+
if (options.body != null && body == null) throw invalid('Fetch body must be a string, Uint8Array or ArrayBuffer.')
|
|
1069
|
+
if (body != null && (method == 'GET' || method == 'HEAD')) throw invalid(`A ${method} request cannot have a body.`)
|
|
1070
|
+
if (body != null && body.length > maximumBytes) throw invalid('Fetch request body exceeds 1 MiB.')
|
|
1071
|
+
if (typeof options.body == 'string' && !headers.some(([name]) => name == 'content-type')) headers.push(['content-type', 'text/plain;charset=UTF-8'])
|
|
1072
|
+
const signal = options.signal as { readonly aborted?: boolean; readonly reason?: unknown; addEventListener?: Function } | undefined
|
|
1073
|
+
if (signal?.aborted) throw signal.reason
|
|
1074
|
+
const request = invoke('http.fetch', { url, method, headers, ...(body == null ? {} : { body: encode(body) }) })
|
|
1075
|
+
const result = (
|
|
1076
|
+
signal?.addEventListener == null
|
|
1077
|
+
? await request
|
|
1078
|
+
: await Promise.race([request, new Promise<never>((_, reject) => signal.addEventListener!('abort', () => reject(signal.reason), { once: true }))])
|
|
1079
|
+
).body as { readonly status?: unknown; readonly statusText?: unknown; readonly url?: unknown; readonly headers?: unknown; readonly body?: unknown } | null
|
|
1080
|
+
if (
|
|
1081
|
+
result == null ||
|
|
1082
|
+
typeof result.status != 'number' ||
|
|
1083
|
+
typeof result.url != 'string' ||
|
|
1084
|
+
typeof result.body != 'string' ||
|
|
1085
|
+
!Array.isArray(result.headers) ||
|
|
1086
|
+
result.headers.some((pair) => !Array.isArray(pair) || typeof pair[0] != 'string' || typeof pair[1] != 'string')
|
|
1087
|
+
)
|
|
1088
|
+
throw new TypeError('The HTTP response is invalid.')
|
|
1089
|
+
const status = result.status
|
|
1090
|
+
const entries = result.headers as [string, string][]
|
|
1091
|
+
const content = decode(result.body)
|
|
1092
|
+
let used = false
|
|
1093
|
+
const consume = (): Uint8Array => {
|
|
1094
|
+
if (used) throw new TypeError('Response body has already been used.')
|
|
1095
|
+
used = true
|
|
1096
|
+
return content.slice()
|
|
1097
|
+
}
|
|
1098
|
+
const responseHeaders = Object.freeze({
|
|
1099
|
+
get: (name: string) => {
|
|
1100
|
+
const values = entries.filter(([key]) => key == String(name).toLowerCase()).map(([, value]) => value)
|
|
1101
|
+
return values.length == 0 ? null : values.join(', ')
|
|
1102
|
+
},
|
|
1103
|
+
has: (name: string) => entries.some(([key]) => key == String(name).toLowerCase()),
|
|
1104
|
+
entries: () => entries.map(([name, value]) => [name, value])[Symbol.iterator](),
|
|
1105
|
+
keys: () => entries.map(([name]) => name)[Symbol.iterator](),
|
|
1106
|
+
values: () => entries.map(([, value]) => value)[Symbol.iterator](),
|
|
1107
|
+
forEach: (callback: (value: string, name: string) => void) => entries.forEach(([name, value]) => callback(value, name)),
|
|
1108
|
+
[Symbol.iterator]: () => entries.map(([name, value]) => [name, value])[Symbol.iterator](),
|
|
1109
|
+
})
|
|
1110
|
+
return Object.freeze({
|
|
1111
|
+
status,
|
|
1112
|
+
statusText: typeof result.statusText == 'string' ? result.statusText : '',
|
|
1113
|
+
ok: status >= 200 && status < 300,
|
|
1114
|
+
redirected: false,
|
|
1115
|
+
url: result.url,
|
|
1116
|
+
headers: responseHeaders,
|
|
1117
|
+
get bodyUsed() {
|
|
1118
|
+
return used
|
|
1119
|
+
},
|
|
1120
|
+
bytes: async () => consume(),
|
|
1121
|
+
arrayBuffer: async () => consume().buffer,
|
|
1122
|
+
text: async () => new TextDecoder().decode(consume()),
|
|
1123
|
+
json: async () => JSON.parse(new TextDecoder().decode(consume())) as unknown,
|
|
1124
|
+
})
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Serialized into the sandbox alongside the strict JSON snapshot helper. */
|
|
1129
|
+
export function createWorkflows(invoke: (payload: JsonValue) => Promise<RuntimeCapabilityResponse>) {
|
|
1130
|
+
return Object.freeze({
|
|
1131
|
+
async call(name: string, input: Readonly<Record<string, JsonValue>> = {}): Promise<JsonValue> {
|
|
1132
|
+
if (typeof name != 'string' || input == null || typeof input != 'object' || Array.isArray(input))
|
|
1133
|
+
throw Object.assign(new Error('Workflow name and input object are required.'), { code: 'capability.invalid' })
|
|
1134
|
+
const value = snapshotRuntimeResult(input)!
|
|
1135
|
+
return (await invoke({ name, input: value })).body
|
|
1136
|
+
},
|
|
1137
|
+
})
|
|
1138
|
+
}
|