@frontera-sdk/automation 1.45.8 → 1.45.10
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 +6 -2
- package/src/define.ts +14 -0
- package/src/index.ts +2 -0
- package/src/inputs.ts +244 -0
- package/src/manifest.ts +77 -1
- package/src/runtime-context.ts +49 -1
- package/src/testing.ts +50 -2
- package/src/types.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/automation",
|
|
3
|
-
"version": "1.45.
|
|
3
|
+
"version": "1.45.10",
|
|
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.
|
|
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)) {
|
package/src/runtime-context.ts
CHANGED
|
@@ -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
|