@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/automation",
3
- "version": "1.45.14",
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.14",
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 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,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
- 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)) {
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
- + '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
  }
@@ -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,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
- 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
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: {