@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/automation",
3
- "version": "1.45.13",
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.13",
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 { MAX_INPUT_BYTES, sanitizeInputsSchema, validateInputValue } from './inputs'
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 five input types' runtime validators, keyed by `InputFieldSpec['type']`.
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(['type', 'required', 'default', 'description', 'enum'])
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
- if (!TYPE_CHECK[t](spec.default)) {
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
- + 'cron has nobody to ask. Add a default or make the trigger manual.',
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
  }
@@ -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(body) },
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
- const step = async (kind: string, label: string, fn: () => Promise<unknown>): Promise<unknown> => {
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({ kind, label, status: 'ok', durationMs: Date.now() - t0 })
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, { status: 'ok', durationMs: Date.now() - t0 })
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({ slug, prompt }),
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
- }) as Promise<{ text: string }>,
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
- }) as Promise<PluginCallResult<T>>,
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
- }) as Promise<HttpResponse>,
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
- }) as Promise<ActionSubmitResult>,
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
- }) as Promise<BlueprintQueryResult<T>>,
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
- run(prompt: string): Promise<{ text: string }>
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
+ }