@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/automation",
3
- "version": "1.43.6",
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": "0.1.0",
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
+ }