@frontera-sdk/functions 1.50.84 → 1.51.0
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/package.json +2 -2
- package/src/messages.ts +44 -0
- package/src/runtime-context.ts +149 -17
- package/src/testing.ts +24 -4
- package/src/types.ts +19 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
4
4
|
"description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"typescript": "^5.9.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@frontera-sdk/blueprint": "1.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.51.0",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/messages.ts
CHANGED
|
@@ -143,3 +143,47 @@ export function agentReplayMismatchMessage(agentSlug: string, originalSlug: stri
|
|
|
143
143
|
'they are recorded once and replayed.'
|
|
144
144
|
)
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A step that returned a `ctx.file` handle.
|
|
149
|
+
*
|
|
150
|
+
* A step's result is kept as JSON and replayed, and JSON cannot hold the
|
|
151
|
+
* handle's readers, so the run would fail one execution later with "text is
|
|
152
|
+
* not a function", far from the step that caused it. Both fixes are named,
|
|
153
|
+
* because which one fits depends on whether the bytes are needed later.
|
|
154
|
+
*/
|
|
155
|
+
export function fileHandleInStepMessage(stepName: string): string {
|
|
156
|
+
return (
|
|
157
|
+
`Step "${stepName}" returned a ctx.file handle. A step's result is saved as JSON, which keeps the ` +
|
|
158
|
+
"file's details but drops text(), bytes() and stream(). Read the file inside the step and return " +
|
|
159
|
+
'what you need from it, or call ctx.file outside the step.'
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Code reading `ctx.file(...).signedUrl`, which a handle no longer has.
|
|
165
|
+
*
|
|
166
|
+
* A bundle deployed before the change still reads it. Without this it gets
|
|
167
|
+
* `undefined`, and fails later in `fetch(undefined)` with nothing pointing
|
|
168
|
+
* back here; the readers are named so the fix is plain.
|
|
169
|
+
*/
|
|
170
|
+
export function signedUrlRemovedMessage(): string {
|
|
171
|
+
return (
|
|
172
|
+
'ctx.file() no longer returns signedUrl: Function code cannot open network connections of its own. ' +
|
|
173
|
+
'Read the file with the handle instead: await file.text(), await file.bytes() or await file.stream().'
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A `createTestContext` file stub written for the handle's old shape.
|
|
179
|
+
*
|
|
180
|
+
* An existing test passing `{ signedUrl, sizeBytes }` has no `content`, and
|
|
181
|
+
* would otherwise fail inside `ctx.file` as a bare TypeError on `undefined`.
|
|
182
|
+
*/
|
|
183
|
+
export function oldFileStubMessage(fileId: string): string {
|
|
184
|
+
return (
|
|
185
|
+
`The file stub for "${fileId}" has no content. ctx.file now returns the file's bytes rather than a signedUrl, ` +
|
|
186
|
+
`so stub it with its content: files: { '${fileId}': { content: '…', mimeType: '…', name: null } }. ` +
|
|
187
|
+
'sizeBytes is computed from the content.'
|
|
188
|
+
)
|
|
189
|
+
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
lostStepRowMessage as lostStepRowMessageText,
|
|
28
28
|
duplicateSubmissionMessage as duplicateSubmissionMessageText,
|
|
29
29
|
emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
|
|
30
|
+
fileHandleInStepMessage,
|
|
31
|
+
signedUrlRemovedMessage,
|
|
30
32
|
} from './messages'
|
|
31
33
|
import type {
|
|
32
34
|
ActionSubmission,
|
|
@@ -187,8 +189,9 @@ function brandTranscript(transcript: ConversationTranscript | null): Conversatio
|
|
|
187
189
|
return transcript
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
+
/** How far a step result is searched for something ctx handed out: a transcript, or a file handle. */
|
|
193
|
+
const RESULT_SEARCH_DEPTH = 4
|
|
194
|
+
const RESULT_SEARCH_NODES = 200
|
|
192
195
|
|
|
193
196
|
/**
|
|
194
197
|
* How many handed-out turns a step result carries, or null when it carries
|
|
@@ -223,7 +226,7 @@ function transcriptTurnsIn(
|
|
|
223
226
|
// Any object is searched, not only plain ones: a class instance serializes
|
|
224
227
|
// its own fields, and a turn could be one of them.
|
|
225
228
|
for (const child of objectChildren(value)) {
|
|
226
|
-
if (depth >=
|
|
229
|
+
if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) {
|
|
227
230
|
// Something here is left unsearched. The nodes already queued are
|
|
228
231
|
// still visited, so a transcript among them is still counted.
|
|
229
232
|
complete = false
|
|
@@ -236,6 +239,54 @@ function transcriptTurnsIn(
|
|
|
236
239
|
return complete ? { kind: 'clean' } : { kind: 'not-inspected' }
|
|
237
240
|
}
|
|
238
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Every handle `ctx.file` returned. Branded, like a transcript, so the check
|
|
244
|
+
* below is exact: the author's own `{ fileId, bytes }` is not a handle.
|
|
245
|
+
*/
|
|
246
|
+
const fileHandles = new WeakSet<object>()
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Finish a handle `ctx.file` returns: brand it, and make the `signedUrl` it no
|
|
250
|
+
* longer has say where it went. Exported for the test context, which builds
|
|
251
|
+
* its own handles and must behave the same.
|
|
252
|
+
*
|
|
253
|
+
* The getter is non-enumerable, so a step result, a trace summary or
|
|
254
|
+
* `JSON.stringify` never reads it; only code that asks for `signedUrl` does.
|
|
255
|
+
*/
|
|
256
|
+
export function asFileHandle<T extends object>(handle: T): T {
|
|
257
|
+
fileHandles.add(handle)
|
|
258
|
+
Object.defineProperty(handle, 'signedUrl', {
|
|
259
|
+
enumerable: false,
|
|
260
|
+
get: () => {
|
|
261
|
+
throw new Error(signedUrlRemovedMessage())
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
return handle
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether a step result carries a `ctx.file` handle, within the same bounds as
|
|
269
|
+
* `transcriptTurnsIn`.
|
|
270
|
+
*
|
|
271
|
+
* A step's result is kept as JSON and replayed, and JSON cannot hold a
|
|
272
|
+
* function: the handle comes back with its details and without `text()`,
|
|
273
|
+
* `bytes()` or `stream()`, so the run fails one execution later with "text is
|
|
274
|
+
* not a function". Caught here, it fails at the step that caused it.
|
|
275
|
+
*/
|
|
276
|
+
export function carriesFileHandle(out: unknown): boolean {
|
|
277
|
+
if (typeof out !== 'object' || out === null) return false
|
|
278
|
+
const queue: Array<{ value: object; depth: number }> = [{ value: out, depth: 0 }]
|
|
279
|
+
for (let next = 0; next < queue.length; next++) {
|
|
280
|
+
const { value, depth } = queue[next]!
|
|
281
|
+
if (fileHandles.has(value)) return true
|
|
282
|
+
for (const child of objectChildren(value)) {
|
|
283
|
+
if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) break
|
|
284
|
+
queue.push({ value: child, depth: depth + 1 })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
|
|
239
290
|
/** The object-valued children of an array or object, one at a time. */
|
|
240
291
|
function* objectChildren(value: object): Generator<object> {
|
|
241
292
|
if (Array.isArray(value)) {
|
|
@@ -349,6 +400,32 @@ const AGENT_TURN_FINISHED_EVENT = 'agent/turn.finished'
|
|
|
349
400
|
*/
|
|
350
401
|
const KEEPALIVE_HEADER = 'x-ctx-keepalive'
|
|
351
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Every header this module sends to the service on a Function's own authority.
|
|
405
|
+
*
|
|
406
|
+
* The runner's forwarder passes on exactly these and drops the rest, so this
|
|
407
|
+
* list is the contract between the two, owned here. The request types below
|
|
408
|
+
* accept no other name: a header added to a call without adding it here fails
|
|
409
|
+
* to compile, instead of being dropped by the forwarder in deployed runs only.
|
|
410
|
+
*/
|
|
411
|
+
export const FUNCTION_SERVICE_HEADERS = [
|
|
412
|
+
'content-type',
|
|
413
|
+
'x-automation-run-token',
|
|
414
|
+
'x-workspace-id',
|
|
415
|
+
KEEPALIVE_HEADER,
|
|
416
|
+
] as const
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Sent only by a process holding the runner's secret (the runner itself, on
|
|
420
|
+
* its own writes). A Function process never has it, so it is not a Function
|
|
421
|
+
* header and the forwarder never passes it on.
|
|
422
|
+
*/
|
|
423
|
+
const RUNNER_TOKEN_HEADER = 'x-automation-runner-token'
|
|
424
|
+
|
|
425
|
+
type ServiceCallHeaders = Partial<Record<(typeof FUNCTION_SERVICE_HEADERS)[number] | typeof RUNNER_TOKEN_HEADER, string>>
|
|
426
|
+
/** A request to the service: `RequestInit` with its headers held to the names above. */
|
|
427
|
+
type ServiceCallInit = Omit<RequestInit, 'headers'> & { headers?: ServiceCallHeaders }
|
|
428
|
+
|
|
352
429
|
type AgentRunOptions = { files?: ReadonlyArray<{ fileId: string }>; timeoutMs?: number }
|
|
353
430
|
|
|
354
431
|
type AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked' | 'cancelled'
|
|
@@ -416,6 +493,31 @@ interface Deps {
|
|
|
416
493
|
* because it has nothing to pass.
|
|
417
494
|
*/
|
|
418
495
|
runnerToken?: string
|
|
496
|
+
/**
|
|
497
|
+
* The Unix socket the runner's forwarder listens on. Inside the deployed
|
|
498
|
+
* runner a Function process is denied internet sockets and reaches the
|
|
499
|
+
* forwarder only here, so every `ctx` call and `ctx.file` read goes over it.
|
|
500
|
+
* Absent (the CLI's dev worker, tests): calls go straight to `serviceUrl`.
|
|
501
|
+
*/
|
|
502
|
+
socketPath?: string
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The path under the forwarder that signed storage reads go to. Owned here and
|
|
507
|
+
* imported by the runner's forwarder, which routes on it, so the two cannot
|
|
508
|
+
* drift apart.
|
|
509
|
+
*/
|
|
510
|
+
export const FORWARDER_STORAGE_PATH = '/storage'
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* A signed storage URL, pointed at the forwarder's storage path under
|
|
514
|
+
* `forwarderUrl`: same path and signature, forwarder host. The forwarder sends
|
|
515
|
+
* it on to real storage. In a Function process the service URL IS the
|
|
516
|
+
* forwarder, and its host is a placeholder; the Unix socket decides where it goes.
|
|
517
|
+
*/
|
|
518
|
+
function readdressToForwarder(forwarderUrl: string, signedUrl: string): string {
|
|
519
|
+
const signed = new URL(signedUrl)
|
|
520
|
+
return `${forwarderUrl}${FORWARDER_STORAGE_PATH}${signed.pathname}${signed.search}`
|
|
419
521
|
}
|
|
420
522
|
|
|
421
523
|
export {
|
|
@@ -443,6 +545,11 @@ class GrantError extends Error {
|
|
|
443
545
|
|
|
444
546
|
export function buildContext(deps: Deps): AutomationContext {
|
|
445
547
|
const serviceUrl = deps.serviceUrl ?? SERVICE_URL
|
|
548
|
+
// Every call to the service goes through here. In the runner it rides the
|
|
549
|
+
// forwarder's Unix socket (the one path left open); elsewhere it is a plain
|
|
550
|
+
// fetch. `unix` is a Bun extension to RequestInit, hence the cast.
|
|
551
|
+
const serviceFetch = (path: string, init?: ServiceCallInit): Promise<Response> =>
|
|
552
|
+
fetch(`${serviceUrl}${path}`, deps.socketPath ? { ...init, unix: deps.socketPath } as RequestInit : init)
|
|
446
553
|
const requireGrant = (grant: string) => {
|
|
447
554
|
if (!deps.grants.includes(grant)) throw new GrantError(grant)
|
|
448
555
|
}
|
|
@@ -460,10 +567,10 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
460
567
|
* a PRESENT runner header as an assertion to verify, so a blank one would be
|
|
461
568
|
* a 401 instead of a fall-through to the run token.
|
|
462
569
|
*/
|
|
463
|
-
const runnerHeaders:
|
|
570
|
+
const runnerHeaders: ServiceCallHeaders = {
|
|
464
571
|
'content-type': 'application/json',
|
|
465
572
|
'x-automation-run-token': deps.runToken,
|
|
466
|
-
...(deps.runnerToken ? {
|
|
573
|
+
...(deps.runnerToken ? { [RUNNER_TOKEN_HEADER]: deps.runnerToken } : {}),
|
|
467
574
|
}
|
|
468
575
|
|
|
469
576
|
/**
|
|
@@ -503,7 +610,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
503
610
|
const recordStep = async (body: Record<string, unknown>): Promise<string> => {
|
|
504
611
|
const parentStepId = stepScope.getStore()?.stepId
|
|
505
612
|
try {
|
|
506
|
-
const res = await
|
|
613
|
+
const res = await serviceFetch(`/v1/automations/runner/runs/${deps.runId}/steps`, {
|
|
507
614
|
method: 'POST',
|
|
508
615
|
headers: runnerHeaders,
|
|
509
616
|
body: JSON.stringify({
|
|
@@ -553,8 +660,8 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
553
660
|
...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
|
|
554
661
|
}
|
|
555
662
|
try {
|
|
556
|
-
const res = await
|
|
557
|
-
|
|
663
|
+
const res = await serviceFetch(
|
|
664
|
+
`/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
|
|
558
665
|
{ method: 'POST', headers: runnerHeaders, body: JSON.stringify(payload) },
|
|
559
666
|
)
|
|
560
667
|
if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)
|
|
@@ -634,13 +741,13 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
634
741
|
* token is the entire authority of this call, and a caller that could replace
|
|
635
742
|
* it could replace the run's scope.
|
|
636
743
|
*/
|
|
637
|
-
const scoped = (path: string, init?:
|
|
744
|
+
const scoped = (path: string, init?: ServiceCallInit) =>
|
|
638
745
|
// `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,
|
|
639
746
|
// which is prefixed, because they authenticate by run token rather than by
|
|
640
747
|
// session. Addressing them as `/automations/...` reaches the session-guarded
|
|
641
748
|
// router instead and 404s. This is only caught end to end: both sides pass
|
|
642
749
|
// their own tests, and the mismatch is between them.
|
|
643
|
-
|
|
750
|
+
serviceFetch(`/v1/automations/runner${path}`, {
|
|
644
751
|
...init,
|
|
645
752
|
headers: {
|
|
646
753
|
...(init?.headers ?? {}),
|
|
@@ -707,6 +814,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
707
814
|
{ stepId, stepName: name, submitted: new Set<string>() },
|
|
708
815
|
fn,
|
|
709
816
|
)
|
|
817
|
+
if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))
|
|
710
818
|
await completeStep(stepId, {
|
|
711
819
|
status: 'ok',
|
|
712
820
|
durationMs: Date.now() - t0,
|
|
@@ -961,21 +1069,45 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
961
1069
|
file: (ref: { fileId: string }) =>
|
|
962
1070
|
step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {
|
|
963
1071
|
// No grant: this only reads a file the run was GIVEN — the route
|
|
964
|
-
// refuses any fileId that is not among this run's own inputs.
|
|
965
|
-
//
|
|
966
|
-
// dry dev run it returns null, like every other ctx call.
|
|
1072
|
+
// refuses any fileId that is not among this run's own inputs. In a dry
|
|
1073
|
+
// dev run it returns null, like every other ctx call.
|
|
967
1074
|
const res = await scoped('/ctx/file-resolve', {
|
|
968
1075
|
method: 'POST',
|
|
969
1076
|
body: JSON.stringify({ fileId: ref.fileId }),
|
|
970
1077
|
})
|
|
971
1078
|
if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
|
|
972
|
-
|
|
1079
|
+
const file = ((await res.json()) as {
|
|
973
1080
|
data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
|
|
974
1081
|
}).data.file
|
|
1082
|
+
if (!file) return null
|
|
1083
|
+
|
|
1084
|
+
// The signed URL never leaves this closure. Function code cannot open a
|
|
1085
|
+
// network socket in the runner, and a URL in a step row would be a live
|
|
1086
|
+
// credential — so the SDK does the read, over the same forwarder socket,
|
|
1087
|
+
// and hands back the bytes. `readFile` re-resolves each time it is
|
|
1088
|
+
// called, so a large file need not sit in memory unless the author asks.
|
|
1089
|
+
const readFile = async (): Promise<Response> => {
|
|
1090
|
+
const target = deps.socketPath ? readdressToForwarder(serviceUrl, file.signedUrl) : file.signedUrl
|
|
1091
|
+
const response = await fetch(target, deps.socketPath ? { unix: deps.socketPath } as RequestInit : undefined)
|
|
1092
|
+
if (!response.ok) throw new Error(`file ${ref.fileId} download → ${response.status}`)
|
|
1093
|
+
return response
|
|
1094
|
+
}
|
|
1095
|
+
const handle: import('./types').ResolvedFileHandle = {
|
|
1096
|
+
fileId: file.fileId,
|
|
1097
|
+
mimeType: file.mimeType,
|
|
1098
|
+
sizeBytes: file.sizeBytes,
|
|
1099
|
+
name: file.name,
|
|
1100
|
+
bytes: async () => new Uint8Array(await (await readFile()).arrayBuffer()),
|
|
1101
|
+
text: async () => (await readFile()).text(),
|
|
1102
|
+
stream: async () => {
|
|
1103
|
+
const body = (await readFile()).body
|
|
1104
|
+
if (!body) throw new Error(`file ${ref.fileId} download had no body`)
|
|
1105
|
+
return body
|
|
1106
|
+
},
|
|
1107
|
+
}
|
|
1108
|
+
return asFileHandle(handle)
|
|
975
1109
|
},
|
|
976
|
-
//
|
|
977
|
-
// short-lived credential, and a row is exactly the wrong place for one:
|
|
978
|
-
// details are rendered verbatim and kept for as long as the run is.
|
|
1110
|
+
// The row records the file's identity, never its bytes or a way to read them.
|
|
979
1111
|
(out) =>
|
|
980
1112
|
out
|
|
981
1113
|
? { fileId: out.fileId, mimeType: out.mimeType, sizeBytes: out.sizeBytes, name: out.name }
|
package/src/testing.ts
CHANGED
|
@@ -3,9 +3,12 @@ import {
|
|
|
3
3
|
duplicateStepMessage,
|
|
4
4
|
duplicateSubmissionMessage,
|
|
5
5
|
emptySubmissionKeyMessage,
|
|
6
|
+
fileHandleInStepMessage,
|
|
6
7
|
missingGrantMessage,
|
|
8
|
+
oldFileStubMessage,
|
|
7
9
|
submitOutsideStepMessage,
|
|
8
10
|
} from './messages'
|
|
11
|
+
import { asFileHandle, carriesFileHandle } from './runtime-context'
|
|
9
12
|
import type {
|
|
10
13
|
ActionSubmission,
|
|
11
14
|
ActionSubmitResult,
|
|
@@ -77,7 +80,7 @@ export interface TestContextOptions {
|
|
|
77
80
|
agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
|
|
78
81
|
/** Per-fileId answers for `ctx.file`: what the resolved handle should carry.
|
|
79
82
|
* An unstubbed fileId throws — a fabricated handle is a false pass. */
|
|
80
|
-
files?: Record<string, {
|
|
83
|
+
files?: Record<string, { content: string | Uint8Array; mimeType: string; name: string | null }>
|
|
81
84
|
/**
|
|
82
85
|
* Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.
|
|
83
86
|
* An unstubbed capability throws rather than answering — a fabricated
|
|
@@ -163,6 +166,9 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
163
166
|
return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
|
|
164
167
|
try {
|
|
165
168
|
const out = await fn()
|
|
169
|
+
// Nothing here replays a step, so the handle would keep working in
|
|
170
|
+
// a test and break in a deployed run.
|
|
171
|
+
if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))
|
|
166
172
|
calls.push({ kind: 'step', label: name, status: 'ok' })
|
|
167
173
|
return out
|
|
168
174
|
} catch (err) {
|
|
@@ -194,11 +200,25 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
194
200
|
// stubbed would otherwise pass on made-up bytes.
|
|
195
201
|
if (!stub) {
|
|
196
202
|
throw new Error(
|
|
197
|
-
`No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': {
|
|
198
|
-
"mimeType: '…',
|
|
203
|
+
`No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': { content: '…', ` +
|
|
204
|
+
"mimeType: '…', name: null } } to createTestContext.",
|
|
199
205
|
)
|
|
200
206
|
}
|
|
201
|
-
|
|
207
|
+
// A stub written for the handle's old shape ({ signedUrl, sizeBytes })
|
|
208
|
+
// has no content; without this, reading it fails as a bare TypeError.
|
|
209
|
+
if (typeof stub.content !== 'string' && !(stub.content instanceof Uint8Array)) {
|
|
210
|
+
throw new Error(oldFileStubMessage(ref.fileId))
|
|
211
|
+
}
|
|
212
|
+
const bytes = typeof stub.content === 'string' ? new TextEncoder().encode(stub.content) : stub.content
|
|
213
|
+
return asFileHandle({
|
|
214
|
+
fileId: ref.fileId,
|
|
215
|
+
mimeType: stub.mimeType,
|
|
216
|
+
name: stub.name,
|
|
217
|
+
sizeBytes: bytes.byteLength,
|
|
218
|
+
bytes: async () => bytes,
|
|
219
|
+
text: async () => new TextDecoder().decode(bytes),
|
|
220
|
+
stream: async () => new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(bytes); controller.close() } }),
|
|
221
|
+
})
|
|
202
222
|
},
|
|
203
223
|
|
|
204
224
|
agent(slug: string) {
|
package/src/types.ts
CHANGED
|
@@ -233,15 +233,31 @@ export interface AgentHandle {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
|
-
* What `ctx.file(ref)` resolves to:
|
|
237
|
-
*
|
|
236
|
+
* What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch
|
|
237
|
+
* its bytes. `null` only in a dry dev run.
|
|
238
|
+
*
|
|
239
|
+
* There is no URL. In the deployed runner a Function cannot open a network
|
|
240
|
+
* socket, and a signed URL would be a live credential; the readers download
|
|
241
|
+
* over the runner's forwarder inside the SDK. Each reader fetches afresh, so a
|
|
242
|
+
* large file need not stay in memory.
|
|
238
243
|
*/
|
|
239
244
|
export interface ResolvedFileHandle {
|
|
240
245
|
fileId: string
|
|
241
|
-
signedUrl: string
|
|
242
246
|
mimeType: string
|
|
243
247
|
sizeBytes: number
|
|
244
248
|
name: string | null
|
|
249
|
+
/**
|
|
250
|
+
* The whole file as bytes.
|
|
251
|
+
*
|
|
252
|
+
* Each reader downloads the file again, through a link that expires about an
|
|
253
|
+
* hour after `ctx.file` returned the handle. Read it soon after, or call
|
|
254
|
+
* `ctx.file` again rather than keeping a handle across a long wait.
|
|
255
|
+
*/
|
|
256
|
+
bytes(): Promise<Uint8Array>
|
|
257
|
+
/** The whole file decoded as UTF-8 text. */
|
|
258
|
+
text(): Promise<string>
|
|
259
|
+
/** The file as a stream, for reading large files without holding them in memory. */
|
|
260
|
+
stream(): Promise<ReadableStream<Uint8Array>>
|
|
245
261
|
}
|
|
246
262
|
|
|
247
263
|
/**
|