@frontera-sdk/automation 1.43.6 → 1.43.8
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/runtime-context.ts +389 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/automation",
|
|
3
|
-
"version": "1.43.
|
|
3
|
+
"version": "1.43.8",
|
|
4
4
|
"description": "Author Frontera automations: manifest, triggers and the typed handler contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
".": {
|
|
24
24
|
"types": "./src/index.ts",
|
|
25
25
|
"import": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"./runtime": {
|
|
28
|
+
"types": "./src/runtime-context.ts",
|
|
29
|
+
"import": "./src/runtime-context.ts"
|
|
26
30
|
}
|
|
27
31
|
},
|
|
28
32
|
"scripts": {
|
|
@@ -34,7 +38,7 @@
|
|
|
34
38
|
"typescript": "^5.9.3"
|
|
35
39
|
},
|
|
36
40
|
"dependencies": {
|
|
37
|
-
"@frontera-sdk/blueprint": "
|
|
41
|
+
"@frontera-sdk/blueprint": "1.43.6",
|
|
38
42
|
"cron-parser": "^5.0.6"
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REAL `ctx` a handler receives — the one that talks to the service.
|
|
3
|
+
*
|
|
4
|
+
* It lives in the SDK rather than in the runner because it now has two
|
|
5
|
+
* consumers: the deployed runner executing a bundle, and the CLI's dev worker
|
|
6
|
+
* executing a file on a developer's machine. One implementation means a dev run
|
|
7
|
+
* and a production run cannot drift in what they enforce or how they word a
|
|
8
|
+
* refusal, which is the whole reason a dev loop is worth trusting.
|
|
9
|
+
*
|
|
10
|
+
* NOT re-exported from `index.ts`, and NOT in `AUTOMATION_SDK_FILES`: a
|
|
11
|
+
* scaffolded project vendors the authoring surface, and this file reaches the
|
|
12
|
+
* network. Authors get `createTestContext`; the two runtimes get this.
|
|
13
|
+
*/
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
15
|
+
// Wording lives in the SDK, not here: the runner refusing a call before it makes
|
|
16
|
+
// it, the service's own 403, and `createTestContext` on the author's machine all
|
|
17
|
+
// have to say the same sentence — a test that fails in different words than
|
|
18
|
+
// production teaches the wrong lesson. Re-exported below because this module is
|
|
19
|
+
// where the runner's code and tests have always reached for them.
|
|
20
|
+
import {
|
|
21
|
+
duplicateStepMessage as duplicateStepMessageText,
|
|
22
|
+
missingGrantMessage as missingGrantMessageText,
|
|
23
|
+
} from './messages'
|
|
24
|
+
import type {
|
|
25
|
+
AutomationContext,
|
|
26
|
+
BlueprintQueryOptions,
|
|
27
|
+
BlueprintQueryResult,
|
|
28
|
+
HttpRequest,
|
|
29
|
+
HttpResponse,
|
|
30
|
+
} from './types'
|
|
31
|
+
|
|
32
|
+
const SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The step tools this module needs, declared structurally rather than imported
|
|
36
|
+
* from `inngest`.
|
|
37
|
+
*
|
|
38
|
+
* Structural because it keeps the whole file testable with a two-line stub, and
|
|
39
|
+
* because it states exactly what `ctx` depends on — one method — instead of the
|
|
40
|
+
* platform's entire step surface. `function-builder.ts` passes the real object
|
|
41
|
+
* straight in, so the compiler still checks the two agree.
|
|
42
|
+
*/
|
|
43
|
+
export interface StepTools {
|
|
44
|
+
/**
|
|
45
|
+
* Returns `unknown`, deliberately, and not the body's own type.
|
|
46
|
+
*
|
|
47
|
+
* What comes back is not the value the body returned but its JSON round trip:
|
|
48
|
+
* the platform stores a step's result and replays it on the next execution, so
|
|
49
|
+
* a `Date` returns as a string and a class instance as a plain object. Typing
|
|
50
|
+
* this as `Promise<T>` here would erase that at exactly the boundary where it
|
|
51
|
+
* happens. `ctx.step.run` narrows it once, at the seam, with the same
|
|
52
|
+
* reasoning `ctx.blueprint.query` narrows a warehouse row.
|
|
53
|
+
*/
|
|
54
|
+
run<T>(id: string, fn: () => Promise<T>): Promise<unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Deps {
|
|
58
|
+
runId: string
|
|
59
|
+
workspaceId: string
|
|
60
|
+
runToken: string
|
|
61
|
+
grants: string[]
|
|
62
|
+
/** The platform's step tools for THIS execution. */
|
|
63
|
+
step: StepTools
|
|
64
|
+
/** Zero-indexed run attempt, stamped onto every row this context writes. */
|
|
65
|
+
attempt?: number
|
|
66
|
+
/**
|
|
67
|
+
* Where the service lives, when the caller knows better than the environment.
|
|
68
|
+
*
|
|
69
|
+
* The deployed runner reads `SERVICE_URL` from its own env; the CLI's dev
|
|
70
|
+
* worker knows it from the origin the developer logged into, and a
|
|
71
|
+
* module-level const read at import time cannot be told. Overriding here keeps
|
|
72
|
+
* this module usable in both processes rather than forked for one.
|
|
73
|
+
*/
|
|
74
|
+
serviceUrl?: string
|
|
75
|
+
/**
|
|
76
|
+
* The deployment-wide runner secret, or absent.
|
|
77
|
+
*
|
|
78
|
+
* PASSED IN, never read from the environment here. This module now runs in two
|
|
79
|
+
* processes, and only one of them may hold this token: the runner does, a
|
|
80
|
+
* developer's laptop must not. Reading `process.env` inside shared code moves
|
|
81
|
+
* that decision into an environment nobody reviews — a developer who has the
|
|
82
|
+
* variable exported for any reason, a copied env file, a locally-run runner,
|
|
83
|
+
* would have `automation dev` sending a workspace-wide credential from their
|
|
84
|
+
* machine with nothing on screen to say so.
|
|
85
|
+
*
|
|
86
|
+
* As a parameter the rule is structural: the dev worker cannot send it,
|
|
87
|
+
* because it has nothing to pass.
|
|
88
|
+
*/
|
|
89
|
+
runnerToken?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { duplicateStepMessage, missingGrantMessage } from './messages'
|
|
93
|
+
|
|
94
|
+
export class DuplicateStepNameError extends Error {
|
|
95
|
+
constructor(readonly stepName: string) {
|
|
96
|
+
super(duplicateStepMessageText(stepName))
|
|
97
|
+
this.name = 'DuplicateStepNameError'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class GrantError extends Error {
|
|
102
|
+
constructor(grant: string) {
|
|
103
|
+
super(missingGrantMessageText(grant))
|
|
104
|
+
this.name = 'GrantError'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildContext(deps: Deps): AutomationContext {
|
|
109
|
+
const serviceUrl = deps.serviceUrl ?? SERVICE_URL
|
|
110
|
+
const requireGrant = (grant: string) => {
|
|
111
|
+
if (!deps.grants.includes(grant)) throw new GrantError(grant)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Step and finish writes carry BOTH credentials, and the service takes either.
|
|
116
|
+
*
|
|
117
|
+
* The deployed runner has the shared secret; a dev worker on a developer's
|
|
118
|
+
* laptop must never hold it, and has only the run's own token — which is the
|
|
119
|
+
* stronger claim for a row that belongs to one run. Sending both means this
|
|
120
|
+
* module works unchanged in either process, which is the whole reason it can
|
|
121
|
+
* be reused by the CLI rather than forked.
|
|
122
|
+
*
|
|
123
|
+
* An empty runner token is omitted rather than sent blank: the service treats
|
|
124
|
+
* a PRESENT runner header as an assertion to verify, so a blank one would be
|
|
125
|
+
* a 401 instead of a fall-through to the run token.
|
|
126
|
+
*/
|
|
127
|
+
const runnerHeaders: Record<string, string> = {
|
|
128
|
+
'content-type': 'application/json',
|
|
129
|
+
'x-automation-run-token': deps.runToken,
|
|
130
|
+
...(deps.runnerToken ? { 'x-automation-runner-token': deps.runnerToken } : {}),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Which author-declared step the code writing a row is running inside.
|
|
135
|
+
*
|
|
136
|
+
* Async-local rather than a plain variable because two steps can be in flight
|
|
137
|
+
* at once — `Promise.all([ctx.step.run('a', …), ctx.step.run('b', …)])` is
|
|
138
|
+
* legal, and a shared mutable "current step" would file `a`'s ctx calls under
|
|
139
|
+
* `b` depending on interleaving. This is per-run, not module-global: two runs
|
|
140
|
+
* in one process must never see each other's scope.
|
|
141
|
+
*/
|
|
142
|
+
const stepScope = new AsyncLocalStorage<{ stepId: string }>()
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Append a row to the run's audit trail, returning the id the service gave it.
|
|
146
|
+
*
|
|
147
|
+
* Never throws. A step row is a record OF the work, not part of it — so a
|
|
148
|
+
* service blip while recording must not turn a completed operation into a
|
|
149
|
+
* failed run, and must not replace an in-flight failure with a transport
|
|
150
|
+
* error on the way to reporting it. The same reasoning is why a failure
|
|
151
|
+
* returns an empty id rather than propagating: losing the parent link on one
|
|
152
|
+
* row is strictly better than losing the run.
|
|
153
|
+
*/
|
|
154
|
+
const recordStep = async (body: Record<string, unknown>): Promise<string> => {
|
|
155
|
+
const parentStepId = stepScope.getStore()?.stepId
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: runnerHeaders,
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
// Only when there IS a parent. A step whose own row failed to write
|
|
162
|
+
// leaves an empty id in scope, and sending that empty string reaches
|
|
163
|
+
// Postgres as `''::uuid`, which errors — so the child row would be
|
|
164
|
+
// dropped too, quietly, because this whole path is non-fatal. One
|
|
165
|
+
// lost step row must not cost the calls made inside it.
|
|
166
|
+
...(parentStepId ? { parentStepId } : {}),
|
|
167
|
+
attempt: deps.attempt ?? 0,
|
|
168
|
+
...body,
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
// `fetch` resolves on a 4xx/5xx, so the status is the only place a
|
|
172
|
+
// rejected step surfaces at all.
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
console.warn(`[ctx] step record failed (non-fatal): ${res.status}`)
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
return ((await res.json()) as { data?: { id?: string } }).data?.id ?? ''
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn('[ctx] step record failed (non-fatal):', (err as Error).message)
|
|
180
|
+
return ''
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Close an author-declared step row. Never throws, for the same reason. */
|
|
185
|
+
const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {
|
|
186
|
+
if (!stepId) return
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(
|
|
189
|
+
`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
|
|
190
|
+
{ method: 'POST', headers: runnerHeaders, body: JSON.stringify(body) },
|
|
191
|
+
)
|
|
192
|
+
if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.warn('[ctx] step complete failed (non-fatal):', (err as Error).message)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The message an author should read when a ctx call is refused.
|
|
200
|
+
*
|
|
201
|
+
* The service answers with an envelope (`{error, message, code}`), so the raw
|
|
202
|
+
* body pasted into an error reads `ctx.http → 400 {"error":true,"message":...}`
|
|
203
|
+
* — the useful sentence is in there, wrapped in JSON the author did not ask
|
|
204
|
+
* for and cannot act on. This unwraps it and falls back to the raw body when
|
|
205
|
+
* the response is not one of ours (a proxy 502, say), because an empty message
|
|
206
|
+
* would be worse than a noisy one.
|
|
207
|
+
*/
|
|
208
|
+
const refusal = async (res: Response): Promise<string> => {
|
|
209
|
+
const body = await res.text()
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(body) as { message?: unknown }
|
|
212
|
+
if (typeof parsed.message === 'string' && parsed.message) return parsed.message
|
|
213
|
+
} catch {
|
|
214
|
+
// Not JSON. Fall through to the body.
|
|
215
|
+
}
|
|
216
|
+
return body
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const step = async (kind: string, label: string, fn: () => Promise<unknown>): Promise<unknown> => {
|
|
220
|
+
const t0 = Date.now()
|
|
221
|
+
try {
|
|
222
|
+
const out = await fn()
|
|
223
|
+
await recordStep({ kind, label, status: 'ok', durationMs: Date.now() - t0 })
|
|
224
|
+
return out
|
|
225
|
+
} catch (err) {
|
|
226
|
+
await recordStep({
|
|
227
|
+
kind,
|
|
228
|
+
label,
|
|
229
|
+
status: 'error',
|
|
230
|
+
detail: { message: (err as Error).message },
|
|
231
|
+
durationMs: Date.now() - t0,
|
|
232
|
+
})
|
|
233
|
+
throw err
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every ctx call carries the per-run token, never a workspace credential.
|
|
239
|
+
*
|
|
240
|
+
* The fixed headers go LAST so `init.headers` cannot override them — the run
|
|
241
|
+
* token is the entire authority of this call, and a caller that could replace
|
|
242
|
+
* it could replace the run's scope.
|
|
243
|
+
*/
|
|
244
|
+
const scoped = (path: string, init?: RequestInit) =>
|
|
245
|
+
// `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,
|
|
246
|
+
// which is prefixed, because they authenticate by run token rather than by
|
|
247
|
+
// session. Addressing them as `/automations/...` reaches the session-guarded
|
|
248
|
+
// router instead and 404s. This is only caught end to end: both sides pass
|
|
249
|
+
// their own tests, and the mismatch is between them.
|
|
250
|
+
fetch(`${serviceUrl}/v1/automations/runner${path}`, {
|
|
251
|
+
...init,
|
|
252
|
+
headers: {
|
|
253
|
+
...(init?.headers ?? {}),
|
|
254
|
+
'content-type': 'application/json',
|
|
255
|
+
'x-automation-run-token': deps.runToken,
|
|
256
|
+
'x-workspace-id': deps.workspaceId,
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Names used by this EXECUTION, which is what the uniqueness rule is about.
|
|
262
|
+
*
|
|
263
|
+
* A run that uses steps is executed many times — once more after each step
|
|
264
|
+
* completes — and every execution walks the handler from the top, naming the
|
|
265
|
+
* same steps again. That is not a duplicate. A duplicate is the same name
|
|
266
|
+
* twice within one walk, which is what this set sees, because a fresh context
|
|
267
|
+
* is built per execution.
|
|
268
|
+
*/
|
|
269
|
+
const namesThisExecution = new Set<string>()
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run `fn` as a durable step.
|
|
273
|
+
*
|
|
274
|
+
* The row is written from INSIDE the step body, and that placement is the
|
|
275
|
+
* whole design rather than an implementation detail. Code after
|
|
276
|
+
* `await step.run(...)` does not run in the same execution — the platform
|
|
277
|
+
* checkpoints the step and resumes the handler in a fresh execution — so a
|
|
278
|
+
* report written there would land one execution late, time the memoized
|
|
279
|
+
* return instead of the work, and repeat on every later resumption. A body
|
|
280
|
+
* runs exactly once per real execution of the step, so a report inside it is
|
|
281
|
+
* written exactly once and times what actually happened.
|
|
282
|
+
*
|
|
283
|
+
* Open-then-close rather than one write at the end: ctx calls made inside the
|
|
284
|
+
* body need the parent row to exist before they record, and opening first also
|
|
285
|
+
* puts the step ahead of its own children in `seq`.
|
|
286
|
+
*/
|
|
287
|
+
const runStep = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
288
|
+
if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)
|
|
289
|
+
namesThisExecution.add(name)
|
|
290
|
+
|
|
291
|
+
// The cast is the honest boundary: the platform hands back the JSON round
|
|
292
|
+
// trip of what the body returned, and nothing here can verify the author's
|
|
293
|
+
// `T` survived it. Rule 2 on `StepApi` is that contract, stated where the
|
|
294
|
+
// author reads it.
|
|
295
|
+
return (await deps.step.run(name, async () => {
|
|
296
|
+
const stepId = await recordStep({
|
|
297
|
+
kind: 'step',
|
|
298
|
+
label: name,
|
|
299
|
+
stepName: name,
|
|
300
|
+
status: 'running',
|
|
301
|
+
})
|
|
302
|
+
const t0 = Date.now()
|
|
303
|
+
try {
|
|
304
|
+
const out = await stepScope.run({ stepId }, fn)
|
|
305
|
+
await completeStep(stepId, { status: 'ok', durationMs: Date.now() - t0 })
|
|
306
|
+
return out
|
|
307
|
+
} catch (err) {
|
|
308
|
+
await completeStep(stepId, {
|
|
309
|
+
status: 'error',
|
|
310
|
+
durationMs: Date.now() - t0,
|
|
311
|
+
detail: { message: (err as Error).message },
|
|
312
|
+
})
|
|
313
|
+
throw err
|
|
314
|
+
}
|
|
315
|
+
})) as T
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
runId: deps.runId,
|
|
320
|
+
workspaceId: deps.workspaceId,
|
|
321
|
+
|
|
322
|
+
step: { run: runStep },
|
|
323
|
+
|
|
324
|
+
async log(message, data) {
|
|
325
|
+
// Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a
|
|
326
|
+
// blip reaching the service must not take down an otherwise-healthy run —
|
|
327
|
+
// the signature promises callers it never rejects.
|
|
328
|
+
await recordStep({ kind: 'log', label: message, detail: data ?? {} })
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
agent(slug: string) {
|
|
332
|
+
return {
|
|
333
|
+
run: (prompt: string) =>
|
|
334
|
+
step('agent', `agent:${slug}`, async () => {
|
|
335
|
+
requireGrant(`agent:${slug}:run`)
|
|
336
|
+
const res = await scoped('/ctx/agent-run', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
body: JSON.stringify({ slug, prompt }),
|
|
339
|
+
})
|
|
340
|
+
if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
|
|
341
|
+
return ((await res.json()) as { data: { text: string } }).data
|
|
342
|
+
}) as Promise<{ text: string }>,
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
http: {
|
|
347
|
+
fetch: (req: HttpRequest) =>
|
|
348
|
+
step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {
|
|
349
|
+
// Pre-flight the HOST grant so an author sees it named locally, in the
|
|
350
|
+
// same wording the service uses. The SECRET grant is deliberately not
|
|
351
|
+
// pre-flighted: the service derives it, and duplicating that derivation
|
|
352
|
+
// here would be a second place to get it wrong.
|
|
353
|
+
let host: string
|
|
354
|
+
try {
|
|
355
|
+
host = new URL(req.url).hostname.toLowerCase()
|
|
356
|
+
} catch {
|
|
357
|
+
throw new Error(`ctx.http: invalid URL ${req.url}`)
|
|
358
|
+
}
|
|
359
|
+
requireGrant(`http:${host}`)
|
|
360
|
+
const res = await scoped('/ctx/http', {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
body: JSON.stringify(req),
|
|
363
|
+
})
|
|
364
|
+
if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)
|
|
365
|
+
return ((await res.json()) as { data: HttpResponse }).data
|
|
366
|
+
}) as Promise<HttpResponse>,
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
blueprint: {
|
|
370
|
+
query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>
|
|
371
|
+
step('blueprint', `query:${objectType}`, async () => {
|
|
372
|
+
requireGrant('blueprint:read')
|
|
373
|
+
// Spread rather than forwarded field-by-field so adding an option to
|
|
374
|
+
// `BlueprintQueryOptions` does not silently drop it here — the
|
|
375
|
+
// service validates the body, so an unknown key is refused there
|
|
376
|
+
// rather than ignored in transit.
|
|
377
|
+
const res = await scoped('/ctx/blueprint-query', {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
body: JSON.stringify({ objectType, ...(options ?? {}) }),
|
|
380
|
+
})
|
|
381
|
+
if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)
|
|
382
|
+
// `T` is an author-supplied shape for rows the warehouse returns
|
|
383
|
+
// untyped. The cast is the honest boundary: nothing here can verify
|
|
384
|
+
// it, and pretending otherwise would just move the lie deeper.
|
|
385
|
+
return ((await res.json()) as { data: BlueprintQueryResult<T> }).data
|
|
386
|
+
}) as Promise<BlueprintQueryResult<T>>,
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
}
|