@frontera-sdk/functions 1.50.84 → 1.51.1
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/index.ts +4 -0
- package/src/inputs.ts +65 -18
- package/src/manifest.ts +6 -5
- package/src/messages.ts +44 -0
- package/src/runtime-context.ts +150 -17
- package/src/testing.ts +24 -4
- package/src/types.ts +32 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.1",
|
|
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.1",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,12 @@ export {
|
|
|
12
12
|
export { createTestContext } from './testing'
|
|
13
13
|
export type { TestCall, TestContext, TestContextOptions } from './testing'
|
|
14
14
|
export {
|
|
15
|
+
DEFAULT_MAX_FILES,
|
|
16
|
+
MAX_FILES_CAP,
|
|
15
17
|
MAX_INPUT_BYTES,
|
|
16
18
|
checkInputFieldSpec,
|
|
19
|
+
isFileInputType,
|
|
20
|
+
maxFilesOf,
|
|
17
21
|
redactedInputKeys,
|
|
18
22
|
sanitizeInputsSchema,
|
|
19
23
|
validateInputValue,
|
package/src/inputs.ts
CHANGED
|
@@ -27,20 +27,39 @@ export type InputValidation =
|
|
|
27
27
|
* naming a non-empty string `fileId`). It deliberately does NOT authorize the id
|
|
28
28
|
* or check mime/size — those need the DB and the caller's workspace scope, so the
|
|
29
29
|
* SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference
|
|
30
|
-
* after this passes.
|
|
30
|
+
* after this passes. `files` is a list of the same reference; its count is
|
|
31
|
+
* checked in `validateInputValue`, which knows the field's `maxFiles`.
|
|
31
32
|
*/
|
|
33
|
+
const isFileRef = (v: unknown): boolean =>
|
|
34
|
+
typeof v === 'object' &&
|
|
35
|
+
v !== null &&
|
|
36
|
+
!Array.isArray(v) &&
|
|
37
|
+
typeof (v as { fileId?: unknown }).fileId === 'string' &&
|
|
38
|
+
(v as { fileId: string }).fileId.length > 0
|
|
39
|
+
|
|
32
40
|
export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
|
|
33
41
|
string: (v) => typeof v === 'string',
|
|
34
42
|
number: (v) => typeof v === 'number' && Number.isFinite(v),
|
|
35
43
|
boolean: (v) => typeof v === 'boolean',
|
|
36
44
|
object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
|
|
37
45
|
array: Array.isArray,
|
|
38
|
-
file:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
file: isFileRef,
|
|
47
|
+
files: (v) => Array.isArray(v) && v.every(isFileRef),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The most files a `files` input holds when its spec names no `maxFiles`. */
|
|
51
|
+
export const DEFAULT_MAX_FILES = 20
|
|
52
|
+
/** The highest `maxFiles` a `files` input may declare. */
|
|
53
|
+
export const MAX_FILES_CAP = 50
|
|
54
|
+
|
|
55
|
+
/** Whether an input of this type holds file references: one, or a list. */
|
|
56
|
+
export function isFileInputType(type: unknown): type is 'file' | 'files' {
|
|
57
|
+
return type === 'file' || type === 'files'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The most files a `files` input holds. */
|
|
61
|
+
export function maxFilesOf(spec: Pick<InputFieldSpec, 'maxFiles'>): number {
|
|
62
|
+
return spec.maxFiles ?? DEFAULT_MAX_FILES
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
|
|
@@ -48,7 +67,7 @@ const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
|
48
67
|
/** Lowercase snake — the one allowance kebab doesn't cover. */
|
|
49
68
|
const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
|
|
50
69
|
|
|
51
|
-
const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file'])
|
|
70
|
+
const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file', 'files'])
|
|
52
71
|
|
|
53
72
|
/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
|
|
54
73
|
* Anything else there is a warning, same philosophy as `manifest.ts`'s
|
|
@@ -63,6 +82,7 @@ const INPUT_SPEC_KEYS = new Set([
|
|
|
63
82
|
'redact',
|
|
64
83
|
'accept',
|
|
65
84
|
'maxBytes',
|
|
85
|
+
'maxFiles',
|
|
66
86
|
])
|
|
67
87
|
|
|
68
88
|
/**
|
|
@@ -129,6 +149,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
129
149
|
redact?: unknown
|
|
130
150
|
accept?: unknown
|
|
131
151
|
maxBytes?: unknown
|
|
152
|
+
maxFiles?: unknown
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
if (spec.description !== undefined && typeof spec.description !== 'string') {
|
|
@@ -136,7 +157,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {
|
|
139
|
-
errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file`)
|
|
160
|
+
errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file, files`)
|
|
140
161
|
return { errors, warnings }
|
|
141
162
|
}
|
|
142
163
|
const t = spec.type as InputFieldSpec['type']
|
|
@@ -196,12 +217,12 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
196
217
|
}
|
|
197
218
|
}
|
|
198
219
|
|
|
199
|
-
// `accept`/`maxBytes` are the file-only constraints
|
|
200
|
-
// type so a typo like `accept` on a string
|
|
201
|
-
// ignored. They constrain the UPLOAD, not the value on the run row (which is
|
|
220
|
+
// `accept`/`maxBytes` are the file-only constraints, for one file or a list
|
|
221
|
+
// of them — refused on any other type so a typo like `accept` on a string
|
|
222
|
+
// field is loud, not silently ignored. They constrain the UPLOAD, not the value on the run row (which is
|
|
202
223
|
// only a reference), so the server enforces them; here we check well-formedness.
|
|
203
224
|
if (spec.accept !== undefined) {
|
|
204
|
-
if (t
|
|
225
|
+
if (!isFileInputType(t)) {
|
|
205
226
|
errors.push(`input "${key}": accept is only valid for file inputs`)
|
|
206
227
|
} else if (
|
|
207
228
|
!Array.isArray(spec.accept)
|
|
@@ -212,18 +233,30 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
212
233
|
}
|
|
213
234
|
}
|
|
214
235
|
if (spec.maxBytes !== undefined) {
|
|
215
|
-
if (t
|
|
236
|
+
if (!isFileInputType(t)) {
|
|
216
237
|
errors.push(`input "${key}": maxBytes is only valid for file inputs`)
|
|
217
238
|
} else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
|
|
218
239
|
errors.push(`input "${key}": maxBytes must be a positive number`)
|
|
219
240
|
}
|
|
220
241
|
}
|
|
242
|
+
if (spec.maxFiles !== undefined) {
|
|
243
|
+
if (t !== 'files') {
|
|
244
|
+
errors.push(`input "${key}": maxFiles is only valid for files inputs`)
|
|
245
|
+
} else if (
|
|
246
|
+
typeof spec.maxFiles !== 'number'
|
|
247
|
+
|| !Number.isInteger(spec.maxFiles)
|
|
248
|
+
|| spec.maxFiles < 1
|
|
249
|
+
|| spec.maxFiles > MAX_FILES_CAP
|
|
250
|
+
) {
|
|
251
|
+
errors.push(`input "${key}": maxFiles must be a whole number from 1 to ${MAX_FILES_CAP}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
221
254
|
|
|
222
255
|
let defaultOk = true
|
|
223
256
|
if (spec.default !== undefined) {
|
|
224
|
-
// A `file` carries
|
|
225
|
-
// default is meaningless — refused rather than type-checked.
|
|
226
|
-
if (t
|
|
257
|
+
// A `file` or `files` input carries references to uploaded items, so a
|
|
258
|
+
// fixed literal default is meaningless — refused rather than type-checked.
|
|
259
|
+
if (isFileInputType(t)) {
|
|
227
260
|
errors.push(`input "${key}": a file input cannot have a default`)
|
|
228
261
|
defaultOk = false
|
|
229
262
|
} else if (!TYPE_CHECK[t](spec.default)) {
|
|
@@ -290,9 +323,23 @@ export function validateInputValue(
|
|
|
290
323
|
continue
|
|
291
324
|
}
|
|
292
325
|
if (!check(v)) {
|
|
293
|
-
errors.push(
|
|
326
|
+
errors.push(
|
|
327
|
+
spec.type === 'files' ? `"${key}" must be a list of files` : `"${key}" must be of type ${spec.type}`,
|
|
328
|
+
)
|
|
294
329
|
continue
|
|
295
330
|
}
|
|
331
|
+
if (spec.type === 'files') {
|
|
332
|
+
const count = (v as unknown[]).length
|
|
333
|
+
// An empty list says "no files", which a required input does not accept.
|
|
334
|
+
if (count === 0 && spec.required === true) {
|
|
335
|
+
errors.push(`"${key}" is required — add at least one file`)
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
if (count > maxFilesOf(spec)) {
|
|
339
|
+
errors.push(`"${key}" holds ${count} files — the most it takes is ${maxFilesOf(spec)}`)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
}
|
|
296
343
|
if (spec.enum && !spec.enum.includes(v as string | number)) {
|
|
297
344
|
errors.push(`"${key}" must be one of ${spec.enum.join(', ')}`)
|
|
298
345
|
continue
|
package/src/manifest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CronExpressionParser } from 'cron-parser'
|
|
2
|
-
import { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'
|
|
2
|
+
import { MAX_INPUT_BYTES, checkInputFieldSpec, isFileInputType } from './inputs'
|
|
3
3
|
|
|
4
4
|
const SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'
|
|
5
5
|
const NAME_RE = new RegExp(`^${SEGMENT}$`)
|
|
@@ -246,10 +246,11 @@ export function validateManifest(input: unknown): ValidationResult {
|
|
|
246
246
|
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
247
247
|
const spec = raw as { type?: unknown; required?: unknown; default?: unknown }
|
|
248
248
|
if (spec?.required === true && spec.default === undefined) {
|
|
249
|
-
// A `file` input can never carry a default (refused
|
|
250
|
-
// "add a default" remedy would send the author
|
|
251
|
-
// next validation error — name the two remedies
|
|
252
|
-
|
|
249
|
+
// A `file` or `files` input can never carry a default (refused
|
|
250
|
+
// above), so the "add a default" remedy would send the author
|
|
251
|
+
// straight into the next validation error — name the two remedies
|
|
252
|
+
// that actually work.
|
|
253
|
+
const remedy = isFileInputType(spec.type)
|
|
253
254
|
? 'A file input cannot have a default — make the input optional or the trigger manual.'
|
|
254
255
|
: 'Add a default or make the trigger manual.'
|
|
255
256
|
errors.push(
|
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,46 @@ 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
|
-
// a
|
|
966
|
-
//
|
|
1072
|
+
// refuses any fileId that is not among this run's own inputs (a `file`
|
|
1073
|
+
// input, or one item of a `files` input). In a dry dev run it returns
|
|
1074
|
+
// null, like every other ctx call.
|
|
967
1075
|
const res = await scoped('/ctx/file-resolve', {
|
|
968
1076
|
method: 'POST',
|
|
969
1077
|
body: JSON.stringify({ fileId: ref.fileId }),
|
|
970
1078
|
})
|
|
971
1079
|
if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
|
|
972
|
-
|
|
1080
|
+
const file = ((await res.json()) as {
|
|
973
1081
|
data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
|
|
974
1082
|
}).data.file
|
|
1083
|
+
if (!file) return null
|
|
1084
|
+
|
|
1085
|
+
// The signed URL never leaves this closure. Function code cannot open a
|
|
1086
|
+
// network socket in the runner, and a URL in a step row would be a live
|
|
1087
|
+
// credential — so the SDK does the read, over the same forwarder socket,
|
|
1088
|
+
// and hands back the bytes. `readFile` re-resolves each time it is
|
|
1089
|
+
// called, so a large file need not sit in memory unless the author asks.
|
|
1090
|
+
const readFile = async (): Promise<Response> => {
|
|
1091
|
+
const target = deps.socketPath ? readdressToForwarder(serviceUrl, file.signedUrl) : file.signedUrl
|
|
1092
|
+
const response = await fetch(target, deps.socketPath ? { unix: deps.socketPath } as RequestInit : undefined)
|
|
1093
|
+
if (!response.ok) throw new Error(`file ${ref.fileId} download → ${response.status}`)
|
|
1094
|
+
return response
|
|
1095
|
+
}
|
|
1096
|
+
const handle: import('./types').ResolvedFileHandle = {
|
|
1097
|
+
fileId: file.fileId,
|
|
1098
|
+
mimeType: file.mimeType,
|
|
1099
|
+
sizeBytes: file.sizeBytes,
|
|
1100
|
+
name: file.name,
|
|
1101
|
+
bytes: async () => new Uint8Array(await (await readFile()).arrayBuffer()),
|
|
1102
|
+
text: async () => (await readFile()).text(),
|
|
1103
|
+
stream: async () => {
|
|
1104
|
+
const body = (await readFile()).body
|
|
1105
|
+
if (!body) throw new Error(`file ${ref.fileId} download had no body`)
|
|
1106
|
+
return body
|
|
1107
|
+
},
|
|
1108
|
+
}
|
|
1109
|
+
return asFileHandle(handle)
|
|
975
1110
|
},
|
|
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.
|
|
1111
|
+
// The row records the file's identity, never its bytes or a way to read them.
|
|
979
1112
|
(out) =>
|
|
980
1113
|
out
|
|
981
1114
|
? { 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
|
@@ -78,21 +78,26 @@ export type Grant =
|
|
|
78
78
|
* philosophy as the grant grammar: small enough that a wrong shape is
|
|
79
79
|
* refusable with a sentence, wide enough for real parameters. */
|
|
80
80
|
export interface InputFieldSpec {
|
|
81
|
-
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
|
|
81
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'
|
|
82
82
|
/** Refused at run start when absent. Mutually exclusive with `default`. */
|
|
83
83
|
required?: boolean
|
|
84
84
|
/** Applied at run start when the field is absent. Cron runs rely on these.
|
|
85
|
-
* Not permitted on a `file` field — a file has no meaningful literal default. */
|
|
85
|
+
* Not permitted on a `file` or `files` field — a file has no meaningful literal default. */
|
|
86
86
|
default?: unknown
|
|
87
87
|
description?: string
|
|
88
88
|
/** Allowed values — string and number types only. */
|
|
89
89
|
enum?: readonly (string | number)[]
|
|
90
|
-
/** `file`
|
|
90
|
+
/** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
|
|
91
|
+
* On a `files` field it applies to each file.
|
|
91
92
|
* A declaration aid: the value on the run row is only a reference, so this is
|
|
92
93
|
* enforced server-side at upload and at run start, never against the value here. */
|
|
93
94
|
accept?: readonly string[]
|
|
94
|
-
/** `file`
|
|
95
|
+
/** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.
|
|
96
|
+
* On a `files` field it applies to each file. */
|
|
95
97
|
maxBytes?: number
|
|
98
|
+
/** `files` type only. The most files the field holds: a whole number from 1
|
|
99
|
+
* to 50, 20 when absent. */
|
|
100
|
+
maxFiles?: number
|
|
96
101
|
/**
|
|
97
102
|
* Mask this field's VALUE wherever a person reads the run.
|
|
98
103
|
*
|
|
@@ -233,15 +238,31 @@ export interface AgentHandle {
|
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
/**
|
|
236
|
-
* What `ctx.file(ref)` resolves to:
|
|
237
|
-
*
|
|
241
|
+
* What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch
|
|
242
|
+
* its bytes. `null` only in a dry dev run.
|
|
243
|
+
*
|
|
244
|
+
* There is no URL. In the deployed runner a Function cannot open a network
|
|
245
|
+
* socket, and a signed URL would be a live credential; the readers download
|
|
246
|
+
* over the runner's forwarder inside the SDK. Each reader fetches afresh, so a
|
|
247
|
+
* large file need not stay in memory.
|
|
238
248
|
*/
|
|
239
249
|
export interface ResolvedFileHandle {
|
|
240
250
|
fileId: string
|
|
241
|
-
signedUrl: string
|
|
242
251
|
mimeType: string
|
|
243
252
|
sizeBytes: number
|
|
244
253
|
name: string | null
|
|
254
|
+
/**
|
|
255
|
+
* The whole file as bytes.
|
|
256
|
+
*
|
|
257
|
+
* Each reader downloads the file again, through a link that expires about an
|
|
258
|
+
* hour after `ctx.file` returned the handle. Read it soon after, or call
|
|
259
|
+
* `ctx.file` again rather than keeping a handle across a long wait.
|
|
260
|
+
*/
|
|
261
|
+
bytes(): Promise<Uint8Array>
|
|
262
|
+
/** The whole file decoded as UTF-8 text. */
|
|
263
|
+
text(): Promise<string>
|
|
264
|
+
/** The file as a stream, for reading large files without holding them in memory. */
|
|
265
|
+
stream(): Promise<ReadableStream<Uint8Array>>
|
|
245
266
|
}
|
|
246
267
|
|
|
247
268
|
/**
|
|
@@ -472,9 +493,10 @@ export interface AutomationContext {
|
|
|
472
493
|
log(message: string, data?: Record<string, unknown>): Promise<void>
|
|
473
494
|
agent(slug: string): AgentHandle
|
|
474
495
|
/**
|
|
475
|
-
* Resolve one of THIS run's
|
|
476
|
-
* authoritative mime/size)
|
|
477
|
-
*
|
|
496
|
+
* Resolve one of THIS run's files to a readable form (signed URL +
|
|
497
|
+
* authoritative mime/size): the value of a `file` input, or one item of a
|
|
498
|
+
* `files` input. No grant — it only reads files the run was given; any other
|
|
499
|
+
* fileId is refused. `null` in a dry dev run.
|
|
478
500
|
*/
|
|
479
501
|
file(ref: FileRef): Promise<ResolvedFileHandle | null>
|
|
480
502
|
/** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */
|