@frontera-sdk/functions 1.49.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/LICENSE +202 -0
- package/README.md +122 -0
- package/package.json +48 -0
- package/src/define.ts +60 -0
- package/src/index.ts +27 -0
- package/src/inputs.ts +340 -0
- package/src/manifest.ts +341 -0
- package/src/messages.ts +108 -0
- package/src/runtime-context.ts +836 -0
- package/src/testing.ts +323 -0
- package/src/types.ts +600 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
// `import type`, so this is erased at compile time and adds no runtime import —
|
|
2
|
+
// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because
|
|
3
|
+
// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming
|
|
4
|
+
// `AutomationContext` needs it to resolve. See the packaging note in
|
|
5
|
+
// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —
|
|
6
|
+
// a subset install that cannot resolve it aborts `bun install` outright.
|
|
7
|
+
import type { WhereNode } from '@frontera-sdk/blueprint/types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cron, manual, and agent for now; event and webhook land with Event Triggers.
|
|
11
|
+
*
|
|
12
|
+
* The `?: never` members are load-bearing. Without them `{ cron, manual }`
|
|
13
|
+
* typechecks — TypeScript's excess-property check admits any key present on
|
|
14
|
+
* *some* member of a union — and the runner would have to decide at runtime
|
|
15
|
+
* what a both-shaped trigger means.
|
|
16
|
+
*
|
|
17
|
+
* `agent` is deliberately NOT part of that exclusion. Cron and manual answer
|
|
18
|
+
* "what fires this on its own"; `agent: true` answers "may a bound agent call
|
|
19
|
+
* this", which is an orthogonal question — a nightly reconciliation that an
|
|
20
|
+
* analyst can also ask an agent to run on demand is one automation, not two.
|
|
21
|
+
* So `agent` rides alongside either, and the third arm exists for the
|
|
22
|
+
* agent-only automation, which has no self-starting trigger at all.
|
|
23
|
+
*
|
|
24
|
+
* Declaring it is only the AUTHOR's half of the permission. A workspace
|
|
25
|
+
* operator must still bind the automation to one named agent before any tool
|
|
26
|
+
* is projected; see the design in
|
|
27
|
+
* docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.
|
|
28
|
+
*/
|
|
29
|
+
export type AutomationTrigger =
|
|
30
|
+
| { cron: string; manual?: never; agent?: true }
|
|
31
|
+
| { cron?: never; manual: true; agent?: true }
|
|
32
|
+
| { cron?: never; manual?: never; agent: true }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An author-time affordance, not a validation gate.
|
|
36
|
+
*
|
|
37
|
+
* `blueprint:read` is a literal, so the compiler completes it and offers "Did
|
|
38
|
+
* you mean 'blueprint:read'?" on a typo. `agent:${string}:run` admits any slug
|
|
39
|
+
* — including one that names no agent — so the union cannot be read as proof
|
|
40
|
+
* that a grant is well-formed. The runtime gate is `validateManifest` in
|
|
41
|
+
* `manifest.ts`; this exists to guide the author as they type.
|
|
42
|
+
*
|
|
43
|
+
* Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a
|
|
44
|
+
* non-breaking change. Narrowing `string` to a union later would break every
|
|
45
|
+
* automation already written, so it starts narrow.
|
|
46
|
+
*/
|
|
47
|
+
export type Grant =
|
|
48
|
+
| 'blueprint:read'
|
|
49
|
+
| `agent:${string}:run`
|
|
50
|
+
/**
|
|
51
|
+
* One capability of one Plugin install: `plugin:<install>:<capability>`.
|
|
52
|
+
*
|
|
53
|
+
* `<install>` is the install's name as `frontera plugin list` shows it
|
|
54
|
+
* (lowercase, no spaces); `<capability>` is the capability's name on that
|
|
55
|
+
* install. One grant per capability — there is no wildcard, for the same
|
|
56
|
+
* reason `http:` has none: the manifest is the reviewable list of what the
|
|
57
|
+
* automation can reach.
|
|
58
|
+
*/
|
|
59
|
+
| `plugin:${string}:${string}`
|
|
60
|
+
/** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and
|
|
61
|
+
* nothing else — a wildcard would ask a reviewer to reason about
|
|
62
|
+
* subdomain-takeover risk, and the answer is usually wrong. */
|
|
63
|
+
| `http:${string}`
|
|
64
|
+
/** The NAME of a workspace secret. Its VALUE never enters this process: you
|
|
65
|
+
* name it, the platform injects it server-side. */
|
|
66
|
+
| `secret:${string}`
|
|
67
|
+
/** One EXACT published Action apiName. `governed:approveInvoice` permits
|
|
68
|
+
* submitting that Action and nothing else.
|
|
69
|
+
*
|
|
70
|
+
* Not wildcardable, for the same reason `http:` is not: a reviewer reading
|
|
71
|
+
* `governed:*` would have to know the whole current Action catalog — and the
|
|
72
|
+
* answer changes with every release — to know what the automation may do. */
|
|
73
|
+
| `governed:${string}`
|
|
74
|
+
|
|
75
|
+
/** One declared run input. A deliberate subset of JSON Schema — the same
|
|
76
|
+
* philosophy as the grant grammar: small enough that a wrong shape is
|
|
77
|
+
* refusable with a sentence, wide enough for real parameters. */
|
|
78
|
+
export interface InputFieldSpec {
|
|
79
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
|
|
80
|
+
/** Refused at run start when absent. Mutually exclusive with `default`. */
|
|
81
|
+
required?: boolean
|
|
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. */
|
|
84
|
+
default?: unknown
|
|
85
|
+
description?: string
|
|
86
|
+
/** Allowed values — string and number types only. */
|
|
87
|
+
enum?: readonly (string | number)[]
|
|
88
|
+
/** `file` type only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
|
|
89
|
+
* A declaration aid: the value on the run row is only a reference, so this is
|
|
90
|
+
* enforced server-side at upload and at run start, never against the value here. */
|
|
91
|
+
accept?: readonly string[]
|
|
92
|
+
/** `file` type only. Maximum upload size in bytes, enforced server-side. */
|
|
93
|
+
maxBytes?: number
|
|
94
|
+
/**
|
|
95
|
+
* Mask this field's VALUE wherever a person reads the run.
|
|
96
|
+
*
|
|
97
|
+
* What it changes: the run list, the run page and `frontera function runs`
|
|
98
|
+
* show a placeholder instead of the value. What it does NOT change: the
|
|
99
|
+
* handler, every resumption and every retried attempt receive the real value,
|
|
100
|
+
* because the run row still holds it — this is a display control, not
|
|
101
|
+
* storage encryption and not an access control.
|
|
102
|
+
*
|
|
103
|
+
* What it CANNOT cover, stated here so the flag never reads as a promise it
|
|
104
|
+
* does not keep:
|
|
105
|
+
*
|
|
106
|
+
* - `ctx.log('…', { key: ctx.input.token })` — an author writing a value
|
|
107
|
+
* into a step detail publishes it, and nothing here can intercept that.
|
|
108
|
+
* - a redacted value the author TRANSFORMS before using it. The agent
|
|
109
|
+
* transcript on the run page is masked by exact occurrence, so a value
|
|
110
|
+
* interpolated into a prompt — or quoted back in the reply — is replaced.
|
|
111
|
+
* A value upper-cased, truncated or reformatted first no longer matches
|
|
112
|
+
* and is not found. Exact match is what can be done without guessing at
|
|
113
|
+
* substrings; the alternative, withholding transcripts entirely for any
|
|
114
|
+
* run with a redacted input, would take the review surface away from
|
|
115
|
+
* exactly the runs that most need reviewing.
|
|
116
|
+
* - `default` and `enum`, which are published in the version manifest and in
|
|
117
|
+
* any tool schema built from it. Declaring either alongside `redact` is
|
|
118
|
+
* refused at deploy for exactly that reason.
|
|
119
|
+
* - the run's own `result` and error message. A handler that returns the
|
|
120
|
+
* value — `return { note: ctx.input.customer_note }` — or throws an error
|
|
121
|
+
* quoting it publishes it on the same run page, unmasked, next to the
|
|
122
|
+
* masked input it came from. Only the INPUT is masked; what the handler
|
|
123
|
+
* chooses to emit is the handler's decision.
|
|
124
|
+
* - anything already recorded. Versions are append-only and the mask is
|
|
125
|
+
* frozen onto each run when it starts, so adding `redact` masks future
|
|
126
|
+
* runs and never rewrites history.
|
|
127
|
+
*
|
|
128
|
+
* A credential still belongs in a `secret:` grant, whose value never enters
|
|
129
|
+
* this process at all. `redact` is for the ordinary personal or commercial
|
|
130
|
+
* detail a run legitimately takes and a bystander has no reason to read.
|
|
131
|
+
*/
|
|
132
|
+
redact?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The value of a `file`-typed input.
|
|
138
|
+
*
|
|
139
|
+
* A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the
|
|
140
|
+
* canonical handle from the platform's unified file registry (see
|
|
141
|
+
* docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in
|
|
142
|
+
* storage; only this small id rides on the run row, so the 64KB input cap is
|
|
143
|
+
* untouched.
|
|
144
|
+
*
|
|
145
|
+
* One reference, two entry points: the run-form uploader gets a `fileId` for a
|
|
146
|
+
* file a person drops in, and an agent passes the `fileId` of a chat attachment
|
|
147
|
+
* it is already holding — both resolve identically downstream. The client never
|
|
148
|
+
* supplies a trusted path or URL; the SERVER authorizes the `fileId` against the
|
|
149
|
+
* caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.
|
|
150
|
+
*/
|
|
151
|
+
export interface FileRef {
|
|
152
|
+
fileId: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type InputsSchema = Record<string, InputFieldSpec>
|
|
156
|
+
|
|
157
|
+
export interface AutomationManifest {
|
|
158
|
+
name: string
|
|
159
|
+
trigger: AutomationTrigger
|
|
160
|
+
grants?: readonly Grant[]
|
|
161
|
+
/**
|
|
162
|
+
* Declared run inputs, validated and defaulted at run start. Absent means
|
|
163
|
+
* this automation takes no input — starting a run WITH input for such a
|
|
164
|
+
* version is refused. See `InputFieldSpec`.
|
|
165
|
+
*/
|
|
166
|
+
inputs?: InputsSchema
|
|
167
|
+
concurrency?: number
|
|
168
|
+
/**
|
|
169
|
+
* Times the platform may retry a run that FAILED. Default 0, and the opt-in
|
|
170
|
+
* is the contract.
|
|
171
|
+
*
|
|
172
|
+
* Setting this asserts your handler is safe to run twice. With `ctx.http` that
|
|
173
|
+
* is a real claim rather than a formality — a retried run that charged a card
|
|
174
|
+
* charges it again, and the platform cannot check idempotency on your behalf.
|
|
175
|
+
* Per-automation, not global, because you are the only one who knows.
|
|
176
|
+
*
|
|
177
|
+
* Retries do NOT extend the ctx call budget: each attempt is a separate run
|
|
178
|
+
* with its own meter.
|
|
179
|
+
*
|
|
180
|
+
* With steps, this is a bound on RUN attempts, and a step that fails is what
|
|
181
|
+
* consumes one. Completed steps are not re-executed on the next attempt —
|
|
182
|
+
* they return their stored results — so a retry resumes from the failure
|
|
183
|
+
* rather than starting the work again. That is the point of putting a call
|
|
184
|
+
* that costs something inside a step: `retries: 2` on a handler whose work is
|
|
185
|
+
* all in steps re-runs only the step that failed, while the same setting on a
|
|
186
|
+
* handler with no steps re-runs everything.
|
|
187
|
+
*/
|
|
188
|
+
retries?: number
|
|
189
|
+
description?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What `automation()` guarantees once defaults are applied — nothing optional
|
|
194
|
+
* left for a consumer to re-handle. Downstream code takes this, not
|
|
195
|
+
* `AutomationManifest`, so it never re-derives a fact already established.
|
|
196
|
+
*/
|
|
197
|
+
export interface ResolvedAutomationManifest extends AutomationManifest {
|
|
198
|
+
// Every member is readonly, not just the two with defaults. `Object.freeze`
|
|
199
|
+
// in `define.ts` freezes the whole object at runtime, so leaving `name` or
|
|
200
|
+
// `description` mutable in the type means `d.manifest.name = 'x'` compiles
|
|
201
|
+
// and then throws — the same compile-clean/throw-at-runtime gap that the
|
|
202
|
+
// removed `as string[]` cast used to create.
|
|
203
|
+
readonly name: string
|
|
204
|
+
readonly trigger: Readonly<AutomationTrigger>
|
|
205
|
+
readonly grants: readonly Grant[]
|
|
206
|
+
readonly inputs?: Readonly<InputsSchema>
|
|
207
|
+
readonly concurrency: number
|
|
208
|
+
readonly retries: number
|
|
209
|
+
readonly description?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface AgentHandle {
|
|
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
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** What `ctx.plugin(install).call(...)` resolves to. */
|
|
236
|
+
export interface PluginCallResult<T = unknown> {
|
|
237
|
+
/** Whatever the capability returned. Shape is the plugin's, not the platform's. */
|
|
238
|
+
data: T
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface PluginHandle {
|
|
242
|
+
/**
|
|
243
|
+
* Invoke one capability of this install.
|
|
244
|
+
*
|
|
245
|
+
* Governed by the install's policy exactly as an agent's tool call is —
|
|
246
|
+
* a disabled install, a `read_only` Action policy, a parameter constraint
|
|
247
|
+
* or a missing workspace account all refuse here with the reason named.
|
|
248
|
+
* A capability that requires approval cannot be called from an automation
|
|
249
|
+
* at all (nobody to ask), and `deploy` refuses the grant up front.
|
|
250
|
+
*
|
|
251
|
+
* A failure reported by the plugin itself is thrown, carrying the plugin's
|
|
252
|
+
* message. A success resolves to `{ data }` — there is no `ok` flag to
|
|
253
|
+
* branch on, only the value.
|
|
254
|
+
*
|
|
255
|
+
* Dry in a dev run: returns `{ data: null }` and sends nothing.
|
|
256
|
+
*/
|
|
257
|
+
call<T = unknown>(
|
|
258
|
+
capability: string,
|
|
259
|
+
input?: Record<string, unknown>,
|
|
260
|
+
): Promise<PluginCallResult<T>>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Durable steps.
|
|
265
|
+
*
|
|
266
|
+
* A step is the unit the platform can memoize, retry and draw. Work inside one
|
|
267
|
+
* runs at most once per run; work outside one runs again every time the
|
|
268
|
+
* platform resumes the handler, which it does after every step completes.
|
|
269
|
+
*
|
|
270
|
+
* That resumption is the whole model and it is what the three rules below are
|
|
271
|
+
* about — none of them is a style preference.
|
|
272
|
+
*/
|
|
273
|
+
export interface StepApi {
|
|
274
|
+
/**
|
|
275
|
+
* Run `fn` as a durable step and return its result.
|
|
276
|
+
*
|
|
277
|
+
* Three rules, all enforced or observable rather than advisory:
|
|
278
|
+
*
|
|
279
|
+
* 1. **`name` must be unique within a run.** The platform memoizes by it, so a
|
|
280
|
+
* repeated name would silently hand back the FIRST call's result. Inside a
|
|
281
|
+
* loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the
|
|
282
|
+
* run naming the collision rather than returning the wrong value.
|
|
283
|
+
* 2. **The result must be JSON-serializable.** It is stored and replayed, so a
|
|
284
|
+
* `Date` comes back as a string and a class instance comes back as a plain
|
|
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.
|
|
289
|
+
* 3. **Code outside a step re-executes.** After each step the handler restarts
|
|
290
|
+
* from the top with completed steps returning their stored results. A
|
|
291
|
+
* `ctx.http` call sitting outside a step therefore fires once per step, and
|
|
292
|
+
* spends its call budget every time. The Console flags such calls on a run
|
|
293
|
+
* that used steps.
|
|
294
|
+
*/
|
|
295
|
+
run<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Park the run for `ms` milliseconds, durably, under a unique name.
|
|
299
|
+
*
|
|
300
|
+
* On the platform this is a real checkpoint: the run stops occupying a
|
|
301
|
+
* worker and resumes after the delay — pace provider polls with it (a
|
|
302
|
+
* measured 429 arrived after ~7 back-to-back polls). In `createTestContext`
|
|
303
|
+
* and in dev runs it records and returns immediately, so tests and dry runs
|
|
304
|
+
* never actually wait. Shares the name-uniqueness rule with `run`: the
|
|
305
|
+
* platform memoizes both by name.
|
|
306
|
+
*/
|
|
307
|
+
sleep(name: string, ms: number): Promise<void>
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The 13 lifecycle states a governed Action Request can hold.
|
|
312
|
+
*
|
|
313
|
+
* A deliberate copy of a WIRE contract, not shared code — same reasoning as
|
|
314
|
+
* `RegistryEntry` in the runner: this package must install from public npm with
|
|
315
|
+
* a three-package dependency list, and importing the service's own enum would
|
|
316
|
+
* drag drizzle and the schema into an author's `bun install`. The service's
|
|
317
|
+
* `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves
|
|
318
|
+
* the two agree.
|
|
319
|
+
*/
|
|
320
|
+
export type ActionRequestLifecycle =
|
|
321
|
+
| 'received'
|
|
322
|
+
| 'awaiting_approval'
|
|
323
|
+
| 'ready'
|
|
324
|
+
| 'executing'
|
|
325
|
+
| 'finalizing'
|
|
326
|
+
| 'succeeded'
|
|
327
|
+
| 'rejected'
|
|
328
|
+
| 'expired'
|
|
329
|
+
| 'cancelled'
|
|
330
|
+
| 'failed'
|
|
331
|
+
| 'outcome_unknown'
|
|
332
|
+
| 'awaiting_resolution'
|
|
333
|
+
| 'closed_unknown'
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* `subjectRef` and `expectedSubjectVersion` are paired deliberately.
|
|
337
|
+
*
|
|
338
|
+
* The plane requires BOTH for an Action over an existing subject and refuses
|
|
339
|
+
* BOTH for a create Action, so independently-optional fields would let an
|
|
340
|
+
* author write a submission that cannot be accepted and only find out at
|
|
341
|
+
* runtime. Which arm applies is the Action's decision, not the caller's — read
|
|
342
|
+
* it off the Action's `subject.mode`.
|
|
343
|
+
*/
|
|
344
|
+
export type ActionSubmission = {
|
|
345
|
+
/** Published Action apiName. Requires a `governed:<apiName>` grant. */
|
|
346
|
+
action: string
|
|
347
|
+
input: Record<string, unknown>
|
|
348
|
+
/** Required when the Action's definition says so. */
|
|
349
|
+
reason?: string
|
|
350
|
+
/**
|
|
351
|
+
* Tells two submissions from the SAME step apart.
|
|
352
|
+
*
|
|
353
|
+
* A step submits once by default. The idempotency key is derived from the run
|
|
354
|
+
* and the step alone, so a submission re-reached by a resumption or by a
|
|
355
|
+
* retried attempt is the SAME key and the plane hands back the original
|
|
356
|
+
* request instead of making a second one. Your own retry loop behaves the
|
|
357
|
+
* same way: a submission that FAILED is not recorded, so submitting again
|
|
358
|
+
* after catching a transport error re-sends and the plane replays.
|
|
359
|
+
*
|
|
360
|
+
* What you cannot do by default is submit twice on purpose. Two submissions
|
|
361
|
+
* the platform cannot tell apart derive one key AND one semantic
|
|
362
|
+
* fingerprint, so the plane would replay the first and answer both calls with
|
|
363
|
+
* the same id — no error, one effect, a green run. Rather than let that
|
|
364
|
+
* happen, the second call is refused before it leaves your process, naming
|
|
365
|
+
* this field.
|
|
366
|
+
*
|
|
367
|
+
* Pass a distinct `submissionKey` per submission to say you meant it — a
|
|
368
|
+
* business identity is the right value, not a counter:
|
|
369
|
+
*
|
|
370
|
+
* ```ts
|
|
371
|
+
* await ctx.step.run('flag', async () => {
|
|
372
|
+
* for (const row of rows) {
|
|
373
|
+
* await ctx.action.submit({
|
|
374
|
+
* action: 'flagForAudit',
|
|
375
|
+
* // Stable for THIS row across every attempt. An array index is not:
|
|
376
|
+
* // if the re-read returns the rows in another order, an index would
|
|
377
|
+
* // bind row B's submission to row A's key.
|
|
378
|
+
* submissionKey: row.id,
|
|
379
|
+
* input: { rowId: row.id },
|
|
380
|
+
* })
|
|
381
|
+
* }
|
|
382
|
+
* })
|
|
383
|
+
* ```
|
|
384
|
+
*
|
|
385
|
+
* It must be stable across attempts for the same intended submission, which
|
|
386
|
+
* is why the platform cannot derive it for you — only your code knows which
|
|
387
|
+
* of two submissions is "the same one again". An empty string is refused;
|
|
388
|
+
* omit it entirely to mean "this step submits once".
|
|
389
|
+
*/
|
|
390
|
+
submissionKey?: string
|
|
391
|
+
} & (
|
|
392
|
+
| {
|
|
393
|
+
subjectRef: { objectTypeId: string; objectId: string }
|
|
394
|
+
/** The version you believe the subject is at: a submission built from a
|
|
395
|
+
* stale read must lose rather than overwrite. */
|
|
396
|
+
expectedSubjectVersion: string
|
|
397
|
+
}
|
|
398
|
+
| { subjectRef?: never; expectedSubjectVersion?: never }
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
export interface ActionSubmitResult {
|
|
402
|
+
requestId: string
|
|
403
|
+
/**
|
|
404
|
+
* Where the request stopped, NOT whether the effect happened.
|
|
405
|
+
*
|
|
406
|
+
* `ready` means accepted and queued for dispatch. `awaiting_approval` means
|
|
407
|
+
* the Action requires a human and one has not decided yet — a normal return,
|
|
408
|
+
* not an error. Neither is a completed business fact.
|
|
409
|
+
*/
|
|
410
|
+
lifecycle: ActionRequestLifecycle
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* `notify` still arrives with a later slice; `governed` is here.
|
|
415
|
+
*/
|
|
416
|
+
export interface AutomationContext {
|
|
417
|
+
runId: string
|
|
418
|
+
workspaceId: string
|
|
419
|
+
/**
|
|
420
|
+
* The values this run was started with — validated against the manifest's
|
|
421
|
+
* `inputs` schema and fixed on the run row at start, so every resumption
|
|
422
|
+
* and retried attempt sees the same object. `{}` when the manifest declares
|
|
423
|
+
* no inputs. Visible in the run trace by design: never put a secret here —
|
|
424
|
+
* `secret:` grants are the credential path.
|
|
425
|
+
*/
|
|
426
|
+
input: Record<string, unknown>
|
|
427
|
+
/** Never rejects — telemetry must not be able to fail a run. */
|
|
428
|
+
log(message: string, data?: Record<string, unknown>): Promise<void>
|
|
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>
|
|
436
|
+
/** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */
|
|
437
|
+
plugin(install: string): PluginHandle
|
|
438
|
+
http: {
|
|
439
|
+
/**
|
|
440
|
+
* Call an allowlisted host, optionally with a workspace secret injected
|
|
441
|
+
* server-side.
|
|
442
|
+
*
|
|
443
|
+
* Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`
|
|
444
|
+
* is used. The secret's VALUE never enters this process — that is deliberate:
|
|
445
|
+
* a credential this process never held cannot be leaked by a stray
|
|
446
|
+
* `ctx.log`, an exception serialiser, or a dependency, and step details are
|
|
447
|
+
* rendered verbatim in the Console.
|
|
448
|
+
*
|
|
449
|
+
* An upstream 4xx/5xx comes back as `status`, not as a throw. An API
|
|
450
|
+
* answering 404 is data; only failures of the mechanism reject.
|
|
451
|
+
*/
|
|
452
|
+
fetch(req: HttpRequest): Promise<HttpResponse>
|
|
453
|
+
}
|
|
454
|
+
blueprint: {
|
|
455
|
+
query<T = Record<string, unknown>>(
|
|
456
|
+
objectType: string,
|
|
457
|
+
options?: BlueprintQueryOptions,
|
|
458
|
+
): Promise<BlueprintQueryResult<T>>
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Durable steps. See `StepApi`.
|
|
462
|
+
*
|
|
463
|
+
* Present on every automation — a handler that uses no steps behaves exactly
|
|
464
|
+
* as it did before this existed, because a run with no steps is never
|
|
465
|
+
* resumed.
|
|
466
|
+
*/
|
|
467
|
+
step: StepApi
|
|
468
|
+
action: {
|
|
469
|
+
/**
|
|
470
|
+
* Ask the governed write plane to perform one named business change.
|
|
471
|
+
*
|
|
472
|
+
* This is the ONLY way an automation changes a system of record. Your code
|
|
473
|
+
* never holds a write handle: you describe the change, and the plane
|
|
474
|
+
* authorizes it, approves it if the Action says so, dispatches it, confirms
|
|
475
|
+
* it and records it. An Action that declares `approval: required` cannot be
|
|
476
|
+
* talked out of it by the caller.
|
|
477
|
+
*
|
|
478
|
+
* Two rules:
|
|
479
|
+
*
|
|
480
|
+
* 1. **It must be called inside `ctx.step.run`.** Code outside a step
|
|
481
|
+
* re-executes after every step boundary, so a submit sitting there would
|
|
482
|
+
* fire once per boundary. Inside a step it runs once, and the step —
|
|
483
|
+
* identified by the row the service itself issued — is what makes the
|
|
484
|
+
* idempotency key stable across resumption and across a retried run.
|
|
485
|
+
*
|
|
486
|
+
* The service checks this rather than taking your word for it: the
|
|
487
|
+
* submission carries a step row id, and a submission whose id names no
|
|
488
|
+
* open step of this run is refused. What that check cannot do is make a
|
|
489
|
+
* determined bundle behave — your code runs unsandboxed in the same
|
|
490
|
+
* process as the run token, so it could open a step row purely to submit
|
|
491
|
+
* inside it. The bound is that such a step is a real row and shows up in
|
|
492
|
+
* the run trace, not that it is impossible.
|
|
493
|
+
* 2. **It never waits.** It returns as soon as the request is durably
|
|
494
|
+
* accepted. A run has nobody to ask for an approval and ten minutes to
|
|
495
|
+
* live, so blocking on a human is not something this can offer —
|
|
496
|
+
* `awaiting_approval` is a normal return value.
|
|
497
|
+
*
|
|
498
|
+
* The returned `lifecycle` is where the request stopped, not proof of
|
|
499
|
+
* effect. Poll the ledger, or let the Action's Business Event tell you.
|
|
500
|
+
*/
|
|
501
|
+
submit(request: ActionSubmission): Promise<ActionSubmitResult>
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export interface HttpRequest {
|
|
506
|
+
url: string
|
|
507
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
508
|
+
headers?: Record<string, string>
|
|
509
|
+
/** String only — no streaming, no binary. */
|
|
510
|
+
body?: string
|
|
511
|
+
/** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */
|
|
512
|
+
auth?: { header: string; secret: string; prefix?: string }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export interface HttpResponse {
|
|
516
|
+
status: number
|
|
517
|
+
headers: Record<string, string>
|
|
518
|
+
/** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a
|
|
519
|
+
* silently shortened response is a wrong answer that looks right. */
|
|
520
|
+
body: string
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface BlueprintQueryOptions {
|
|
524
|
+
/** The real Blueprint filter DSL, not a shorthand — `and`/`or`/`not`, ranges
|
|
525
|
+
* and date presets all work. A convenience subset with no escape hatch is the
|
|
526
|
+
* thing the first "overdue OR flagged" automation would have to work around. */
|
|
527
|
+
where?: WhereNode
|
|
528
|
+
select?: string[]
|
|
529
|
+
/** `dir`, not `direction` — matches `QueryRequest` exactly. */
|
|
530
|
+
orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>
|
|
531
|
+
/** Default 100, clamped to 1000. Exceeding the cap sets `hasMore`; it never
|
|
532
|
+
* truncates silently. */
|
|
533
|
+
limit?: number
|
|
534
|
+
/** Opaque. Pass back the previous result's `nextPageToken`; absent means the
|
|
535
|
+
* first page. Cursor-based, so a scan stays correct while the table moves
|
|
536
|
+
* underneath it — which a cron-driven automation's table always does. */
|
|
537
|
+
pageToken?: string
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export interface BlueprintQueryResult<T = Record<string, unknown>> {
|
|
541
|
+
rows: T[]
|
|
542
|
+
/** True when the query matched more rows than were returned. */
|
|
543
|
+
hasMore: boolean
|
|
544
|
+
/** Present iff `hasMore`. Feed to the next call's `pageToken`.
|
|
545
|
+
*
|
|
546
|
+
* Forwarded rather than narrowed away on purpose: `hasMore` on its own is a
|
|
547
|
+
* fact the author can do nothing about, which is how a digest over the first
|
|
548
|
+
* 500 of 5,000 rows reports success. */
|
|
549
|
+
nextPageToken?: string
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export type AutomationHandler = (ctx: AutomationContext) => Promise<unknown>
|
|
553
|
+
|
|
554
|
+
export interface AutomationDescriptor {
|
|
555
|
+
readonly manifest: ResolvedAutomationManifest
|
|
556
|
+
readonly handler: AutomationHandler
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* How a run came to exist, named once so the two sides of the invoke event
|
|
561
|
+
* cannot drift.
|
|
562
|
+
*
|
|
563
|
+
* This is not decoration. The service publishes `source` on the invoke event,
|
|
564
|
+
* the RUNNER resolves it back and posts it to the open-run call, and the
|
|
565
|
+
* service then refuses an invocation ticket arriving under a source that does
|
|
566
|
+
* not expect one. So a source the service knows and the runner does not is not
|
|
567
|
+
* a mis-filed run — it is no run at all: the ticket rides along, the open-run
|
|
568
|
+
* call 400s, and the caller waits forever on a request that never became
|
|
569
|
+
* anything. That is precisely how the App lane shipped broken.
|
|
570
|
+
*
|
|
571
|
+
* Both packages depend on this one, so the list lives here rather than being
|
|
572
|
+
* spelled out in each. Adding a source means adding it here, and the two
|
|
573
|
+
* consumers pick it up by construction.
|
|
574
|
+
*
|
|
575
|
+
* `cron` is deliberately absent: it is what the runner INFERS when the event
|
|
576
|
+
* names no source at all, so it is never carried on an event.
|
|
577
|
+
*/
|
|
578
|
+
export const EVENT_TRIGGER_SOURCES = ['manual', 'rehearsal', 'agent', 'app'] as const
|
|
579
|
+
export type EventTriggerSource = (typeof EVENT_TRIGGER_SOURCES)[number]
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The sources whose runs MUST arrive with an invocation ticket.
|
|
583
|
+
*
|
|
584
|
+
* A run under one of these has a caller whose identity exists only on the
|
|
585
|
+
* ticket, so a missing one is refused rather than opened unattributed. A ticket
|
|
586
|
+
* under any other source is refused too — it means the event was tampered with
|
|
587
|
+
* or two payloads got mixed.
|
|
588
|
+
*/
|
|
589
|
+
export const TICKETED_TRIGGER_SOURCES = ['agent', 'app'] as const
|
|
590
|
+
export type TicketedTriggerSource = (typeof TICKETED_TRIGGER_SOURCES)[number]
|
|
591
|
+
|
|
592
|
+
export function isEventTriggerSource(value: unknown): value is EventTriggerSource {
|
|
593
|
+
return typeof value === 'string'
|
|
594
|
+
&& (EVENT_TRIGGER_SOURCES as readonly string[]).includes(value)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function isTicketedTriggerSource(value: unknown): value is TicketedTriggerSource {
|
|
598
|
+
return typeof value === 'string'
|
|
599
|
+
&& (TICKETED_TRIGGER_SOURCES as readonly string[]).includes(value)
|
|
600
|
+
}
|