@frontera-sdk/automation 1.45.14 → 1.46.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/inputs.ts +48 -4
- package/src/manifest.ts +8 -2
- package/src/runtime-context.ts +283 -11
- package/src/testing.ts +18 -1
- package/src/types.ts +59 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/automation",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"description": "Author Frontera automations: 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.46.0",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/inputs.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type InputValidation =
|
|
|
13
13
|
| { ok: false; errors: string[] }
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* The
|
|
16
|
+
* The input types' runtime validators, keyed by `InputFieldSpec['type']`.
|
|
17
17
|
*
|
|
18
18
|
* Single source of truth for "does this value have this type" — `validateInputValue`'s
|
|
19
19
|
* value check and `checkInputFieldSpec`'s default/enum checks all call this instead
|
|
@@ -22,6 +22,12 @@ export type InputValidation =
|
|
|
22
22
|
* deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`
|
|
23
23
|
* used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why
|
|
24
24
|
* this is exported rather than kept module-private.
|
|
25
|
+
*
|
|
26
|
+
* `file` is shape-only here: it confirms the value is a `FileRef` (a plain object
|
|
27
|
+
* naming a non-empty string `fileId`). It deliberately does NOT authorize the id
|
|
28
|
+
* or check mime/size — those need the DB and the caller's workspace scope, so the
|
|
29
|
+
* SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference
|
|
30
|
+
* after this passes.
|
|
25
31
|
*/
|
|
26
32
|
export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
|
|
27
33
|
string: (v) => typeof v === 'string',
|
|
@@ -29,6 +35,12 @@ export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean>
|
|
|
29
35
|
boolean: (v) => typeof v === 'boolean',
|
|
30
36
|
object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
|
|
31
37
|
array: Array.isArray,
|
|
38
|
+
file: (v) =>
|
|
39
|
+
typeof v === 'object' &&
|
|
40
|
+
v !== null &&
|
|
41
|
+
!Array.isArray(v) &&
|
|
42
|
+
typeof (v as { fileId?: unknown }).fileId === 'string' &&
|
|
43
|
+
(v as { fileId: string }).fileId.length > 0,
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
|
|
@@ -36,7 +48,7 @@ const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
|
36
48
|
/** Lowercase snake — the one allowance kebab doesn't cover. */
|
|
37
49
|
const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
|
|
38
50
|
|
|
39
|
-
const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array'])
|
|
51
|
+
const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file'])
|
|
40
52
|
|
|
41
53
|
/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
|
|
42
54
|
* Anything else there is a warning, same philosophy as `manifest.ts`'s
|
|
@@ -49,6 +61,8 @@ const INPUT_SPEC_KEYS = new Set([
|
|
|
49
61
|
'description',
|
|
50
62
|
'enum',
|
|
51
63
|
'redact',
|
|
64
|
+
'accept',
|
|
65
|
+
'maxBytes',
|
|
52
66
|
])
|
|
53
67
|
|
|
54
68
|
/**
|
|
@@ -113,6 +127,8 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
113
127
|
enum?: unknown
|
|
114
128
|
description?: unknown
|
|
115
129
|
redact?: unknown
|
|
130
|
+
accept?: unknown
|
|
131
|
+
maxBytes?: unknown
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
if (spec.description !== undefined && typeof spec.description !== 'string') {
|
|
@@ -120,7 +136,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
120
136
|
}
|
|
121
137
|
|
|
122
138
|
if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {
|
|
123
|
-
errors.push(`input "${key}": type must be one of string, number, boolean, object, array`)
|
|
139
|
+
errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file`)
|
|
124
140
|
return { errors, warnings }
|
|
125
141
|
}
|
|
126
142
|
const t = spec.type as InputFieldSpec['type']
|
|
@@ -180,9 +196,37 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
// `accept`/`maxBytes` are the file-only constraints — refused on any other
|
|
200
|
+
// type so a typo like `accept` on a string field is loud, not silently
|
|
201
|
+
// ignored. They constrain the UPLOAD, not the value on the run row (which is
|
|
202
|
+
// only a reference), so the server enforces them; here we check well-formedness.
|
|
203
|
+
if (spec.accept !== undefined) {
|
|
204
|
+
if (t !== 'file') {
|
|
205
|
+
errors.push(`input "${key}": accept is only valid for file inputs`)
|
|
206
|
+
} else if (
|
|
207
|
+
!Array.isArray(spec.accept)
|
|
208
|
+
|| spec.accept.length === 0
|
|
209
|
+
|| spec.accept.some((a) => typeof a !== 'string' || a.length === 0)
|
|
210
|
+
) {
|
|
211
|
+
errors.push(`input "${key}": accept must be a non-empty array of MIME patterns`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (spec.maxBytes !== undefined) {
|
|
215
|
+
if (t !== 'file') {
|
|
216
|
+
errors.push(`input "${key}": maxBytes is only valid for file inputs`)
|
|
217
|
+
} else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
|
|
218
|
+
errors.push(`input "${key}": maxBytes must be a positive number`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
183
222
|
let defaultOk = true
|
|
184
223
|
if (spec.default !== undefined) {
|
|
185
|
-
|
|
224
|
+
// A `file` carries a reference to an uploaded item, so a fixed literal
|
|
225
|
+
// default is meaningless — refused rather than type-checked.
|
|
226
|
+
if (t === 'file') {
|
|
227
|
+
errors.push(`input "${key}": a file input cannot have a default`)
|
|
228
|
+
defaultOk = false
|
|
229
|
+
} else if (!TYPE_CHECK[t](spec.default)) {
|
|
186
230
|
errors.push(`input "${key}": default must match the declared type`)
|
|
187
231
|
defaultOk = false
|
|
188
232
|
}
|
package/src/manifest.ts
CHANGED
|
@@ -244,11 +244,17 @@ export function validateManifest(input: unknown): ValidationResult {
|
|
|
244
244
|
const trig = m.trigger as { cron?: unknown } | undefined
|
|
245
245
|
if (typeof trig?.cron === 'string') {
|
|
246
246
|
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
247
|
-
const spec = raw as { required?: unknown; default?: unknown }
|
|
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 above), so the
|
|
250
|
+
// "add a default" remedy would send the author straight into the
|
|
251
|
+
// next validation error — name the two remedies that actually work.
|
|
252
|
+
const remedy = spec.type === 'file'
|
|
253
|
+
? 'A file input cannot have a default — make the input optional or the trigger manual.'
|
|
254
|
+
: 'Add a default or make the trigger manual.'
|
|
249
255
|
errors.push(
|
|
250
256
|
`input "${key}" is required with no default, and the trigger is a cron — `
|
|
251
|
-
+
|
|
257
|
+
+ `cron has nobody to ask. ${remedy}`,
|
|
252
258
|
)
|
|
253
259
|
}
|
|
254
260
|
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -38,6 +38,143 @@ import type {
|
|
|
38
38
|
|
|
39
39
|
const SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* How much of a summary one step row may carry.
|
|
43
|
+
*
|
|
44
|
+
* A step row is telemetry, not storage: it is read by a human scanning a run,
|
|
45
|
+
* and it is written on every ctx call of every run. So what it records about a
|
|
46
|
+
* call is bounded rather than complete, and a value that does not fit is
|
|
47
|
+
* reported as a SIZE — which tells the reader the value existed and was large,
|
|
48
|
+
* instead of showing them the empty panel this cap exists to avoid.
|
|
49
|
+
*/
|
|
50
|
+
const DETAIL_MAX_CHARS = 4_000
|
|
51
|
+
/** How much of an agent's answer a row keeps, so a trace can be read without
|
|
52
|
+
* re-running the agent. */
|
|
53
|
+
const PREVIEW_MAX_CHARS = 500
|
|
54
|
+
|
|
55
|
+
/** JSON length of a value, or null when it does not serialize (a cycle, a BigInt). */
|
|
56
|
+
function jsonSize(value: unknown): number | null {
|
|
57
|
+
try {
|
|
58
|
+
const json = JSON.stringify(value)
|
|
59
|
+
return json === undefined ? null : json.length
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A NUL and an unpaired surrogate, which Postgres `jsonb` REFUSES rather than
|
|
67
|
+
* escapes: `\u0000 cannot be converted to text`, and `Unicode low surrogate
|
|
68
|
+
* must follow a high surrogate`. Both verified against the repo's own Postgres.
|
|
69
|
+
*/
|
|
70
|
+
const NUL = /\u0000/g
|
|
71
|
+
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Make a value storable, by removing the two byte classes `jsonb` rejects.
|
|
75
|
+
*
|
|
76
|
+
* Both are reachable from what a row now carries: `preview` cuts at a fixed
|
|
77
|
+
* offset and can split an emoji in half, and an author's step result may hold
|
|
78
|
+
* text pulled out of a PDF or a Postgres `text` column, where a NUL is routine.
|
|
79
|
+
*
|
|
80
|
+
* What a refusal costs is not the detail. A rejected insert loses the whole
|
|
81
|
+
* ROW — `recordStep` swallows a non-2xx by contract — and a rejected completion
|
|
82
|
+
* leaves an author's step reading `running` forever inside a run that finished,
|
|
83
|
+
* because the update is guarded on that status. Either is a worse trace than
|
|
84
|
+
* the empty panel this file set out to fix, so the scrub sits at the write
|
|
85
|
+
* rather than in each summarizer: one rule covering summaries, error messages
|
|
86
|
+
* and `ctx.log`'s author-supplied data alike.
|
|
87
|
+
*/
|
|
88
|
+
function jsonbSafeText(text: string): string {
|
|
89
|
+
return text.replace(NUL, '').replace(UNPAIRED_SURROGATE, '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function jsonbSafe(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
|
|
93
|
+
if (typeof value === 'string') return jsonbSafeText(value)
|
|
94
|
+
if (value === null || typeof value !== 'object') return value
|
|
95
|
+
// A cycle cannot be serialized at all: dropped here, named, rather than left
|
|
96
|
+
// to throw inside the fetch that was carrying an otherwise-good row.
|
|
97
|
+
if (seen.has(value)) return undefined
|
|
98
|
+
// The ANCESTOR stack, not every node ever visited — unmarked on the way out.
|
|
99
|
+
// A node marked for good cannot tell a cycle from an ordinary shared
|
|
100
|
+
// reference, which `JSON.stringify` handles fine: `{ before: row, after: row }`
|
|
101
|
+
// lost `after`, and `[row, row]` became `[null, null]`. That is silent data
|
|
102
|
+
// loss in the very detail this file exists to make readable.
|
|
103
|
+
seen.add(value)
|
|
104
|
+
const safe = Array.isArray(value)
|
|
105
|
+
? value.map((item) => jsonbSafe(item, seen))
|
|
106
|
+
: Object.fromEntries(
|
|
107
|
+
Object.entries(value).map(([key, item]) => [jsonbSafeText(key), jsonbSafe(item, seen)]),
|
|
108
|
+
)
|
|
109
|
+
seen.delete(value)
|
|
110
|
+
return safe
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `{ [key]: value }`, but only while the value stays small.
|
|
115
|
+
*
|
|
116
|
+
* Per key rather than over the whole detail so one enormous filter cannot cost
|
|
117
|
+
* the counts standing next to it — dropping everything would leave the row
|
|
118
|
+
* exactly as empty as it was before any of this existed.
|
|
119
|
+
*/
|
|
120
|
+
function ifSmall(key: string, value: unknown, max: number): Record<string, unknown> {
|
|
121
|
+
if (value === undefined) return {}
|
|
122
|
+
const size = jsonSize(value)
|
|
123
|
+
return size !== null && size <= max ? { [key]: value } : {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The head of a text answer, marked when cut. */
|
|
127
|
+
function preview(text: string): string {
|
|
128
|
+
return text.length <= PREVIEW_MAX_CHARS ? text : `${text.slice(0, PREVIEW_MAX_CHARS)}\u2026`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a summarizer for a row's `detail`, defending the run from it.
|
|
133
|
+
*
|
|
134
|
+
* Never throws and never grows without bound. A summary is a record OF the
|
|
135
|
+
* work by the same rule `recordStep` follows, so a summarizer that trips over
|
|
136
|
+
* a shape it did not expect — a dry dev run answering null, a service that
|
|
137
|
+
* grew a field — costs the detail, never the call it describes.
|
|
138
|
+
*/
|
|
139
|
+
function summarize<T>(
|
|
140
|
+
build: ((out: T) => Record<string, unknown>) | undefined,
|
|
141
|
+
out: T,
|
|
142
|
+
): Record<string, unknown> | undefined {
|
|
143
|
+
if (!build) return undefined
|
|
144
|
+
let detail: Record<string, unknown>
|
|
145
|
+
try {
|
|
146
|
+
detail = build(out)
|
|
147
|
+
} catch {
|
|
148
|
+
// Named rather than omitted: an omitted detail renders as "This step
|
|
149
|
+
// recorded no detail", which is the sentence this whole path exists to
|
|
150
|
+
// stop — and it would hide a broken summarizer behind a fixed bug's
|
|
151
|
+
// symptom.
|
|
152
|
+
return { omitted: 'summary unavailable' }
|
|
153
|
+
}
|
|
154
|
+
const size = jsonSize(detail)
|
|
155
|
+
if (size === null) return { omitted: 'summary not serializable' }
|
|
156
|
+
return size <= DETAIL_MAX_CHARS ? detail : { omitted: 'summary too large', chars: size }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What an author-declared step records about its own return value.
|
|
161
|
+
*
|
|
162
|
+
* The value is already JSON — the platform stores and replays it — so keeping a
|
|
163
|
+
* copy asks nothing new of it, and it is what makes the row readable at all:
|
|
164
|
+
* without it a step shows a name and a duration for work whose result nobody
|
|
165
|
+
* can see. Bounded, and reported as a size when it does not fit, because a step
|
|
166
|
+
* that returns a thousand warehouse rows must not write them a second time into
|
|
167
|
+
* the audit trail.
|
|
168
|
+
*/
|
|
169
|
+
function stepResultDetail(out: unknown): Record<string, unknown> | undefined {
|
|
170
|
+
if (out === undefined) return undefined
|
|
171
|
+
const size = jsonSize(out)
|
|
172
|
+
if (size === null) return { omitted: 'result not serializable' }
|
|
173
|
+
return size <= DETAIL_MAX_CHARS
|
|
174
|
+
? { result: out }
|
|
175
|
+
: { resultChars: size, omitted: 'result too large' }
|
|
176
|
+
}
|
|
177
|
+
|
|
41
178
|
/**
|
|
42
179
|
* The step tools this module needs, declared structurally rather than imported
|
|
43
180
|
* from `inngest`.
|
|
@@ -197,6 +334,19 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
197
334
|
...(parentStepId ? { parentStepId } : {}),
|
|
198
335
|
attempt: deps.attempt ?? 0,
|
|
199
336
|
...body,
|
|
337
|
+
// Last, so it applies to whatever `body` brought — see `jsonbSafe`.
|
|
338
|
+
...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
|
|
339
|
+
// `label` and `step_name` are `text`, and a NUL is refused there too —
|
|
340
|
+
// `invalid byte sequence for encoding "UTF8": 0x00`. `ctx.log` writes
|
|
341
|
+
// the author's message as the label, so a NUL riding in from a PDF
|
|
342
|
+
// kills the row through the field NEXT to the one being scrubbed. A
|
|
343
|
+
// lone surrogate is not a hazard in a text column (the driver encodes
|
|
344
|
+
// it as U+FFFD), but it is scrubbed with it rather than reasoned about
|
|
345
|
+
// twice.
|
|
346
|
+
...(typeof body.label === 'string' ? { label: jsonbSafeText(body.label) } : {}),
|
|
347
|
+
...(typeof body.stepName === 'string'
|
|
348
|
+
? { stepName: jsonbSafeText(body.stepName) }
|
|
349
|
+
: {}),
|
|
200
350
|
}),
|
|
201
351
|
})
|
|
202
352
|
// `fetch` resolves on a 4xx/5xx, so the status is the only place a
|
|
@@ -215,10 +365,16 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
215
365
|
/** Close an author-declared step row. Never throws, for the same reason. */
|
|
216
366
|
const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {
|
|
217
367
|
if (!stepId) return
|
|
368
|
+
// A row refused here is not closed at all — the service guards the update on
|
|
369
|
+
// `status = 'running'` — so the step would read `running` forever.
|
|
370
|
+
const payload = {
|
|
371
|
+
...body,
|
|
372
|
+
...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
|
|
373
|
+
}
|
|
218
374
|
try {
|
|
219
375
|
const res = await fetch(
|
|
220
376
|
`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
|
|
221
|
-
{ method: 'POST', headers: runnerHeaders, body: JSON.stringify(
|
|
377
|
+
{ method: 'POST', headers: runnerHeaders, body: JSON.stringify(payload) },
|
|
222
378
|
)
|
|
223
379
|
if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)
|
|
224
380
|
} catch (err) {
|
|
@@ -247,11 +403,36 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
247
403
|
return body
|
|
248
404
|
}
|
|
249
405
|
|
|
250
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Record one ctx call as a row: timed, and with a bounded summary of what it
|
|
408
|
+
* did.
|
|
409
|
+
*
|
|
410
|
+
* The summary is per capability rather than generic. A reader opening
|
|
411
|
+
* `query:LoanApplication` wants the row count and the filter; a reader opening
|
|
412
|
+
* `agent:triage` wants what the agent said — a generic dump of the return
|
|
413
|
+
* value would be both larger and less useful than either. A capability whose
|
|
414
|
+
* result carries a credential (a file's signed URL) summarizes AROUND it:
|
|
415
|
+
* details are rendered verbatim in the Console.
|
|
416
|
+
*/
|
|
417
|
+
const step = async <T>(
|
|
418
|
+
kind: string,
|
|
419
|
+
label: string,
|
|
420
|
+
fn: () => Promise<T>,
|
|
421
|
+
detailOf?: (out: T) => Record<string, unknown>,
|
|
422
|
+
): Promise<T> => {
|
|
251
423
|
const t0 = Date.now()
|
|
252
424
|
try {
|
|
253
425
|
const out = await fn()
|
|
254
|
-
await recordStep({
|
|
426
|
+
await recordStep({
|
|
427
|
+
kind,
|
|
428
|
+
label,
|
|
429
|
+
status: 'ok',
|
|
430
|
+
durationMs: Date.now() - t0,
|
|
431
|
+
// Omitted, not `{}`: an absent field leaves the service's own default in
|
|
432
|
+
// place rather than writing an empty object the panel would have to
|
|
433
|
+
// treat as detail.
|
|
434
|
+
detail: summarize(detailOf, out),
|
|
435
|
+
})
|
|
255
436
|
return out
|
|
256
437
|
} catch (err) {
|
|
257
438
|
await recordStep({
|
|
@@ -342,7 +523,11 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
342
523
|
{ stepId, stepName: name, submitted: new Set<string>() },
|
|
343
524
|
fn,
|
|
344
525
|
)
|
|
345
|
-
await completeStep(stepId, {
|
|
526
|
+
await completeStep(stepId, {
|
|
527
|
+
status: 'ok',
|
|
528
|
+
durationMs: Date.now() - t0,
|
|
529
|
+
detail: stepResultDetail(out),
|
|
530
|
+
})
|
|
346
531
|
return out
|
|
347
532
|
} catch (err) {
|
|
348
533
|
await completeStep(stepId, {
|
|
@@ -390,19 +575,65 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
390
575
|
|
|
391
576
|
agent(slug: string) {
|
|
392
577
|
return {
|
|
393
|
-
run: (prompt: string) =>
|
|
578
|
+
run: (prompt: string, options?: { files?: Array<{ fileId: string }> }) =>
|
|
394
579
|
step('agent', `agent:${slug}`, async () => {
|
|
395
580
|
requireGrant(`agent:${slug}:run`)
|
|
581
|
+
// `files` hands the agent already-uploaded files by canonical id —
|
|
582
|
+
// the same `{ fileId }` a `file`-typed run input carries, so an
|
|
583
|
+
// input can be forwarded as `ctx.agent(s).run(p, { files:
|
|
584
|
+
// [ctx.input.doc] })`. The service authorizes each id against this
|
|
585
|
+
// run's workspace and stages the bytes onto the agent's computer;
|
|
586
|
+
// the agent is told the staged paths in its prompt.
|
|
396
587
|
const res = await scoped('/ctx/agent-run', {
|
|
397
588
|
method: 'POST',
|
|
398
|
-
body: JSON.stringify({
|
|
589
|
+
body: JSON.stringify({
|
|
590
|
+
slug,
|
|
591
|
+
prompt,
|
|
592
|
+
...(options?.files?.length ? { files: options.files.map((f) => ({ fileId: f.fileId })) } : {}),
|
|
593
|
+
}),
|
|
399
594
|
})
|
|
400
595
|
if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
|
|
401
596
|
return ((await res.json()) as { data: { text: string } }).data
|
|
402
|
-
}
|
|
597
|
+
},
|
|
598
|
+
// The answer itself, capped. A run whose agent step is the expensive
|
|
599
|
+
// one is read to find out WHAT the agent said, and a length alone
|
|
600
|
+
// sends the reader back to re-run the automation to learn it.
|
|
601
|
+
(out) => ({
|
|
602
|
+
slug,
|
|
603
|
+
promptChars: prompt.length,
|
|
604
|
+
...(options?.files?.length ? { files: options.files.length } : {}),
|
|
605
|
+
...(typeof out?.text === 'string'
|
|
606
|
+
? { textChars: out.text.length, preview: preview(out.text) }
|
|
607
|
+
: {}),
|
|
608
|
+
}),
|
|
609
|
+
) as Promise<{ text: string }>,
|
|
403
610
|
}
|
|
404
611
|
},
|
|
405
612
|
|
|
613
|
+
file: (ref: { fileId: string }) =>
|
|
614
|
+
step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {
|
|
615
|
+
// No grant: this only reads a file the run was GIVEN — the route
|
|
616
|
+
// refuses any fileId that is not among this run's own inputs. Returns
|
|
617
|
+
// a short-lived signed URL plus the authoritative mime/size/name; in a
|
|
618
|
+
// dry dev run it returns null, like every other ctx call.
|
|
619
|
+
const res = await scoped('/ctx/file-resolve', {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
body: JSON.stringify({ fileId: ref.fileId }),
|
|
622
|
+
})
|
|
623
|
+
if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
|
|
624
|
+
return ((await res.json()) as {
|
|
625
|
+
data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
|
|
626
|
+
}).data.file
|
|
627
|
+
},
|
|
628
|
+
// Everything the handle carries EXCEPT `signedUrl`. That URL is a
|
|
629
|
+
// short-lived credential, and a row is exactly the wrong place for one:
|
|
630
|
+
// details are rendered verbatim and kept for as long as the run is.
|
|
631
|
+
(out) =>
|
|
632
|
+
out
|
|
633
|
+
? { fileId: out.fileId, mimeType: out.mimeType, sizeBytes: out.sizeBytes, name: out.name }
|
|
634
|
+
: { resolved: false },
|
|
635
|
+
) as Promise<import('./types').ResolvedFileHandle | null>,
|
|
636
|
+
|
|
406
637
|
plugin(install: string) {
|
|
407
638
|
return {
|
|
408
639
|
call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>
|
|
@@ -422,7 +653,19 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
422
653
|
}
|
|
423
654
|
// Two levels: the service envelope's data, then PluginCallResult's own data.
|
|
424
655
|
return ((await res.json()) as { data: PluginCallResult<T> }).data
|
|
425
|
-
}
|
|
656
|
+
},
|
|
657
|
+
(out) => {
|
|
658
|
+
const chars = jsonSize(out?.data)
|
|
659
|
+
return {
|
|
660
|
+
install,
|
|
661
|
+
capability,
|
|
662
|
+
...(chars === null ? {} : { resultChars: chars }),
|
|
663
|
+
// The plugin's own shape, while it is small enough to read at a
|
|
664
|
+
// glance. The size above stands for it when it is not.
|
|
665
|
+
...ifSmall('data', out?.data, 2_000),
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
) as Promise<PluginCallResult<T>>,
|
|
426
669
|
}
|
|
427
670
|
},
|
|
428
671
|
|
|
@@ -446,7 +689,15 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
446
689
|
})
|
|
447
690
|
if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)
|
|
448
691
|
return ((await res.json()) as { data: HttpResponse }).data
|
|
449
|
-
}
|
|
692
|
+
},
|
|
693
|
+
// The upstream status, which is the fact this call is read for — an API
|
|
694
|
+
// answering 404 is data here, not a throw, so the row is the only place
|
|
695
|
+
// that outcome appears at all. Not the body: it is capped at 1 MB and
|
|
696
|
+
// may carry whatever the host sent back.
|
|
697
|
+
(out) => ({
|
|
698
|
+
...(out ? { status: out.status, bodyChars: out.body?.length ?? 0 } : {}),
|
|
699
|
+
}),
|
|
700
|
+
) as Promise<HttpResponse>,
|
|
450
701
|
},
|
|
451
702
|
|
|
452
703
|
action: {
|
|
@@ -538,7 +789,16 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
538
789
|
scope.submitted.delete(submissionIdentity)
|
|
539
790
|
throw err
|
|
540
791
|
}
|
|
541
|
-
}
|
|
792
|
+
},
|
|
793
|
+
// `lifecycle` is the half of the answer a reader most often wants: a
|
|
794
|
+
// submission that stopped at `awaiting_approval` is a normal return, so
|
|
795
|
+
// the row's green status does not say whether anything happened yet.
|
|
796
|
+
(out) => ({
|
|
797
|
+
action: request.action,
|
|
798
|
+
...(request.submissionKey ? { submissionKey: request.submissionKey } : {}),
|
|
799
|
+
...(out ? { requestId: out.requestId, lifecycle: out.lifecycle } : {}),
|
|
800
|
+
}),
|
|
801
|
+
) as Promise<ActionSubmitResult>,
|
|
542
802
|
},
|
|
543
803
|
|
|
544
804
|
blueprint: {
|
|
@@ -558,7 +818,19 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
558
818
|
// untyped. The cast is the honest boundary: nothing here can verify
|
|
559
819
|
// it, and pretending otherwise would just move the lie deeper.
|
|
560
820
|
return ((await res.json()) as { data: BlueprintQueryResult<T> }).data
|
|
561
|
-
}
|
|
821
|
+
},
|
|
822
|
+
// Count AND filter, because the two questions a reader brings to a
|
|
823
|
+
// query step are "how many did it match" and "what did it ask for" —
|
|
824
|
+
// and `hasMore` is how they tell an empty result from a truncated one.
|
|
825
|
+
(out) => ({
|
|
826
|
+
objectType,
|
|
827
|
+
...(out ? { rowCount: out.rows?.length ?? 0, hasMore: out.hasMore ?? false } : {}),
|
|
828
|
+
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
|
829
|
+
...ifSmall('where', options?.where, 1_000),
|
|
830
|
+
...ifSmall('select', options?.select, 500),
|
|
831
|
+
...ifSmall('orderBy', options?.orderBy, 500),
|
|
832
|
+
}),
|
|
833
|
+
) as Promise<BlueprintQueryResult<T>>,
|
|
562
834
|
},
|
|
563
835
|
}
|
|
564
836
|
}
|
package/src/testing.ts
CHANGED
|
@@ -47,7 +47,7 @@ import type {
|
|
|
47
47
|
* for a refusal, and on `calls` for what ran.
|
|
48
48
|
*/
|
|
49
49
|
export interface TestCall {
|
|
50
|
-
kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action'
|
|
50
|
+
kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file'
|
|
51
51
|
/** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */
|
|
52
52
|
label: string
|
|
53
53
|
/** Present on a step: how it ended. */
|
|
@@ -74,6 +74,9 @@ export interface TestContextOptions {
|
|
|
74
74
|
grants?: readonly Grant[]
|
|
75
75
|
/** Per-slug agent answers. An unstubbed agent throws rather than answering. */
|
|
76
76
|
agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
|
|
77
|
+
/** Per-fileId answers for `ctx.file`: what the resolved handle should carry.
|
|
78
|
+
* An unstubbed fileId throws — a fabricated handle is a false pass. */
|
|
79
|
+
files?: Record<string, { signedUrl: string; mimeType: string; sizeBytes: number; name: string | null }>
|
|
77
80
|
/**
|
|
78
81
|
* Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.
|
|
79
82
|
* An unstubbed capability throws rather than answering — a fabricated
|
|
@@ -177,6 +180,20 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
177
180
|
calls.push({ kind: 'log', label: message })
|
|
178
181
|
},
|
|
179
182
|
|
|
183
|
+
async file(ref: { fileId: string }) {
|
|
184
|
+
calls.push({ kind: 'file', label: ref.fileId })
|
|
185
|
+
const stub = options.files?.[ref.fileId]
|
|
186
|
+
// Throwing beats a fabricated handle: a test that reads a file it never
|
|
187
|
+
// stubbed would otherwise pass on made-up bytes.
|
|
188
|
+
if (!stub) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': { signedUrl: '…', ` +
|
|
191
|
+
"mimeType: '…', sizeBytes: 0, name: null } } to createTestContext.",
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return { fileId: ref.fileId, ...stub }
|
|
195
|
+
},
|
|
196
|
+
|
|
180
197
|
agent(slug: string) {
|
|
181
198
|
return {
|
|
182
199
|
async run(prompt: string) {
|
package/src/types.ts
CHANGED
|
@@ -76,14 +76,21 @@ export type Grant =
|
|
|
76
76
|
* philosophy as the grant grammar: small enough that a wrong shape is
|
|
77
77
|
* refusable with a sentence, wide enough for real parameters. */
|
|
78
78
|
export interface InputFieldSpec {
|
|
79
|
-
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
|
79
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
|
|
80
80
|
/** Refused at run start when absent. Mutually exclusive with `default`. */
|
|
81
81
|
required?: boolean
|
|
82
|
-
/** Applied at run start when the field is absent. Cron runs rely on these.
|
|
82
|
+
/** Applied at run start when the field is absent. Cron runs rely on these.
|
|
83
|
+
* Not permitted on a `file` field — a file has no meaningful literal default. */
|
|
83
84
|
default?: unknown
|
|
84
85
|
description?: string
|
|
85
86
|
/** Allowed values — string and number types only. */
|
|
86
87
|
enum?: readonly (string | number)[]
|
|
88
|
+
/** `file` type only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
|
|
89
|
+
* A declaration aid: the value on the run row is only a reference, so this is
|
|
90
|
+
* enforced server-side at upload and at run start, never against the value here. */
|
|
91
|
+
accept?: readonly string[]
|
|
92
|
+
/** `file` type only. Maximum upload size in bytes, enforced server-side. */
|
|
93
|
+
maxBytes?: number
|
|
87
94
|
/**
|
|
88
95
|
* Mask this field's VALUE wherever a person reads the run.
|
|
89
96
|
*
|
|
@@ -125,6 +132,26 @@ export interface InputFieldSpec {
|
|
|
125
132
|
redact?: boolean
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The value of a `file`-typed input.
|
|
138
|
+
*
|
|
139
|
+
* A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the
|
|
140
|
+
* canonical handle from the platform's unified file registry (see
|
|
141
|
+
* docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in
|
|
142
|
+
* storage; only this small id rides on the run row, so the 64KB input cap is
|
|
143
|
+
* untouched.
|
|
144
|
+
*
|
|
145
|
+
* One reference, two entry points: the run-form uploader gets a `fileId` for a
|
|
146
|
+
* file a person drops in, and an agent passes the `fileId` of a chat attachment
|
|
147
|
+
* it is already holding — both resolve identically downstream. The client never
|
|
148
|
+
* supplies a trusted path or URL; the SERVER authorizes the `fileId` against the
|
|
149
|
+
* caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.
|
|
150
|
+
*/
|
|
151
|
+
export interface FileRef {
|
|
152
|
+
fileId: string
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
export type InputsSchema = Record<string, InputFieldSpec>
|
|
129
156
|
|
|
130
157
|
export interface AutomationManifest {
|
|
@@ -183,7 +210,26 @@ export interface ResolvedAutomationManifest extends AutomationManifest {
|
|
|
183
210
|
}
|
|
184
211
|
|
|
185
212
|
export interface AgentHandle {
|
|
186
|
-
|
|
213
|
+
/**
|
|
214
|
+
* One headless agent turn. `options.files` hands the agent already-uploaded
|
|
215
|
+
* files by canonical id — the same `{ fileId }` a `file`-typed run input
|
|
216
|
+
* carries, so an input forwards directly: `run(p, { files: [ctx.input.doc] })`.
|
|
217
|
+
* Each id is authorized against this run's workspace and the bytes are staged
|
|
218
|
+
* onto the agent's computer; the agent is told the staged paths.
|
|
219
|
+
*/
|
|
220
|
+
run(prompt: string, options?: { files?: readonly FileRef[] }): Promise<{ text: string }>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* What `ctx.file(ref)` resolves to: a short-lived signed URL plus the
|
|
225
|
+
* authoritative mime/size resolved at upload. `null` only in a dry dev run.
|
|
226
|
+
*/
|
|
227
|
+
export interface ResolvedFileHandle {
|
|
228
|
+
fileId: string
|
|
229
|
+
signedUrl: string
|
|
230
|
+
mimeType: string
|
|
231
|
+
sizeBytes: number
|
|
232
|
+
name: string | null
|
|
187
233
|
}
|
|
188
234
|
|
|
189
235
|
/** What `ctx.plugin(install).call(...)` resolves to. */
|
|
@@ -236,7 +282,10 @@ export interface StepApi {
|
|
|
236
282
|
* run naming the collision rather than returning the wrong value.
|
|
237
283
|
* 2. **The result must be JSON-serializable.** It is stored and replayed, so a
|
|
238
284
|
* `Date` comes back as a string and a class instance comes back as a plain
|
|
239
|
-
* object. Return data, not objects with behaviour.
|
|
285
|
+
* object. Return data, not objects with behaviour. It is also recorded on
|
|
286
|
+
* the step's row — capped, and replaced by its size when it is too large —
|
|
287
|
+
* so the run trace can show what the step produced. Never return a secret
|
|
288
|
+
* from a step: details are rendered verbatim in the Console.
|
|
240
289
|
* 3. **Code outside a step re-executes.** After each step the handler restarts
|
|
241
290
|
* from the top with completed steps returning their stored results. A
|
|
242
291
|
* `ctx.http` call sitting outside a step therefore fires once per step, and
|
|
@@ -378,6 +427,12 @@ export interface AutomationContext {
|
|
|
378
427
|
/** Never rejects — telemetry must not be able to fail a run. */
|
|
379
428
|
log(message: string, data?: Record<string, unknown>): Promise<void>
|
|
380
429
|
agent(slug: string): AgentHandle
|
|
430
|
+
/**
|
|
431
|
+
* Resolve one of THIS run's `file` inputs to a readable form (signed URL +
|
|
432
|
+
* authoritative mime/size). No grant — it only reads files the run was given;
|
|
433
|
+
* any other fileId is refused. `null` in a dry dev run.
|
|
434
|
+
*/
|
|
435
|
+
file(ref: FileRef): Promise<ResolvedFileHandle | null>
|
|
381
436
|
/** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */
|
|
382
437
|
plugin(install: string): PluginHandle
|
|
383
438
|
http: {
|