@frontera-sdk/cli 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 +3 -3
- package/src/api/automation-api.ts +86 -6
- package/src/api/blueprint-authoring-api.ts +38 -0
- package/src/automation-template.ts +13 -3
- package/src/blueprint/compile.ts +168 -2
- package/src/blueprint/diff.ts +132 -2
- package/src/blueprint/model.ts +48 -13
- package/src/blueprint/projection.ts +217 -21
- package/src/blueprint/render.ts +24 -1
- package/src/blueprint/scaffold.ts +149 -13
- package/src/commands/automation/build-entry.ts +73 -0
- package/src/commands/automation/dev.ts +453 -0
- package/src/commands/automation/index-commands.ts +5 -55
- package/src/commands/automation/run.ts +127 -10
- package/src/commands/blueprint/declarative.ts +480 -37
- package/src/flag-help.ts +9 -0
- package/src/vendor/sdk-sources.json +10 -9
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { UsageError } from '../../errors'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Building an automation entry file, extracted from `index-commands`.
|
|
9
|
+
*
|
|
10
|
+
* Its own module because two verbs need it — `deploy` and `dev` — and
|
|
11
|
+
* `index-commands` imports `dev` to register the command. Left where it was,
|
|
12
|
+
* that is a require cycle whose resolution order is Bun-version-dependent: the
|
|
13
|
+
* command list evaluated before `automationDev` was initialised and threw
|
|
14
|
+
* `Cannot access 'automationDev' before initialization`. The same class of bug
|
|
15
|
+
* `binary-smoke.test.ts` exists to catch, and the same reason
|
|
16
|
+
* `function-builder.ts` takes its executor as a parameter.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the entry file, then read the manifest off the BUILT module.
|
|
21
|
+
*
|
|
22
|
+
* The artifact is the thing the runner will execute, so it is the thing worth
|
|
23
|
+
* inspecting: a manifest read from the source could differ from the one that
|
|
24
|
+
* ships. The cost is that importing it evaluates the module here, so anything
|
|
25
|
+
* an author does at import time runs on this machine — calling `automation()`
|
|
26
|
+
* and nothing else is the contract.
|
|
27
|
+
*/
|
|
28
|
+
export async function buildAndExtract(file: string): Promise<{
|
|
29
|
+
manifest: Record<string, unknown>
|
|
30
|
+
code: string
|
|
31
|
+
}> {
|
|
32
|
+
const entry = resolve(file)
|
|
33
|
+
if (!existsSync(entry)) {
|
|
34
|
+
throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `throw: false`. The default raises an AggregateError whose message is the
|
|
38
|
+
// bare string "Bundle failed" — no file, no reason — and it escapes as an
|
|
39
|
+
// INTERNAL_ERROR, telling the caller to RETRY a build that will never
|
|
40
|
+
// succeed. A build error is something the caller fixes, so it exits 2.
|
|
41
|
+
const built = await Bun.build({
|
|
42
|
+
entrypoints: [entry],
|
|
43
|
+
target: 'bun',
|
|
44
|
+
minify: false,
|
|
45
|
+
throw: false,
|
|
46
|
+
})
|
|
47
|
+
if (!built.success) {
|
|
48
|
+
throw new UsageError(
|
|
49
|
+
`could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
|
|
50
|
+
'fix the errors above, then run the command again',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const code = await built.outputs[0]!.text()
|
|
55
|
+
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
|
|
57
|
+
try {
|
|
58
|
+
const bundle = join(dir, 'bundle.js')
|
|
59
|
+
await Bun.write(bundle, code)
|
|
60
|
+
const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
|
|
61
|
+
const manifest = mod.default?.manifest
|
|
62
|
+
|
|
63
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
64
|
+
throw new UsageError(
|
|
65
|
+
`${file} has no automation as its default export`,
|
|
66
|
+
'export the result of `automation({ … }, handler)` as the default export',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return { manifest: manifest as Record<string, unknown>, code }
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { mkdtempSync, statSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { buildContext } from '@frontera-sdk/automation/runtime'
|
|
6
|
+
import { AutomationApi } from '../../api/automation-api'
|
|
7
|
+
import { CliError, UsageError } from '../../errors'
|
|
8
|
+
import { type Command, type CommandContext } from '../types'
|
|
9
|
+
import { validateManifest } from '@frontera-sdk/automation'
|
|
10
|
+
import { buildAndExtract } from './build-entry'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `frontera automation dev` — run the handler in THIS file, from this machine,
|
|
14
|
+
* triggered from the Console.
|
|
15
|
+
*
|
|
16
|
+
* The loop it replaces is a deploy: build, upload, mint a version, bump the
|
|
17
|
+
* registry, wait for the broker. Fifteen seconds and an immutable row, for
|
|
18
|
+
* questions as small as "is this field a number or a string". Measured on
|
|
19
|
+
* 2026-08-10: five versions to get one automation working, four of them
|
|
20
|
+
* discarded, none of them logic bugs.
|
|
21
|
+
*
|
|
22
|
+
* Resumption IS reproduced — see `localStepTools`. The handler is re-entered
|
|
23
|
+
* after every step, exactly as production does, so code outside a step repeats
|
|
24
|
+
* here the way it repeats live.
|
|
25
|
+
*
|
|
26
|
+
* What this still does NOT test, and why one real deploy before promoting
|
|
27
|
+
* remains worth doing: the bundle (this imports the module directly), and broker
|
|
28
|
+
* registration (nothing here reaches Inngest). And every ctx call is DRY, so the
|
|
29
|
+
* data a handler branches on is empty rather than real.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** How often to ask for work. Fast enough that pressing Run feels immediate,
|
|
33
|
+
* slow enough that an idle session is not a busy loop against the service. */
|
|
34
|
+
const POLL_MS = 1_000
|
|
35
|
+
|
|
36
|
+
/** Lease renewal rides on the same poll, so there is no second timer to get out
|
|
37
|
+
* of step with it. */
|
|
38
|
+
export interface DevPollResult {
|
|
39
|
+
held: boolean
|
|
40
|
+
run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Thrown to end an execution the moment a new step completes.
|
|
45
|
+
*
|
|
46
|
+
* Not an error the author can see or catch meaningfully — `runHandlerToCompletion`
|
|
47
|
+
* is the only thing that ever observes it. A class rather than a sentinel value
|
|
48
|
+
* so a handler wrapping its body in `try/catch` and swallowing everything is
|
|
49
|
+
* caught by the driver as the determinism bug it is, rather than silently
|
|
50
|
+
* finishing an execution that was supposed to yield.
|
|
51
|
+
*/
|
|
52
|
+
class StepYield extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super('step yielded')
|
|
55
|
+
this.name = 'StepYield'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A backstop, not a policy.
|
|
61
|
+
*
|
|
62
|
+
* Every yield memoizes exactly one step, so a handler with N steps settles in
|
|
63
|
+
* N+1 executions and this is never reached — the case it looks like it guards
|
|
64
|
+
* against, a step name built from a counter, is caught by the determinism check
|
|
65
|
+
* instead, which says something far more useful. It stays because the driver is
|
|
66
|
+
* an unbounded loop around author code, and an unbounded loop wants a ceiling
|
|
67
|
+
* even when the reasoning says it cannot spin.
|
|
68
|
+
*/
|
|
69
|
+
const MAX_EXECUTIONS = 1_000
|
|
70
|
+
|
|
71
|
+
export interface DevStepState {
|
|
72
|
+
/** Step name → its JSON-round-tripped result, in completion order. */
|
|
73
|
+
memo: Map<string, unknown>
|
|
74
|
+
/** Names in the order the handler produced them, for the determinism check. */
|
|
75
|
+
order: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function newDevStepState(): DevStepState {
|
|
79
|
+
return { memo: new Map(), order: [] }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Step tools that resume, the way the deployed runner's do.
|
|
84
|
+
*
|
|
85
|
+
* They used to just call the body: the dev worker invoked the handler ONCE,
|
|
86
|
+
* straight through, and memoization had nothing to replay. That made dev a
|
|
87
|
+
* different execution model from production, which re-enters the handler after
|
|
88
|
+
* every step (`function-builder.ts` hands Inngest's own `step` down to `ctx`, so
|
|
89
|
+
* the author's `ctx.step.run` calls ARE the run's steps).
|
|
90
|
+
*
|
|
91
|
+
* The difference was not academic. Code OUTSIDE a step runs once per resumption
|
|
92
|
+
* in production and once in total here, so a counter incremented between two
|
|
93
|
+
* steps read `40` on a laptop and `120` live — no error, no warning, and only
|
|
94
|
+
* visible after a promote. Any `ctx` call outside a step cost 1 here and N+1
|
|
95
|
+
* there, so an automation that fitted the budget in dev could exceed it live.
|
|
96
|
+
*
|
|
97
|
+
* So: one new step per execution. A completed step is memoized and returns its
|
|
98
|
+
* stored value without running the body again; a step that has not run yet runs
|
|
99
|
+
* and then YIELDS, ending the execution. `runHandlerToCompletion` calls the
|
|
100
|
+
* handler again, and again, until it returns without yielding.
|
|
101
|
+
*
|
|
102
|
+
* Two things this buys beyond fidelity:
|
|
103
|
+
*
|
|
104
|
+
* - The memo is a JSON round trip, because that is what the platform stores
|
|
105
|
+
* and replays. `Date` comes back a string and `undefined` disappears, here,
|
|
106
|
+
* rather than after a deploy.
|
|
107
|
+
* - Re-execution makes non-determinism VISIBLE. A handler that produces a
|
|
108
|
+
* different step order on the second pass is broken in production in a way
|
|
109
|
+
* that reports nothing; here it names the step that moved.
|
|
110
|
+
*/
|
|
111
|
+
export function localStepTools(
|
|
112
|
+
state: DevStepState = newDevStepState(),
|
|
113
|
+
): { run<T>(id: string, fn: () => Promise<T>): Promise<unknown> } {
|
|
114
|
+
// Reset per EXECUTION, not per run: it counts what this pass has replayed, so
|
|
115
|
+
// the next unmemoized name can be checked against the recorded order.
|
|
116
|
+
let replayed = 0
|
|
117
|
+
return {
|
|
118
|
+
async run<T>(id: string, fn: () => Promise<T>): Promise<unknown> {
|
|
119
|
+
// The determinism check, and the reason re-execution is worth its cost.
|
|
120
|
+
// Production replays memoized results POSITIONALLY; a handler that emits a
|
|
121
|
+
// different order on the second pass gets another step's result handed to
|
|
122
|
+
// it, silently. Named here instead.
|
|
123
|
+
const expected = state.order[replayed]
|
|
124
|
+
if (expected !== undefined && expected !== id) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`this automation is not deterministic: step ${replayed + 1} was `
|
|
127
|
+
+ `"${expected}" on an earlier execution and is "${id}" now. The platform `
|
|
128
|
+
+ 'replays completed steps by position, so the two runs would be handed '
|
|
129
|
+
+ "each other's results. Move anything conditional INSIDE a step.",
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (state.memo.has(id)) {
|
|
134
|
+
replayed += 1
|
|
135
|
+
return state.memo.get(id)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const out = await fn()
|
|
139
|
+
// Through JSON, because that is the boundary the platform puts here — the
|
|
140
|
+
// author gets back what a resumption would give them, not the object the
|
|
141
|
+
// body happened to return.
|
|
142
|
+
//
|
|
143
|
+
// Stringify FIRST and check. `JSON.stringify` returns `undefined` for a
|
|
144
|
+
// function, a symbol, or an object whose `toJSON()` returns undefined —
|
|
145
|
+
// and `JSON.parse(undefined)` then throws `SyntaxError: "undefined" is
|
|
146
|
+
// not valid JSON`, which escaped as the author's own error and failed the
|
|
147
|
+
// run naming neither the step nor the cause. The platform stores an
|
|
148
|
+
// unserialisable value as nothing rather than exploding, so parsing
|
|
149
|
+
// blindly was also a divergence in the one helper built for fidelity.
|
|
150
|
+
let json: string | undefined
|
|
151
|
+
try {
|
|
152
|
+
json = JSON.stringify(out)
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// A `BigInt` or a circular structure cannot be stored by the platform
|
|
155
|
+
// either, so failing is right — anonymously is not. Named, because the
|
|
156
|
+
// author otherwise reads a `TypeError` from a line they did not write.
|
|
157
|
+
throw new Error(
|
|
158
|
+
`step "${id}" returned a value the platform cannot store: ${(err as Error).message}. `
|
|
159
|
+
+ 'Step results make a JSON round trip between resumptions.',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
state.memo.set(id, json === undefined ? undefined : JSON.parse(json))
|
|
163
|
+
state.order.push(id)
|
|
164
|
+
// Ends the execution. Everything after this call in the author's handler
|
|
165
|
+
// belongs to the NEXT one, which is precisely the production behaviour
|
|
166
|
+
// this exists to reproduce.
|
|
167
|
+
throw new StepYield()
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Call the handler until it finishes without yielding.
|
|
174
|
+
*
|
|
175
|
+
* The other half of `localStepTools`. One execution ends at the first step that
|
|
176
|
+
* has not run before; this brings it back, with that step now memoized, and the
|
|
177
|
+
* handler gets one step further. For N steps the handler is entered N+1 times —
|
|
178
|
+
* the same shape as a production run, and the reason code outside a step is now
|
|
179
|
+
* seen to repeat.
|
|
180
|
+
*
|
|
181
|
+
* `buildContext` is called per execution rather than once, because production
|
|
182
|
+
* builds a fresh `ctx` on every resumption too — the duplicate-step-name set
|
|
183
|
+
* inside it is scoped to one execution, and reusing a context would make the
|
|
184
|
+
* second pass reject every name it had already seen.
|
|
185
|
+
*/
|
|
186
|
+
export async function runHandlerToCompletion(
|
|
187
|
+
handler: (ctx: unknown) => Promise<unknown>,
|
|
188
|
+
buildContextFor: (step: ReturnType<typeof localStepTools>) => unknown,
|
|
189
|
+
state: DevStepState = newDevStepState(),
|
|
190
|
+
): Promise<unknown> {
|
|
191
|
+
for (let execution = 0; ; execution += 1) {
|
|
192
|
+
if (execution > MAX_EXECUTIONS) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`this automation did not settle after ${MAX_EXECUTIONS} executions, which `
|
|
195
|
+
+ 'should not be reachable — please report it with the handler that caused it.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return await handler(buildContextFor(localStepTools(state)))
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Anything that is NOT the yield is the author's error, and belongs to
|
|
202
|
+
// them: it fails the run and reaches the Console with its message.
|
|
203
|
+
if (!(err instanceof StepYield)) throw err
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A fresh module path per build.
|
|
210
|
+
*
|
|
211
|
+
* Bun's module registry is keyed by resolved PATH: re-writing the same path with
|
|
212
|
+
* new bytes and importing again returns the FIRST module — stale code, with no
|
|
213
|
+
* error to read. The deployed runner escapes this by accident of design, since
|
|
214
|
+
* every deploy mints a random version id and therefore a new path. A worker
|
|
215
|
+
* rebuilding one slug has no such key and must make one.
|
|
216
|
+
*/
|
|
217
|
+
export function devModulePath(dir: string, counter: number): string {
|
|
218
|
+
return join(dir, `build-${counter}.js`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Close a dev run.
|
|
224
|
+
*
|
|
225
|
+
* A plain `fetch`, not the workspace client: this is the runner protocol, and
|
|
226
|
+
* the only credential the local process holds for it is the RUN token the claim
|
|
227
|
+
* handed over. Routing it through `FronteraClient` would attach the workspace
|
|
228
|
+
* key to a call that must not need one.
|
|
229
|
+
*/
|
|
230
|
+
async function finishDevRun(
|
|
231
|
+
apiUrl: string,
|
|
232
|
+
runId: string,
|
|
233
|
+
runToken: string,
|
|
234
|
+
body: { status: 'succeeded' | 'failed'; result?: unknown; errorMessage?: string },
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
await fetch(`${apiUrl}/v1/automations/runner/runs/${runId}/finish`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'content-type': 'application/json', 'x-automation-run-token': runToken },
|
|
239
|
+
body: JSON.stringify(body),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* How often to rebuild the entry so the manifest sent with each poll is current.
|
|
245
|
+
*
|
|
246
|
+
* Not every poll: `Bun.build` is milliseconds but a second is a lot of them, and
|
|
247
|
+
* an idle session would spend its life rebuilding a file nobody touched. The
|
|
248
|
+
* entry's mtime is the cheap proxy — an edit moves it, and nothing else does.
|
|
249
|
+
*/
|
|
250
|
+
function mtimeOf(path: string): number {
|
|
251
|
+
try {
|
|
252
|
+
return statSync(path).mtimeMs
|
|
253
|
+
} catch {
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const automationDev: Command = {
|
|
259
|
+
meta: {
|
|
260
|
+
noun: 'automation',
|
|
261
|
+
verb: 'dev',
|
|
262
|
+
args: [
|
|
263
|
+
{
|
|
264
|
+
name: 'file',
|
|
265
|
+
required: true,
|
|
266
|
+
description: 'entry file whose default export is an `automation(…)`',
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
flags: {},
|
|
270
|
+
summary: 'Serve this file to dev runs — no deploy, no version',
|
|
271
|
+
examples: ['frontera automation dev src/index.ts'],
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async run(ctx: CommandContext) {
|
|
275
|
+
const file = ctx.positional[0]
|
|
276
|
+
if (!file) {
|
|
277
|
+
throw new UsageError('missing <file>', 'frontera automation dev <file>')
|
|
278
|
+
}
|
|
279
|
+
const entry = resolve(file)
|
|
280
|
+
|
|
281
|
+
// Built once up front for its MANIFEST: the slug is what the session is
|
|
282
|
+
// opened against, and building later would mean claiming a session for a
|
|
283
|
+
// name the file may not carry. The bundle from this build is discarded —
|
|
284
|
+
// every run rebuilds, so an edit made while waiting is picked up.
|
|
285
|
+
let { manifest } = await buildAndExtract(entry)
|
|
286
|
+
const invalid = validateManifest(manifest)
|
|
287
|
+
if (!invalid.valid) {
|
|
288
|
+
throw new UsageError(
|
|
289
|
+
`${file} does not describe a valid automation:\n ${invalid.errors.join('\n ')}`,
|
|
290
|
+
'fix the manifest, then run the command again',
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
let lastMtime = mtimeOf(entry)
|
|
294
|
+
// VALIDATED HERE, once, before a session is opened.
|
|
295
|
+
//
|
|
296
|
+
// The comment this replaces said `validateManifest` had already run inside
|
|
297
|
+
// the build. It had not: `buildAndExtract` only asserts the default export
|
|
298
|
+
// carries a `manifest` object, and `automation()` checks `name` and nothing
|
|
299
|
+
// else. So a file with no trigger, or a six-field cron, built fine, opened a
|
|
300
|
+
// session fine, and then failed EVERY poll — which the worker printed as
|
|
301
|
+
// "poll failed, retrying" until the lease lapsed and the author was told
|
|
302
|
+
// they had been taken over.
|
|
303
|
+
const slug = typeof manifest.name === 'string' ? manifest.name : ''
|
|
304
|
+
if (!slug) {
|
|
305
|
+
throw new UsageError(
|
|
306
|
+
`${file} does not name an automation`,
|
|
307
|
+
'the default export must be `automation({ name: "…" }, handler)`',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const api = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
312
|
+
// Identity of THIS process, minted once and sent with every dev call.
|
|
313
|
+
//
|
|
314
|
+
// The session id cannot serve: take-over is an upsert on the automation, so
|
|
315
|
+
// the row and its id outlive the hand-over and a displaced worker's
|
|
316
|
+
// heartbeat still matches. Nor can the user id, which two terminals belonging
|
|
317
|
+
// to one developer share — the common case, and how this was found.
|
|
318
|
+
const holderId = crypto.randomUUID()
|
|
319
|
+
const session = await api.devSessionOpen(slug, entry, holderId)
|
|
320
|
+
|
|
321
|
+
const out = ctx.output
|
|
322
|
+
out.note(`[dev] ${slug} — running from ${file}`)
|
|
323
|
+
out.note(`[dev] connected to ${ctx.apiUrl}`)
|
|
324
|
+
// The first question an author has is whether this disturbs production, and
|
|
325
|
+
// no verb name can answer it. Stated before anything else happens.
|
|
326
|
+
out.note(
|
|
327
|
+
session.liveVersionId
|
|
328
|
+
? '[dev] the live version keeps running on schedule; this only serves dev runs'
|
|
329
|
+
: '[dev] nothing is deployed yet, so nothing runs on a schedule',
|
|
330
|
+
)
|
|
331
|
+
// Said at START, not only on the run line. An author reads the header once
|
|
332
|
+
// and then watches runs scroll past; a caveat printed per run is noise, and
|
|
333
|
+
// one printed nowhere is a surprise the first time they wonder why a query
|
|
334
|
+
// came back empty.
|
|
335
|
+
out.note('[dev] runs are DRY — ctx.agent, ctx.http and ctx.blueprint return empty and send nothing')
|
|
336
|
+
if (session.created) out.note(`[dev] created the automation "${slug}" (no version yet)`)
|
|
337
|
+
out.note(`[dev] waiting — press Run dev in the Console, or \`frontera automation run ${slug} --dev\``)
|
|
338
|
+
|
|
339
|
+
const workDir = mkdtempSync(join(tmpdir(), 'frontera-dev-'))
|
|
340
|
+
let builds = 0
|
|
341
|
+
let stopping = false
|
|
342
|
+
|
|
343
|
+
// Released explicitly so the Console banner clears at once rather than
|
|
344
|
+
// waiting out the lease. Best-effort by nature — SIGKILL never arrives here,
|
|
345
|
+
// and the lease is what covers that.
|
|
346
|
+
const release = async () => {
|
|
347
|
+
if (stopping) return
|
|
348
|
+
stopping = true
|
|
349
|
+
out.note('\n[dev] releasing the session')
|
|
350
|
+
await api.devSessionClose(slug, session.sessionId, holderId).catch(() => {})
|
|
351
|
+
process.exit(0)
|
|
352
|
+
}
|
|
353
|
+
process.on('SIGINT', () => void release())
|
|
354
|
+
process.on('SIGTERM', () => void release())
|
|
355
|
+
|
|
356
|
+
while (!stopping) {
|
|
357
|
+
// Rebuild only when the file moved. The manifest goes up with every
|
|
358
|
+
// poll so the service can set a claimed run's grants from what the file
|
|
359
|
+
// says NOW — but rebuilding once a second for an idle session would be
|
|
360
|
+
// work nobody asked for.
|
|
361
|
+
const mtime = mtimeOf(entry)
|
|
362
|
+
if (mtime !== lastMtime) {
|
|
363
|
+
try {
|
|
364
|
+
manifest = (await buildAndExtract(entry)).manifest
|
|
365
|
+
lastMtime = mtime
|
|
366
|
+
} catch (err) {
|
|
367
|
+
// A broken file must not end the session — the author is mid-edit.
|
|
368
|
+
// The previous manifest stays in force until the file parses again.
|
|
369
|
+
out.note(`[dev] build failed, keeping the last manifest: ${(err as Error).message}`)
|
|
370
|
+
lastMtime = mtime
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let poll: DevPollResult
|
|
375
|
+
try {
|
|
376
|
+
poll = await api.devPoll(slug, session.sessionId, holderId, manifest)
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// A service blip must not end a session the author still has open.
|
|
379
|
+
out.note(`[dev] poll failed, retrying: ${(err as Error).message}`)
|
|
380
|
+
await new Promise((r) => setTimeout(r, POLL_MS))
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!poll.held) {
|
|
385
|
+
throw new CliError(
|
|
386
|
+
'this dev session is no longer held — another `automation dev` took it over, '
|
|
387
|
+
+ 'or the lease expired',
|
|
388
|
+
{
|
|
389
|
+
code: 'REQUEST_FAILED',
|
|
390
|
+
// Re-opening silently would let two processes ping-pong a session
|
|
391
|
+
// neither can keep, and a run would execute against whichever file
|
|
392
|
+
// won the last exchange.
|
|
393
|
+
hint: 'start it again if you still want to serve this automation',
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!poll.run) {
|
|
399
|
+
await new Promise((r) => setTimeout(r, POLL_MS))
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const run = poll.run
|
|
404
|
+
out.note(`[dev] run ${run.runId.slice(0, 8)} — building`)
|
|
405
|
+
const started = Date.now()
|
|
406
|
+
try {
|
|
407
|
+
// REBUILT per run, not once at startup. `Bun.build` on one automation is
|
|
408
|
+
// milliseconds, and the alternative — edit, press Run, watch the
|
|
409
|
+
// previous code execute — is silent staleness with no error to read.
|
|
410
|
+
const built = await buildAndExtract(entry)
|
|
411
|
+
const path = devModulePath(workDir, ++builds)
|
|
412
|
+
await Bun.write(path, built.code)
|
|
413
|
+
const mod = (await import(path)) as {
|
|
414
|
+
default?: { handler?: (c: unknown) => Promise<unknown> }
|
|
415
|
+
}
|
|
416
|
+
if (typeof mod.default?.handler !== 'function') {
|
|
417
|
+
throw new Error(`${file} has no automation() default export`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Entered once per step, not once per run — see `localStepTools`. A
|
|
421
|
+
// fresh context each time, because production builds one per resumption
|
|
422
|
+
// and its duplicate-name set is scoped to a single execution.
|
|
423
|
+
const result = await runHandlerToCompletion(mod.default.handler, (step) =>
|
|
424
|
+
buildContext({
|
|
425
|
+
runId: run.runId,
|
|
426
|
+
workspaceId: run.workspaceId,
|
|
427
|
+
runToken: run.runToken,
|
|
428
|
+
grants: run.grants,
|
|
429
|
+
step,
|
|
430
|
+
serviceUrl: ctx.apiUrl,
|
|
431
|
+
}),
|
|
432
|
+
)
|
|
433
|
+
await finishDevRun(ctx.apiUrl, run.runId, run.runToken, { status: 'succeeded', result })
|
|
434
|
+
// "succeeded" is the wrong word for a run whose every ctx call returned
|
|
435
|
+
// empty. It finished; whether it works is not something a dry run can
|
|
436
|
+
// say, and the terminal is where an author decides to promote.
|
|
437
|
+
out.note(`[dev] run ${run.runId.slice(0, 8)} finished (dry) in ${Date.now() - started}ms`)
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
440
|
+
// A build error is reported as a FAILED RUN, not only here: the person
|
|
441
|
+
// who pressed Run is looking at the Console, and an error that appears
|
|
442
|
+
// only in a terminal they may not be watching reads as a hang.
|
|
443
|
+
await finishDevRun(ctx.apiUrl, run.runId, run.runToken, {
|
|
444
|
+
status: 'failed',
|
|
445
|
+
errorMessage: message,
|
|
446
|
+
}).catch(() => {})
|
|
447
|
+
out.note(`[dev] run ${run.runId.slice(0, 8)} failed: ${message}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { data: { slug, sessionId: session.sessionId }, text: '' }
|
|
452
|
+
},
|
|
453
|
+
}
|
|
@@ -10,6 +10,10 @@ import { packDirectory } from '../../pack'
|
|
|
10
10
|
import { automationInit } from './init'
|
|
11
11
|
import { automationPull } from './pull'
|
|
12
12
|
import { automationRun, automationRuns } from './run'
|
|
13
|
+
import { automationDev } from './dev'
|
|
14
|
+
import { buildAndExtract } from './build-entry'
|
|
15
|
+
|
|
16
|
+
export { buildAndExtract } from './build-entry'
|
|
13
17
|
import { findAutomationProjectRoot } from './project-root'
|
|
14
18
|
import { formatBytes } from '../../packaging'
|
|
15
19
|
import { table } from '../../table'
|
|
@@ -27,61 +31,6 @@ function requireSlug(ctx: CommandContext): string {
|
|
|
27
31
|
return slug
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
/**
|
|
31
|
-
* Build the entry file, then read the manifest off the BUILT module.
|
|
32
|
-
*
|
|
33
|
-
* The artifact is the thing the runner will execute, so it is the thing worth
|
|
34
|
-
* inspecting: a manifest read from the source could differ from the one that
|
|
35
|
-
* ships. The cost is that importing it evaluates the module here, so anything
|
|
36
|
-
* an author does at import time runs on this machine — calling `automation()`
|
|
37
|
-
* and nothing else is the contract.
|
|
38
|
-
*/
|
|
39
|
-
export async function buildAndExtract(file: string): Promise<{
|
|
40
|
-
manifest: Record<string, unknown>
|
|
41
|
-
code: string
|
|
42
|
-
}> {
|
|
43
|
-
const entry = resolve(file)
|
|
44
|
-
if (!existsSync(entry)) {
|
|
45
|
-
throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// `throw: false`. The default raises an AggregateError whose message is the
|
|
49
|
-
// bare string "Bundle failed" — no file, no reason — and it escapes as an
|
|
50
|
-
// INTERNAL_ERROR, telling the caller to RETRY a build that will never
|
|
51
|
-
// succeed. A build error is something the caller fixes, so it exits 2.
|
|
52
|
-
const built = await Bun.build({
|
|
53
|
-
entrypoints: [entry],
|
|
54
|
-
target: 'bun',
|
|
55
|
-
minify: false,
|
|
56
|
-
throw: false,
|
|
57
|
-
})
|
|
58
|
-
if (!built.success) {
|
|
59
|
-
throw new UsageError(
|
|
60
|
-
`could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
|
|
61
|
-
'fix the errors above, then run the command again',
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const code = await built.outputs[0]!.text()
|
|
66
|
-
|
|
67
|
-
const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
|
|
68
|
-
try {
|
|
69
|
-
const bundle = join(dir, 'bundle.js')
|
|
70
|
-
await Bun.write(bundle, code)
|
|
71
|
-
const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
|
|
72
|
-
const manifest = mod.default?.manifest
|
|
73
|
-
|
|
74
|
-
if (!manifest || typeof manifest !== 'object') {
|
|
75
|
-
throw new UsageError(
|
|
76
|
-
`${file} has no automation as its default export`,
|
|
77
|
-
'export the result of `automation({ … }, handler)` as the default export',
|
|
78
|
-
)
|
|
79
|
-
}
|
|
80
|
-
return { manifest: manifest as Record<string, unknown>, code }
|
|
81
|
-
} finally {
|
|
82
|
-
rmSync(dir, { recursive: true, force: true })
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
34
|
|
|
86
35
|
/** How stale a registry poll may be before a deploy is worth warning about.
|
|
87
36
|
* The runner's default interval is 15s, so a minute is several missed polls —
|
|
@@ -363,4 +312,5 @@ export const automationCommands: Command[] = [
|
|
|
363
312
|
automationRuns,
|
|
364
313
|
automationPull,
|
|
365
314
|
automationInit,
|
|
315
|
+
automationDev,
|
|
366
316
|
]
|