@frontera-sdk/automation 1.45.13 → 1.45.15
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 +12 -1
- package/src/inputs.ts +101 -5
- package/src/manifest.ts +21 -3
- package/src/runtime-context.ts +283 -11
- package/src/testing.ts +18 -1
- package/src/types.ts +141 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/automation",
|
|
3
|
-
"version": "1.45.
|
|
3
|
+
"version": "1.45.15",
|
|
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.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.45.15",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,17 @@ export {
|
|
|
11
11
|
} from './messages'
|
|
12
12
|
export { createTestContext } from './testing'
|
|
13
13
|
export type { TestCall, TestContext, TestContextOptions } from './testing'
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
MAX_INPUT_BYTES,
|
|
16
|
+
redactedInputKeys,
|
|
17
|
+
sanitizeInputsSchema,
|
|
18
|
+
validateInputValue,
|
|
19
|
+
} from './inputs'
|
|
15
20
|
export type { InputValidation } from './inputs'
|
|
21
|
+
export {
|
|
22
|
+
EVENT_TRIGGER_SOURCES,
|
|
23
|
+
TICKETED_TRIGGER_SOURCES,
|
|
24
|
+
isEventTriggerSource,
|
|
25
|
+
isTicketedTriggerSource,
|
|
26
|
+
} from './types'
|
|
16
27
|
export type * from './types'
|
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,13 +48,22 @@ 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
|
|
43
55
|
* top-level unknown-key warning: a typo like `requred` should be visible,
|
|
44
56
|
* but a field a newer SDK added must not fail an older validator's deploy. */
|
|
45
|
-
const INPUT_SPEC_KEYS = new Set([
|
|
57
|
+
const INPUT_SPEC_KEYS = new Set([
|
|
58
|
+
'type',
|
|
59
|
+
'required',
|
|
60
|
+
'default',
|
|
61
|
+
'description',
|
|
62
|
+
'enum',
|
|
63
|
+
'redact',
|
|
64
|
+
'accept',
|
|
65
|
+
'maxBytes',
|
|
66
|
+
])
|
|
46
67
|
|
|
47
68
|
/**
|
|
48
69
|
* Serialized cap on a run's input object — the same 64KB the service enforces
|
|
@@ -105,6 +126,9 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
105
126
|
default?: unknown
|
|
106
127
|
enum?: unknown
|
|
107
128
|
description?: unknown
|
|
129
|
+
redact?: unknown
|
|
130
|
+
accept?: unknown
|
|
131
|
+
maxBytes?: unknown
|
|
108
132
|
}
|
|
109
133
|
|
|
110
134
|
if (spec.description !== undefined && typeof spec.description !== 'string') {
|
|
@@ -112,7 +136,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
112
136
|
}
|
|
113
137
|
|
|
114
138
|
if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {
|
|
115
|
-
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`)
|
|
116
140
|
return { errors, warnings }
|
|
117
141
|
}
|
|
118
142
|
const t = spec.type as InputFieldSpec['type']
|
|
@@ -129,6 +153,29 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
129
153
|
errors.push(`input "${key}": required and default are mutually exclusive — a default always satisfies required`)
|
|
130
154
|
}
|
|
131
155
|
|
|
156
|
+
// Same `=== true` predicate as `required`, for the same reason: the runtime
|
|
157
|
+
// treats only a literal `true` as active, so a truthy check here would let
|
|
158
|
+
// `redact: 1` deploy clean and then mask nothing.
|
|
159
|
+
if (spec.redact !== undefined && typeof spec.redact !== 'boolean') {
|
|
160
|
+
errors.push(`input "${key}": redact must be a boolean`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Both of these publish the value the flag claims to hide, so they are
|
|
164
|
+
// refused rather than warned about: a manifest that declares them is a
|
|
165
|
+
// masking guarantee that was never going to hold.
|
|
166
|
+
if (spec.redact === true && spec.default !== undefined) {
|
|
167
|
+
errors.push(
|
|
168
|
+
`input "${key}": redact and default are mutually exclusive — a default is published in the `
|
|
169
|
+
+ 'version manifest, so the value would be readable there',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
if (spec.redact === true && spec.enum !== undefined) {
|
|
173
|
+
errors.push(
|
|
174
|
+
`input "${key}": redact and enum are mutually exclusive — an enum publishes every allowed `
|
|
175
|
+
+ 'value in the version manifest',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
let enumOk = true
|
|
133
180
|
if (spec.enum !== undefined) {
|
|
134
181
|
if (t !== 'string' && t !== 'number') {
|
|
@@ -149,9 +196,37 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
|
|
|
149
196
|
}
|
|
150
197
|
}
|
|
151
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
|
+
|
|
152
222
|
let defaultOk = true
|
|
153
223
|
if (spec.default !== undefined) {
|
|
154
|
-
|
|
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)) {
|
|
155
230
|
errors.push(`input "${key}": default must match the declared type`)
|
|
156
231
|
defaultOk = false
|
|
157
232
|
}
|
|
@@ -242,3 +317,24 @@ export function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined
|
|
|
242
317
|
}
|
|
243
318
|
return inputs as InputsSchema
|
|
244
319
|
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The input names a version declared `redact: true` on.
|
|
324
|
+
*
|
|
325
|
+
* One implementation, so the service, the Console and anything else answer
|
|
326
|
+
* "which fields are masked" the same way — the same argument that keeps
|
|
327
|
+
* `checkInputFieldSpec` shared between deploy and runtime.
|
|
328
|
+
*
|
|
329
|
+
* FAIL CLOSED IS THE CALLER'S JOB, and it matters: `sanitizeInputsSchema`
|
|
330
|
+
* returns `undefined` for the WHOLE schema when any single field is malformed,
|
|
331
|
+
* which a caller deriving this list from its result would read as "nothing is
|
|
332
|
+
* redacted". A caller holding a non-empty raw `inputs` whose sanitized form is
|
|
333
|
+
* `undefined` must mask everything rather than nothing.
|
|
334
|
+
*/
|
|
335
|
+
export function redactedInputKeys(schema: InputsSchema | undefined): string[] {
|
|
336
|
+
if (!schema) return []
|
|
337
|
+
return Object.entries(schema)
|
|
338
|
+
.filter(([, spec]) => spec?.redact === true)
|
|
339
|
+
.map(([key]) => key)
|
|
340
|
+
}
|
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
|
}
|
|
@@ -300,13 +306,25 @@ export function validateManifest(input: unknown): ValidationResult {
|
|
|
300
306
|
const agentInputs = m.inputs
|
|
301
307
|
if (agentInputs && typeof agentInputs === 'object' && !Array.isArray(agentInputs)) {
|
|
302
308
|
for (const [key, raw] of Object.entries(agentInputs as Record<string, unknown>)) {
|
|
303
|
-
const spec = raw as { description?: unknown } | null
|
|
309
|
+
const spec = raw as { description?: unknown; redact?: unknown } | null
|
|
304
310
|
if (typeof spec?.description !== 'string' || spec.description.trim().length === 0) {
|
|
305
311
|
errors.push(
|
|
306
312
|
`input "${key}" needs a description: trigger { agent: true } publishes every input as `
|
|
307
313
|
+ 'a tool argument, and an agent cannot fill an argument it has no description for.',
|
|
308
314
|
)
|
|
309
315
|
}
|
|
316
|
+
// An error, not a warning, and refused here where the author still has
|
|
317
|
+
// the file open. On the agent path the MODEL produces this value as
|
|
318
|
+
// tool-call arguments: it is in the conversation and in that
|
|
319
|
+
// conversation's trace before a run row exists to mask. Masking the run
|
|
320
|
+
// row would advertise a guarantee this path cannot keep.
|
|
321
|
+
if (spec?.redact === true) {
|
|
322
|
+
errors.push(
|
|
323
|
+
`input "${key}": redact cannot be used with trigger { agent: true } — the agent `
|
|
324
|
+
+ 'supplies this value as a tool argument, so it is already in the conversation and '
|
|
325
|
+
+ 'its trace before the run exists.',
|
|
326
|
+
)
|
|
327
|
+
}
|
|
310
328
|
}
|
|
311
329
|
}
|
|
312
330
|
}
|
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,80 @@ 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
|
|
94
|
+
/**
|
|
95
|
+
* Mask this field's VALUE wherever a person reads the run.
|
|
96
|
+
*
|
|
97
|
+
* What it changes: the run list, the run page and `frontera automation runs`
|
|
98
|
+
* show a placeholder instead of the value. What it does NOT change: the
|
|
99
|
+
* handler, every resumption and every retried attempt receive the real value,
|
|
100
|
+
* because the run row still holds it — this is a display control, not
|
|
101
|
+
* storage encryption and not an access control.
|
|
102
|
+
*
|
|
103
|
+
* What it CANNOT cover, stated here so the flag never reads as a promise it
|
|
104
|
+
* does not keep:
|
|
105
|
+
*
|
|
106
|
+
* - `ctx.log('…', { key: ctx.input.token })` — an author writing a value
|
|
107
|
+
* into a step detail publishes it, and nothing here can intercept that.
|
|
108
|
+
* - a redacted value the author TRANSFORMS before using it. The agent
|
|
109
|
+
* transcript on the run page is masked by exact occurrence, so a value
|
|
110
|
+
* interpolated into a prompt — or quoted back in the reply — is replaced.
|
|
111
|
+
* A value upper-cased, truncated or reformatted first no longer matches
|
|
112
|
+
* and is not found. Exact match is what can be done without guessing at
|
|
113
|
+
* substrings; the alternative, withholding transcripts entirely for any
|
|
114
|
+
* run with a redacted input, would take the review surface away from
|
|
115
|
+
* exactly the runs that most need reviewing.
|
|
116
|
+
* - `default` and `enum`, which are published in the version manifest and in
|
|
117
|
+
* any tool schema built from it. Declaring either alongside `redact` is
|
|
118
|
+
* refused at deploy for exactly that reason.
|
|
119
|
+
* - the run's own `result` and error message. A handler that returns the
|
|
120
|
+
* value — `return { note: ctx.input.customer_note }` — or throws an error
|
|
121
|
+
* quoting it publishes it on the same run page, unmasked, next to the
|
|
122
|
+
* masked input it came from. Only the INPUT is masked; what the handler
|
|
123
|
+
* chooses to emit is the handler's decision.
|
|
124
|
+
* - anything already recorded. Versions are append-only and the mask is
|
|
125
|
+
* frozen onto each run when it starts, so adding `redact` masks future
|
|
126
|
+
* runs and never rewrites history.
|
|
127
|
+
*
|
|
128
|
+
* A credential still belongs in a `secret:` grant, whose value never enters
|
|
129
|
+
* this process at all. `redact` is for the ordinary personal or commercial
|
|
130
|
+
* detail a run legitimately takes and a bystander has no reason to read.
|
|
131
|
+
*/
|
|
132
|
+
redact?: boolean
|
|
133
|
+
}
|
|
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
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
export type InputsSchema = Record<string, InputFieldSpec>
|
|
@@ -144,7 +210,26 @@ export interface ResolvedAutomationManifest extends AutomationManifest {
|
|
|
144
210
|
}
|
|
145
211
|
|
|
146
212
|
export interface AgentHandle {
|
|
147
|
-
|
|
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
|
|
148
233
|
}
|
|
149
234
|
|
|
150
235
|
/** What `ctx.plugin(install).call(...)` resolves to. */
|
|
@@ -197,7 +282,10 @@ export interface StepApi {
|
|
|
197
282
|
* run naming the collision rather than returning the wrong value.
|
|
198
283
|
* 2. **The result must be JSON-serializable.** It is stored and replayed, so a
|
|
199
284
|
* `Date` comes back as a string and a class instance comes back as a plain
|
|
200
|
-
* 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.
|
|
201
289
|
* 3. **Code outside a step re-executes.** After each step the handler restarts
|
|
202
290
|
* from the top with completed steps returning their stored results. A
|
|
203
291
|
* `ctx.http` call sitting outside a step therefore fires once per step, and
|
|
@@ -339,6 +427,12 @@ export interface AutomationContext {
|
|
|
339
427
|
/** Never rejects — telemetry must not be able to fail a run. */
|
|
340
428
|
log(message: string, data?: Record<string, unknown>): Promise<void>
|
|
341
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>
|
|
342
436
|
/** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */
|
|
343
437
|
plugin(install: string): PluginHandle
|
|
344
438
|
http: {
|
|
@@ -461,3 +555,46 @@ export interface AutomationDescriptor {
|
|
|
461
555
|
readonly manifest: ResolvedAutomationManifest
|
|
462
556
|
readonly handler: AutomationHandler
|
|
463
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* How a run came to exist, named once so the two sides of the invoke event
|
|
561
|
+
* cannot drift.
|
|
562
|
+
*
|
|
563
|
+
* This is not decoration. The service publishes `source` on the invoke event,
|
|
564
|
+
* the RUNNER resolves it back and posts it to the open-run call, and the
|
|
565
|
+
* service then refuses an invocation ticket arriving under a source that does
|
|
566
|
+
* not expect one. So a source the service knows and the runner does not is not
|
|
567
|
+
* a mis-filed run — it is no run at all: the ticket rides along, the open-run
|
|
568
|
+
* call 400s, and the caller waits forever on a request that never became
|
|
569
|
+
* anything. That is precisely how the App lane shipped broken.
|
|
570
|
+
*
|
|
571
|
+
* Both packages depend on this one, so the list lives here rather than being
|
|
572
|
+
* spelled out in each. Adding a source means adding it here, and the two
|
|
573
|
+
* consumers pick it up by construction.
|
|
574
|
+
*
|
|
575
|
+
* `cron` is deliberately absent: it is what the runner INFERS when the event
|
|
576
|
+
* names no source at all, so it is never carried on an event.
|
|
577
|
+
*/
|
|
578
|
+
export const EVENT_TRIGGER_SOURCES = ['manual', 'rehearsal', 'agent', 'app'] as const
|
|
579
|
+
export type EventTriggerSource = (typeof EVENT_TRIGGER_SOURCES)[number]
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The sources whose runs MUST arrive with an invocation ticket.
|
|
583
|
+
*
|
|
584
|
+
* A run under one of these has a caller whose identity exists only on the
|
|
585
|
+
* ticket, so a missing one is refused rather than opened unattributed. A ticket
|
|
586
|
+
* under any other source is refused too — it means the event was tampered with
|
|
587
|
+
* or two payloads got mixed.
|
|
588
|
+
*/
|
|
589
|
+
export const TICKETED_TRIGGER_SOURCES = ['agent', 'app'] as const
|
|
590
|
+
export type TicketedTriggerSource = (typeof TICKETED_TRIGGER_SOURCES)[number]
|
|
591
|
+
|
|
592
|
+
export function isEventTriggerSource(value: unknown): value is EventTriggerSource {
|
|
593
|
+
return typeof value === 'string'
|
|
594
|
+
&& (EVENT_TRIGGER_SOURCES as readonly string[]).includes(value)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function isTicketedTriggerSource(value: unknown): value is TicketedTriggerSource {
|
|
598
|
+
return typeof value === 'string'
|
|
599
|
+
&& (TICKETED_TRIGGER_SOURCES as readonly string[]).includes(value)
|
|
600
|
+
}
|