@frontera-sdk/automation 1.45.9 → 1.45.11

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.9",
3
+ "version": "1.45.11",
4
4
  "description": "Author Frontera automations: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -27,6 +27,10 @@
27
27
  "./runtime": {
28
28
  "types": "./src/runtime-context.ts",
29
29
  "import": "./src/runtime-context.ts"
30
+ },
31
+ "./inputs": {
32
+ "types": "./src/inputs.ts",
33
+ "import": "./src/inputs.ts"
30
34
  }
31
35
  },
32
36
  "scripts": {
@@ -38,7 +42,7 @@
38
42
  "typescript": "^5.9.3"
39
43
  },
40
44
  "dependencies": {
41
- "@frontera-sdk/blueprint": "1.45.7",
45
+ "@frontera-sdk/blueprint": "1.45.9",
42
46
  "cron-parser": "^5.0.6"
43
47
  }
44
48
  }
package/src/define.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AutomationHandler,
4
4
  AutomationManifest,
5
5
  AutomationTrigger,
6
+ InputsSchema,
6
7
  } from './types'
7
8
 
8
9
  export function automation(
@@ -18,11 +19,24 @@ export function automation(
18
19
  // runner registers.
19
20
  const trigger: AutomationTrigger = { ...manifest.trigger }
20
21
 
22
+ let inputs: Readonly<InputsSchema> | undefined
23
+ if (manifest.inputs) {
24
+ try {
25
+ inputs = Object.freeze(structuredClone(manifest.inputs))
26
+ } catch {
27
+ // `structuredClone` throws a raw `DataCloneError` DOMException on a
28
+ // function/symbol/etc default, which names neither the automation nor
29
+ // the field — useless in a deploy log. Rethrow with both.
30
+ throw new Error(`Automation "${manifest.name}": input defaults must be JSON-serializable values`)
31
+ }
32
+ }
33
+
21
34
  return Object.freeze({
22
35
  manifest: Object.freeze({
23
36
  ...manifest,
24
37
  trigger: Object.freeze(trigger),
25
38
  grants: Object.freeze([...(manifest.grants ?? [])]),
39
+ ...(inputs ? { inputs } : {}),
26
40
  concurrency: manifest.concurrency ?? 1,
27
41
  retries: manifest.retries ?? 0,
28
42
  }),
package/src/index.ts CHANGED
@@ -11,4 +11,6 @@ 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'
15
+ export type { InputValidation } from './inputs'
14
16
  export type * from './types'
package/src/inputs.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Run-input validation — the value-side twin of `validateManifest`.
3
+ *
4
+ * Three callers must agree on the verdict and the wording: the run route
5
+ * (fast 400 before anything queues), `startRun` (authoritative — the runner
6
+ * posts whatever rode the event), and the Console form (client courtesy).
7
+ * Living in the SDK is what keeps them one implementation.
8
+ */
9
+ import type { InputFieldSpec, InputsSchema } from './types'
10
+
11
+ export type InputValidation =
12
+ | { ok: true; value: Record<string, unknown> }
13
+ | { ok: false; errors: string[] }
14
+
15
+ /**
16
+ * The five input types' runtime validators, keyed by `InputFieldSpec['type']`.
17
+ *
18
+ * Single source of truth for "does this value have this type" — `validateInputValue`'s
19
+ * value check and `checkInputFieldSpec`'s default/enum checks all call this instead
20
+ * of re-deriving it, so a tightening here (the `Number.isFinite` guard that excludes
21
+ * `Infinity`/`NaN` from `number`) or a future widening can never drift between
22
+ * deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`
23
+ * used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why
24
+ * this is exported rather than kept module-private.
25
+ */
26
+ export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
27
+ string: (v) => typeof v === 'string',
28
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
29
+ boolean: (v) => typeof v === 'boolean',
30
+ object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
31
+ array: Array.isArray,
32
+ }
33
+
34
+ /** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
35
+ const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
36
+ /** Lowercase snake — the one allowance kebab doesn't cover. */
37
+ const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
38
+
39
+ const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array'])
40
+
41
+ /** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
42
+ * Anything else there is a warning, same philosophy as `manifest.ts`'s
43
+ * top-level unknown-key warning: a typo like `requred` should be visible,
44
+ * 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'])
46
+
47
+ /**
48
+ * Serialized cap on a run's input object — the same 64KB the service enforces
49
+ * when a run starts. Exported so `validateManifest` can refuse a manifest
50
+ * whose *defaults alone* exceed it at deploy time: past that point a cron
51
+ * fire would fail inside run-open with no run row, which is invisible.
52
+ */
53
+ export const MAX_INPUT_BYTES = 64 * 1024
54
+
55
+ /** Input names that smell like credentials — warned at deploy, never blocked.
56
+ * Tails are anchored so `max_tokens` (a count) and `secretary` stay quiet
57
+ * while `api_key`, `auth-token`, `client_secret`, `secret_key` still warn. */
58
+ const CREDENTIAL_NAME_RE = /([_-]token$|[_-]key$|password|(^|[_-])secret([_-]|$))/i
59
+
60
+ export interface InputFieldCheck {
61
+ errors: string[]
62
+ warnings: string[]
63
+ }
64
+
65
+ /**
66
+ * Structural rules for ONE `{ name: spec }` entry in an inputs schema — name
67
+ * shape, declared type, required/default shape, enum.
68
+ *
69
+ * The shared source for both `validateManifest` (deploy-time; also surfaces
70
+ * the non-fatal warnings) and `sanitizeInputsSchema` (runtime; pass/fail
71
+ * only) so the two can never quietly diverge on what "a well-formed input
72
+ * field" means — which is exactly how `manifest.ts`'s default-type check once
73
+ * drifted from `TYPE_CHECK` and admitted `default: Infinity`.
74
+ */
75
+ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck {
76
+ const errors: string[] = []
77
+ const warnings: string[] = []
78
+
79
+ if (!INPUT_NAME_KEBAB_RE.test(key) && !INPUT_NAME_SNAKE_RE.test(key)) {
80
+ errors.push(`input "${key}" — names are lowercase snake or kebab`)
81
+ return { errors, warnings }
82
+ }
83
+
84
+ // Inputs land on the run row and in traces permanently; there is no way to
85
+ // detect a secret in a value, but a name that says "credential" is an honest
86
+ // mistake we can flag while the author is still looking at the file.
87
+ if (CREDENTIAL_NAME_RE.test(key)) {
88
+ warnings.push(
89
+ `input "${key}" looks like a credential — inputs are stored on the run row `
90
+ + 'and visible in traces. Use a `secret:` grant instead.',
91
+ )
92
+ }
93
+
94
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
95
+ for (const specKey of Object.keys(raw as Record<string, unknown>)) {
96
+ if (!INPUT_SPEC_KEYS.has(specKey)) {
97
+ warnings.push(`unknown key "${specKey}" on input "${key}" — ignored`)
98
+ }
99
+ }
100
+ }
101
+
102
+ const spec = (raw ?? {}) as {
103
+ type?: unknown
104
+ required?: unknown
105
+ default?: unknown
106
+ enum?: unknown
107
+ description?: unknown
108
+ }
109
+
110
+ if (spec.description !== undefined && typeof spec.description !== 'string') {
111
+ warnings.push(`input "${key}": description is not a string — ignored`)
112
+ }
113
+
114
+ 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`)
116
+ return { errors, warnings }
117
+ }
118
+ const t = spec.type as InputFieldSpec['type']
119
+
120
+ // Deploy-side and runtime must share this exact predicate (`=== true`), not
121
+ // a truthy check — `inputs.ts`'s own `validateInputValue` only treats
122
+ // `required` as active when it is literally `true`. Without this, a plain
123
+ // `required: 1` would deploy clean and then never actually be enforced.
124
+ if (spec.required !== undefined && typeof spec.required !== 'boolean') {
125
+ errors.push(`input "${key}": required must be a boolean`)
126
+ }
127
+
128
+ if (spec.required === true && spec.default !== undefined) {
129
+ errors.push(`input "${key}": required and default are mutually exclusive — a default always satisfies required`)
130
+ }
131
+
132
+ let enumOk = true
133
+ if (spec.enum !== undefined) {
134
+ if (t !== 'string' && t !== 'number') {
135
+ errors.push(`input "${key}": enum is only valid for string and number types`)
136
+ enumOk = false
137
+ } else if (!Array.isArray(spec.enum) || spec.enum.length === 0) {
138
+ errors.push(`input "${key}": enum must not be empty`)
139
+ enumOk = false
140
+ } else if (spec.enum.some((e) => typeof e !== t)) {
141
+ errors.push(`input "${key}": enum values must match the declared type`)
142
+ enumOk = false
143
+ } else if (t === 'number' && spec.enum.some((e) => !Number.isFinite(e as number))) {
144
+ // TYPE_CHECK's own `number` check already excludes NaN/Infinity from
145
+ // values — a member of `enum` that no value could ever equal is
146
+ // unreachable and can only be an authoring mistake.
147
+ errors.push(`input "${key}": enum values must be finite numbers`)
148
+ enumOk = false
149
+ }
150
+ }
151
+
152
+ let defaultOk = true
153
+ if (spec.default !== undefined) {
154
+ if (!TYPE_CHECK[t](spec.default)) {
155
+ errors.push(`input "${key}": default must match the declared type`)
156
+ defaultOk = false
157
+ }
158
+ }
159
+
160
+ if (spec.enum !== undefined && spec.default !== undefined && enumOk && defaultOk) {
161
+ if (!(spec.enum as unknown[]).includes(spec.default)) {
162
+ errors.push(`input "${key}": default must be one of the enum values`)
163
+ }
164
+ }
165
+
166
+ return { errors, warnings }
167
+ }
168
+
169
+ export function validateInputValue(
170
+ schema: InputsSchema | undefined,
171
+ value: Record<string, unknown> | undefined | null,
172
+ ): InputValidation {
173
+ const given = value ?? {}
174
+ if (!schema || Object.keys(schema).length === 0) {
175
+ return Object.keys(given).length === 0
176
+ ? { ok: true, value: {} }
177
+ : { ok: false, errors: ['this automation declares no inputs — remove the input and run again'] }
178
+ }
179
+ const errors: string[] = []
180
+ const out: Record<string, unknown> = {}
181
+ for (const key of Object.keys(given)) {
182
+ // `Object.hasOwn`, not `key in schema`: the `in` operator also sees
183
+ // inherited members — every plain object "has" `toString` via
184
+ // `Object.prototype` — so a value keyed `toString` would slip past an
185
+ // undeclared-field check that used `in`.
186
+ if (!Object.hasOwn(schema, key)) errors.push(`"${key}" is not a declared input`)
187
+ }
188
+ for (const [key, spec] of Object.entries(schema)) {
189
+ // Same reasoning in reverse: plain `given[key]` for key `constructor`
190
+ // resolves to `Object.prototype.constructor` (a function) rather than
191
+ // `undefined` when the caller never supplied one, which would run type
192
+ // checks against Object's own constructor instead of treating the field
193
+ // as absent.
194
+ const v = Object.hasOwn(given, key) ? given[key] : undefined
195
+ if (v === undefined) {
196
+ if (spec.default !== undefined) out[key] = structuredClone(spec.default)
197
+ // `=== true`, not truthy: a legacy/malformed `required: 1` must not be
198
+ // silently enforced here when `validateManifest` already refuses it as
199
+ // "not a boolean" — the two sides share one predicate on purpose.
200
+ else if (spec.required === true) errors.push(`"${key}" is required`)
201
+ continue
202
+ }
203
+ // `Object.hasOwn`, not a plain lookup: `TYPE_CHECK` is an object literal,
204
+ // so `TYPE_CHECK['toString']` resolves to `Object.prototype.toString` —
205
+ // truthy, and callable — rather than `undefined`. A spec of `{ type:
206
+ // 'toString' }` would then pass `check(v)` for ANY `v` instead of being
207
+ // refused as the unknown type it is.
208
+ const check = Object.hasOwn(TYPE_CHECK, spec.type) ? TYPE_CHECK[spec.type as InputFieldSpec['type']] : undefined
209
+ if (!check) {
210
+ // A stored manifest can predate this SDK version and carry a `type`
211
+ // this build has never heard of (pre-input-validation, `inputs` was an
212
+ // unknown key with no shape checking at all). Fail the field, don't
213
+ // crash the run route.
214
+ errors.push(`"${key}" has an unknown declared type "${String(spec.type)}"`)
215
+ continue
216
+ }
217
+ if (!check(v)) {
218
+ errors.push(`"${key}" must be of type ${spec.type}`)
219
+ continue
220
+ }
221
+ if (spec.enum && !spec.enum.includes(v as string | number)) {
222
+ errors.push(`"${key}" must be one of ${spec.enum.join(', ')}`)
223
+ continue
224
+ }
225
+ out[key] = v
226
+ }
227
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, value: out }
228
+ }
229
+
230
+ /**
231
+ * A stored manifest's `inputs` key, admitted only when structurally valid.
232
+ *
233
+ * Versions deployed before inputs existed could carry ANY value under this
234
+ * key (it was warn-and-store), and `startRun` must not let a stray legacy
235
+ * blob retroactively break a working schedule — an invalid schema is treated
236
+ * as "declares no inputs", never as a refusal.
237
+ */
238
+ export function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined {
239
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined
240
+ for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
241
+ if (checkInputFieldSpec(key, raw).errors.length > 0) return undefined
242
+ }
243
+ return inputs as InputsSchema
244
+ }
package/src/manifest.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CronExpressionParser } from 'cron-parser'
2
+ import { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'
2
3
 
3
4
  const SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'
4
5
  const NAME_RE = new RegExp(`^${SEGMENT}$`)
@@ -41,6 +42,18 @@ const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/
41
42
  // deploy.
42
43
  const ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/
43
44
 
45
+ // `plugin:<install>:<capability>`. The service does NOT validate
46
+ // `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an
47
+ // admin can name an install "My CRM" and it works fine everywhere except
48
+ // here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog
49
+ // default is kebab) is what makes an install's name usable from a manifest;
50
+ // one outside it has to be renamed before an automation can grant it. The
51
+ // capability half is deliberately wider: MCP tool names and spec capability
52
+ // names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No
53
+ // wildcard in either half — the manifest is the reviewable list of what the
54
+ // automation can reach, same as `http:`.
55
+ const PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
56
+
44
57
  /** Namespaces whose value is not a SEGMENT. */
45
58
  const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
46
59
  http: { re: HOST_RE, hint: 'a hostname, e.g. "http:api.stripe.com" (no scheme, no path, no wildcard)' },
@@ -49,10 +62,17 @@ const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
49
62
  re: ACTION_API_NAME_RE,
50
63
  hint: 'one published Action apiName, e.g. "governed:approveInvoice" (camelCase, no wildcard)',
51
64
  },
65
+ plugin: {
66
+ re: PLUGIN_GRANT_RE,
67
+ hint:
68
+ '<install>:<capability>, e.g. "plugin:crm:create_ticket" — the install name from '
69
+ + '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'
70
+ + ' — rename the install if its name has capitals or spaces',
71
+ },
52
72
  }
53
73
 
54
74
  const KNOWN_KEYS = new Set([
55
- 'name', 'trigger', 'grants', 'concurrency', 'retries', 'description',
75
+ 'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',
56
76
  ])
57
77
 
58
78
  export interface ValidationResult {
@@ -88,6 +108,7 @@ export function validateManifest(input: unknown): ValidationResult {
88
108
  name?: unknown
89
109
  trigger?: unknown
90
110
  grants?: unknown
111
+ inputs?: unknown
91
112
  concurrency?: unknown
92
113
  retries?: unknown
93
114
  }
@@ -176,6 +197,61 @@ export function validateManifest(input: unknown): ValidationResult {
176
197
  errors.push('retries must be an integer between 0 and 5')
177
198
  }
178
199
 
200
+ const inputs = m.inputs
201
+ if (inputs !== undefined) {
202
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {
203
+ errors.push('inputs must be an object of { name: { type, … } }')
204
+ } else {
205
+ // Per-field rules (name shape, type, required/default shape, enum) live
206
+ // in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the
207
+ // two can never drift on what "a well-formed input field" means.
208
+ for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
209
+ const field = checkInputFieldSpec(key, raw)
210
+ errors.push(...field.errors)
211
+ warnings.push(...field.warnings)
212
+ }
213
+ // A cron fire has no one to prompt: every required field must be
214
+ // satisfiable from defaults, or the schedule would fail on every tick.
215
+ const trig = m.trigger as { cron?: unknown } | undefined
216
+ if (typeof trig?.cron === 'string') {
217
+ for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
218
+ const spec = raw as { required?: unknown; default?: unknown }
219
+ if (spec?.required === true && spec.default === undefined) {
220
+ errors.push(
221
+ `input "${key}" is required with no default, and the trigger is a cron — `
222
+ + 'cron has nobody to ask. Add a default or make the trigger manual.',
223
+ )
224
+ }
225
+ }
226
+ }
227
+ // A run's input is capped at MAX_INPUT_BYTES when it starts. If the
228
+ // declared defaults alone already exceed that, a cron fire (or a bare
229
+ // Run-now) fails inside run-open before any run row exists — a silent
230
+ // death only visible in runner logs. Catch it here, the one place the
231
+ // author is still looking at the file.
232
+ const defaultsOnly: Record<string, unknown> = {}
233
+ for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
234
+ const spec = raw as { default?: unknown }
235
+ if (spec && typeof spec === 'object' && spec.default !== undefined) {
236
+ defaultsOnly[key] = spec.default
237
+ }
238
+ }
239
+ try {
240
+ const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length
241
+ if (bytes > MAX_INPUT_BYTES) {
242
+ errors.push(
243
+ `input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `
244
+ + 'run-input cap, so every run would fail at start. Slim the defaults.',
245
+ )
246
+ }
247
+ } catch {
248
+ // A default that JSON.stringify chokes on (circular, throwing toJSON)
249
+ // is practically unreachable — the manifest itself must serialize to
250
+ // deploy at all — but the size check must never be the thing that throws.
251
+ }
252
+ }
253
+ }
254
+
179
255
  if (input && typeof input === 'object' && !Array.isArray(input)) {
180
256
  for (const key of Object.keys(input)) {
181
257
  if (!KNOWN_KEYS.has(key)) {
@@ -33,6 +33,7 @@ import type {
33
33
  BlueprintQueryResult,
34
34
  HttpRequest,
35
35
  HttpResponse,
36
+ PluginCallResult,
36
37
  } from './types'
37
38
 
38
39
  const SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'
@@ -58,6 +59,8 @@ export interface StepTools {
58
59
  * reasoning `ctx.blueprint.query` narrows a warehouse row.
59
60
  */
60
61
  run<T>(id: string, fn: () => Promise<T>): Promise<unknown>
62
+ /** Absent on hosts that predate it — the runtime falls back to an inline wait. */
63
+ sleep?(id: string, ms: number): Promise<void>
61
64
  }
62
65
 
63
66
  interface Deps {
@@ -67,6 +70,8 @@ interface Deps {
67
70
  grants: string[]
68
71
  /** The platform's step tools for THIS execution. */
69
72
  step: StepTools
73
+ /** The run's frozen input row, or absent when the manifest declares none. */
74
+ input?: Record<string, unknown>
70
75
  /** Zero-indexed run attempt, stamped onto every row this context writes. */
71
76
  attempt?: number
72
77
  /**
@@ -350,11 +355,31 @@ export function buildContext(deps: Deps): AutomationContext {
350
355
  })) as T
351
356
  }
352
357
 
358
+ /**
359
+ * Durable pause. No step row is written: a row recorded after a memoized
360
+ * sleep would be re-recorded by every later execution (the code after an
361
+ * awaited memoized step re-runs per resumption), and unlike `runStep`
362
+ * there is no body to write it from exactly once.
363
+ */
364
+ const sleepStep = async (name: string, ms: number): Promise<void> => {
365
+ if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)
366
+ namesThisExecution.add(name)
367
+ if (deps.step.sleep) {
368
+ await deps.step.sleep(name, ms)
369
+ return
370
+ }
371
+ // Host without a sleep arm (an old dev worker): wait inline. Correct,
372
+ // just not durable — acceptable for the host that cannot resume anyway.
373
+ await new Promise((resolve) => setTimeout(resolve, ms))
374
+ }
375
+
353
376
  return {
354
377
  runId: deps.runId,
355
378
  workspaceId: deps.workspaceId,
379
+ // The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.
380
+ input: deps.input ?? {},
356
381
 
357
- step: { run: runStep },
382
+ step: { run: runStep, sleep: sleepStep },
358
383
 
359
384
  async log(message, data) {
360
385
  // Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a
@@ -378,6 +403,29 @@ export function buildContext(deps: Deps): AutomationContext {
378
403
  }
379
404
  },
380
405
 
406
+ plugin(install: string) {
407
+ return {
408
+ call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>
409
+ step('plugin', `plugin:${install}:${capability}`, async () => {
410
+ // Pre-flighted locally so an author reads the grant by name, in the
411
+ // same words the service uses. The service checks it again — this
412
+ // copy exists for the message, not for the authority.
413
+ requireGrant(`plugin:${install}:${capability}`)
414
+ const res = await scoped('/ctx/plugin-call', {
415
+ method: 'POST',
416
+ body: JSON.stringify({ install, capability, input: input ?? {} }),
417
+ })
418
+ if (!res.ok) {
419
+ throw new Error(
420
+ `ctx.plugin("${install}").call("${capability}") → ${res.status} ${await refusal(res)}`,
421
+ )
422
+ }
423
+ // Two levels: the service envelope's data, then PluginCallResult's own data.
424
+ return ((await res.json()) as { data: PluginCallResult<T> }).data
425
+ }) as Promise<PluginCallResult<T>>,
426
+ }
427
+ },
428
+
381
429
  http: {
382
430
  fetch: (req: HttpRequest) =>
383
431
  step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {
package/src/testing.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  Grant,
16
16
  HttpRequest,
17
17
  HttpResponse,
18
+ PluginCallResult,
18
19
  } from './types'
19
20
 
20
21
  /**
@@ -46,8 +47,8 @@ import type {
46
47
  * for a refusal, and on `calls` for what ran.
47
48
  */
48
49
  export interface TestCall {
49
- kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint' | 'action'
50
- /** Step name, log message, agent slug, URL, object type, or Action apiName. */
50
+ kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action'
51
+ /** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */
51
52
  label: string
52
53
  /** Present on a step: how it ended. */
53
54
  status?: 'ok' | 'error'
@@ -57,6 +58,9 @@ export interface TestCall {
57
58
  export interface TestContextOptions {
58
59
  runId?: string
59
60
  workspaceId?: string
61
+ /** What the run was started with. Passed through verbatim — a unit test
62
+ * states exactly what the handler sees; defaults are `startRun`'s job. */
63
+ input?: Record<string, unknown>
60
64
  /**
61
65
  * The grants the manifest declares.
62
66
  *
@@ -70,6 +74,15 @@ export interface TestContextOptions {
70
74
  grants?: readonly Grant[]
71
75
  /** Per-slug agent answers. An unstubbed agent throws rather than answering. */
72
76
  agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
77
+ /**
78
+ * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.
79
+ * An unstubbed capability throws rather than answering — a fabricated
80
+ * `{ data: {} }` is a test that passes while asserting nothing.
81
+ */
82
+ plugins?: Record<
83
+ string,
84
+ Record<string, (input: Record<string, unknown>) => Promise<PluginCallResult> | PluginCallResult>
85
+ >
73
86
  /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */
74
87
  http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse
75
88
  /** Rows per object type. An unstubbed type returns no rows, which is a real
@@ -130,6 +143,7 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
130
143
  const ctx: AutomationContext = {
131
144
  runId: options.runId ?? 'test-run',
132
145
  workspaceId: options.workspaceId ?? 'test-workspace',
146
+ input: options.input ?? {},
133
147
 
134
148
  step: {
135
149
  async run<T>(name: string, fn: () => Promise<T>): Promise<T> {
@@ -147,6 +161,15 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
147
161
  }
148
162
  })
149
163
  },
164
+
165
+ async sleep(name: string): Promise<void> {
166
+ if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
167
+ seenNames.add(name)
168
+ steps.push(name)
169
+ // Recorded, never waited: a test suite that really slept out its
170
+ // backoffs would take minutes to say nothing.
171
+ calls.push({ kind: 'step', label: name, status: 'ok' })
172
+ },
150
173
  },
151
174
 
152
175
  async log(message, data) {
@@ -174,6 +197,31 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
174
197
  }
175
198
  },
176
199
 
200
+ plugin(install: string) {
201
+ return {
202
+ async call<T = unknown>(capability: string, input?: Record<string, unknown>) {
203
+ requireGrant(`plugin:${install}:${capability}`)
204
+ const stub = options.plugins?.[install]?.[capability]
205
+ // Before the record, matching the contract on `TestCall` and the
206
+ // `action` arm. (`agent` and `http` record first — a pre-existing
207
+ // divergence.)
208
+ if (!stub) {
209
+ throw new Error(
210
+ `No plugin stub for "${install}".${capability}. Pass ` +
211
+ // Quoted, unlike a bare identifier: an install name defaults to
212
+ // the catalog kind (kebab, e.g. "github-prod") and a capability
213
+ // can be dotted ("run.query") — neither survives as an object
214
+ // key without quotes, so the unquoted form the author would
215
+ // paste back in does not parse.
216
+ `plugins: { '${install}': { '${capability}': () => ({ data: … }) } } to createTestContext.`,
217
+ )
218
+ }
219
+ calls.push({ kind: 'plugin', label: `${install}:${capability}` })
220
+ return (await stub(input ?? {})) as PluginCallResult<T>
221
+ },
222
+ }
223
+ },
224
+
177
225
  http: {
178
226
  async fetch(req: HttpRequest) {
179
227
  let host: string
package/src/types.ts CHANGED
@@ -34,6 +34,16 @@ export type AutomationTrigger =
34
34
  export type Grant =
35
35
  | 'blueprint:read'
36
36
  | `agent:${string}:run`
37
+ /**
38
+ * One capability of one Plugin install: `plugin:<install>:<capability>`.
39
+ *
40
+ * `<install>` is the install's name as `frontera plugin list` shows it
41
+ * (lowercase, no spaces); `<capability>` is the capability's name on that
42
+ * install. One grant per capability — there is no wildcard, for the same
43
+ * reason `http:` has none: the manifest is the reviewable list of what the
44
+ * automation can reach.
45
+ */
46
+ | `plugin:${string}:${string}`
37
47
  /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and
38
48
  * nothing else — a wildcard would ask a reviewer to reason about
39
49
  * subdomain-takeover risk, and the answer is usually wrong. */
@@ -49,10 +59,32 @@ export type Grant =
49
59
  * answer changes with every release — to know what the automation may do. */
50
60
  | `governed:${string}`
51
61
 
62
+ /** One declared run input. A deliberate subset of JSON Schema — the same
63
+ * philosophy as the grant grammar: small enough that a wrong shape is
64
+ * refusable with a sentence, wide enough for real parameters. */
65
+ export interface InputFieldSpec {
66
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array'
67
+ /** Refused at run start when absent. Mutually exclusive with `default`. */
68
+ required?: boolean
69
+ /** Applied at run start when the field is absent. Cron runs rely on these. */
70
+ default?: unknown
71
+ description?: string
72
+ /** Allowed values — string and number types only. */
73
+ enum?: readonly (string | number)[]
74
+ }
75
+
76
+ export type InputsSchema = Record<string, InputFieldSpec>
77
+
52
78
  export interface AutomationManifest {
53
79
  name: string
54
80
  trigger: AutomationTrigger
55
81
  grants?: readonly Grant[]
82
+ /**
83
+ * Declared run inputs, validated and defaulted at run start. Absent means
84
+ * this automation takes no input — starting a run WITH input for such a
85
+ * version is refused. See `InputFieldSpec`.
86
+ */
87
+ inputs?: InputsSchema
56
88
  concurrency?: number
57
89
  /**
58
90
  * Times the platform may retry a run that FAILED. Default 0, and the opt-in
@@ -92,6 +124,7 @@ export interface ResolvedAutomationManifest extends AutomationManifest {
92
124
  readonly name: string
93
125
  readonly trigger: Readonly<AutomationTrigger>
94
126
  readonly grants: readonly Grant[]
127
+ readonly inputs?: Readonly<InputsSchema>
95
128
  readonly concurrency: number
96
129
  readonly retries: number
97
130
  readonly description?: string
@@ -101,6 +134,34 @@ export interface AgentHandle {
101
134
  run(prompt: string): Promise<{ text: string }>
102
135
  }
103
136
 
137
+ /** What `ctx.plugin(install).call(...)` resolves to. */
138
+ export interface PluginCallResult<T = unknown> {
139
+ /** Whatever the capability returned. Shape is the plugin's, not the platform's. */
140
+ data: T
141
+ }
142
+
143
+ export interface PluginHandle {
144
+ /**
145
+ * Invoke one capability of this install.
146
+ *
147
+ * Governed by the install's policy exactly as an agent's tool call is —
148
+ * a disabled install, a `read_only` Action policy, a parameter constraint
149
+ * or a missing workspace account all refuse here with the reason named.
150
+ * A capability that requires approval cannot be called from an automation
151
+ * at all (nobody to ask), and `deploy` refuses the grant up front.
152
+ *
153
+ * A failure reported by the plugin itself is thrown, carrying the plugin's
154
+ * message. A success resolves to `{ data }` — there is no `ok` flag to
155
+ * branch on, only the value.
156
+ *
157
+ * Dry in a dev run: returns `{ data: null }` and sends nothing.
158
+ */
159
+ call<T = unknown>(
160
+ capability: string,
161
+ input?: Record<string, unknown>,
162
+ ): Promise<PluginCallResult<T>>
163
+ }
164
+
104
165
  /**
105
166
  * Durable steps.
106
167
  *
@@ -131,6 +192,18 @@ export interface StepApi {
131
192
  * that used steps.
132
193
  */
133
194
  run<T>(name: string, fn: () => Promise<T>): Promise<T>
195
+
196
+ /**
197
+ * Park the run for `ms` milliseconds, durably, under a unique name.
198
+ *
199
+ * On the platform this is a real checkpoint: the run stops occupying a
200
+ * worker and resumes after the delay — pace provider polls with it (a
201
+ * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`
202
+ * and in dev runs it records and returns immediately, so tests and dry runs
203
+ * never actually wait. Shares the name-uniqueness rule with `run`: the
204
+ * platform memoizes both by name.
205
+ */
206
+ sleep(name: string, ms: number): Promise<void>
134
207
  }
135
208
 
136
209
  /**
@@ -242,9 +315,19 @@ export interface ActionSubmitResult {
242
315
  export interface AutomationContext {
243
316
  runId: string
244
317
  workspaceId: string
318
+ /**
319
+ * The values this run was started with — validated against the manifest's
320
+ * `inputs` schema and fixed on the run row at start, so every resumption
321
+ * and retried attempt sees the same object. `{}` when the manifest declares
322
+ * no inputs. Visible in the run trace by design: never put a secret here —
323
+ * `secret:` grants are the credential path.
324
+ */
325
+ input: Record<string, unknown>
245
326
  /** Never rejects — telemetry must not be able to fail a run. */
246
327
  log(message: string, data?: Record<string, unknown>): Promise<void>
247
328
  agent(slug: string): AgentHandle
329
+ /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */
330
+ plugin(install: string): PluginHandle
248
331
  http: {
249
332
  /**
250
333
  * Call an allowlisted host, optionally with a workspace secret injected