@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
|
@@ -0,0 +1,836 @@
|
|
|
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
|
+
submitOutsideStepMessage as submitOutsideStepMessageText,
|
|
24
|
+
lostStepRowMessage as lostStepRowMessageText,
|
|
25
|
+
duplicateSubmissionMessage as duplicateSubmissionMessageText,
|
|
26
|
+
emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
|
|
27
|
+
} from './messages'
|
|
28
|
+
import type {
|
|
29
|
+
ActionSubmission,
|
|
30
|
+
ActionSubmitResult,
|
|
31
|
+
AutomationContext,
|
|
32
|
+
BlueprintQueryOptions,
|
|
33
|
+
BlueprintQueryResult,
|
|
34
|
+
HttpRequest,
|
|
35
|
+
HttpResponse,
|
|
36
|
+
PluginCallResult,
|
|
37
|
+
} from './types'
|
|
38
|
+
|
|
39
|
+
const SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How much of a summary one step row may carry.
|
|
43
|
+
*
|
|
44
|
+
* A step row is telemetry, not storage: it is read by a human scanning a run,
|
|
45
|
+
* and it is written on every ctx call of every run. So what it records about a
|
|
46
|
+
* call is bounded rather than complete, and a value that does not fit is
|
|
47
|
+
* reported as a SIZE — which tells the reader the value existed and was large,
|
|
48
|
+
* instead of showing them the empty panel this cap exists to avoid.
|
|
49
|
+
*/
|
|
50
|
+
const DETAIL_MAX_CHARS = 4_000
|
|
51
|
+
/** How much of an agent's answer a row keeps, so a trace can be read without
|
|
52
|
+
* re-running the agent. */
|
|
53
|
+
const PREVIEW_MAX_CHARS = 500
|
|
54
|
+
|
|
55
|
+
/** JSON length of a value, or null when it does not serialize (a cycle, a BigInt). */
|
|
56
|
+
function jsonSize(value: unknown): number | null {
|
|
57
|
+
try {
|
|
58
|
+
const json = JSON.stringify(value)
|
|
59
|
+
return json === undefined ? null : json.length
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A NUL and an unpaired surrogate, which Postgres `jsonb` REFUSES rather than
|
|
67
|
+
* escapes: `\u0000 cannot be converted to text`, and `Unicode low surrogate
|
|
68
|
+
* must follow a high surrogate`. Both verified against the repo's own Postgres.
|
|
69
|
+
*/
|
|
70
|
+
const NUL = /\u0000/g
|
|
71
|
+
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Make a value storable, by removing the two byte classes `jsonb` rejects.
|
|
75
|
+
*
|
|
76
|
+
* Both are reachable from what a row now carries: `preview` cuts at a fixed
|
|
77
|
+
* offset and can split an emoji in half, and an author's step result may hold
|
|
78
|
+
* text pulled out of a PDF or a Postgres `text` column, where a NUL is routine.
|
|
79
|
+
*
|
|
80
|
+
* What a refusal costs is not the detail. A rejected insert loses the whole
|
|
81
|
+
* ROW — `recordStep` swallows a non-2xx by contract — and a rejected completion
|
|
82
|
+
* leaves an author's step reading `running` forever inside a run that finished,
|
|
83
|
+
* because the update is guarded on that status. Either is a worse trace than
|
|
84
|
+
* the empty panel this file set out to fix, so the scrub sits at the write
|
|
85
|
+
* rather than in each summarizer: one rule covering summaries, error messages
|
|
86
|
+
* and `ctx.log`'s author-supplied data alike.
|
|
87
|
+
*/
|
|
88
|
+
function jsonbSafeText(text: string): string {
|
|
89
|
+
return text.replace(NUL, '').replace(UNPAIRED_SURROGATE, '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function jsonbSafe(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
|
|
93
|
+
if (typeof value === 'string') return jsonbSafeText(value)
|
|
94
|
+
if (value === null || typeof value !== 'object') return value
|
|
95
|
+
// A cycle cannot be serialized at all: dropped here, named, rather than left
|
|
96
|
+
// to throw inside the fetch that was carrying an otherwise-good row.
|
|
97
|
+
if (seen.has(value)) return undefined
|
|
98
|
+
// The ANCESTOR stack, not every node ever visited — unmarked on the way out.
|
|
99
|
+
// A node marked for good cannot tell a cycle from an ordinary shared
|
|
100
|
+
// reference, which `JSON.stringify` handles fine: `{ before: row, after: row }`
|
|
101
|
+
// lost `after`, and `[row, row]` became `[null, null]`. That is silent data
|
|
102
|
+
// loss in the very detail this file exists to make readable.
|
|
103
|
+
seen.add(value)
|
|
104
|
+
const safe = Array.isArray(value)
|
|
105
|
+
? value.map((item) => jsonbSafe(item, seen))
|
|
106
|
+
: Object.fromEntries(
|
|
107
|
+
Object.entries(value).map(([key, item]) => [jsonbSafeText(key), jsonbSafe(item, seen)]),
|
|
108
|
+
)
|
|
109
|
+
seen.delete(value)
|
|
110
|
+
return safe
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `{ [key]: value }`, but only while the value stays small.
|
|
115
|
+
*
|
|
116
|
+
* Per key rather than over the whole detail so one enormous filter cannot cost
|
|
117
|
+
* the counts standing next to it — dropping everything would leave the row
|
|
118
|
+
* exactly as empty as it was before any of this existed.
|
|
119
|
+
*/
|
|
120
|
+
function ifSmall(key: string, value: unknown, max: number): Record<string, unknown> {
|
|
121
|
+
if (value === undefined) return {}
|
|
122
|
+
const size = jsonSize(value)
|
|
123
|
+
return size !== null && size <= max ? { [key]: value } : {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The head of a text answer, marked when cut. */
|
|
127
|
+
function preview(text: string): string {
|
|
128
|
+
return text.length <= PREVIEW_MAX_CHARS ? text : `${text.slice(0, PREVIEW_MAX_CHARS)}\u2026`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a summarizer for a row's `detail`, defending the run from it.
|
|
133
|
+
*
|
|
134
|
+
* Never throws and never grows without bound. A summary is a record OF the
|
|
135
|
+
* work by the same rule `recordStep` follows, so a summarizer that trips over
|
|
136
|
+
* a shape it did not expect — a dry dev run answering null, a service that
|
|
137
|
+
* grew a field — costs the detail, never the call it describes.
|
|
138
|
+
*/
|
|
139
|
+
function summarize<T>(
|
|
140
|
+
build: ((out: T) => Record<string, unknown>) | undefined,
|
|
141
|
+
out: T,
|
|
142
|
+
): Record<string, unknown> | undefined {
|
|
143
|
+
if (!build) return undefined
|
|
144
|
+
let detail: Record<string, unknown>
|
|
145
|
+
try {
|
|
146
|
+
detail = build(out)
|
|
147
|
+
} catch {
|
|
148
|
+
// Named rather than omitted: an omitted detail renders as "This step
|
|
149
|
+
// recorded no detail", which is the sentence this whole path exists to
|
|
150
|
+
// stop — and it would hide a broken summarizer behind a fixed bug's
|
|
151
|
+
// symptom.
|
|
152
|
+
return { omitted: 'summary unavailable' }
|
|
153
|
+
}
|
|
154
|
+
const size = jsonSize(detail)
|
|
155
|
+
if (size === null) return { omitted: 'summary not serializable' }
|
|
156
|
+
return size <= DETAIL_MAX_CHARS ? detail : { omitted: 'summary too large', chars: size }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What an author-declared step records about its own return value.
|
|
161
|
+
*
|
|
162
|
+
* The value is already JSON — the platform stores and replays it — so keeping a
|
|
163
|
+
* copy asks nothing new of it, and it is what makes the row readable at all:
|
|
164
|
+
* without it a step shows a name and a duration for work whose result nobody
|
|
165
|
+
* can see. Bounded, and reported as a size when it does not fit, because a step
|
|
166
|
+
* that returns a thousand warehouse rows must not write them a second time into
|
|
167
|
+
* the audit trail.
|
|
168
|
+
*/
|
|
169
|
+
function stepResultDetail(out: unknown): Record<string, unknown> | undefined {
|
|
170
|
+
if (out === undefined) return undefined
|
|
171
|
+
const size = jsonSize(out)
|
|
172
|
+
if (size === null) return { omitted: 'result not serializable' }
|
|
173
|
+
return size <= DETAIL_MAX_CHARS
|
|
174
|
+
? { result: out }
|
|
175
|
+
: { resultChars: size, omitted: 'result too large' }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The step tools this module needs, declared structurally rather than imported
|
|
180
|
+
* from `inngest`.
|
|
181
|
+
*
|
|
182
|
+
* Structural because it keeps the whole file testable with a two-line stub, and
|
|
183
|
+
* because it states exactly what `ctx` depends on — one method — instead of the
|
|
184
|
+
* platform's entire step surface. `function-builder.ts` passes the real object
|
|
185
|
+
* straight in, so the compiler still checks the two agree.
|
|
186
|
+
*/
|
|
187
|
+
export interface StepTools {
|
|
188
|
+
/**
|
|
189
|
+
* Returns `unknown`, deliberately, and not the body's own type.
|
|
190
|
+
*
|
|
191
|
+
* What comes back is not the value the body returned but its JSON round trip:
|
|
192
|
+
* the platform stores a step's result and replays it on the next execution, so
|
|
193
|
+
* a `Date` returns as a string and a class instance as a plain object. Typing
|
|
194
|
+
* this as `Promise<T>` here would erase that at exactly the boundary where it
|
|
195
|
+
* happens. `ctx.step.run` narrows it once, at the seam, with the same
|
|
196
|
+
* reasoning `ctx.blueprint.query` narrows a warehouse row.
|
|
197
|
+
*/
|
|
198
|
+
run<T>(id: string, fn: () => Promise<T>): Promise<unknown>
|
|
199
|
+
/** Absent on hosts that predate it — the runtime falls back to an inline wait. */
|
|
200
|
+
sleep?(id: string, ms: number): Promise<void>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface Deps {
|
|
204
|
+
runId: string
|
|
205
|
+
workspaceId: string
|
|
206
|
+
runToken: string
|
|
207
|
+
grants: string[]
|
|
208
|
+
/** The platform's step tools for THIS execution. */
|
|
209
|
+
step: StepTools
|
|
210
|
+
/** The run's frozen input row, or absent when the manifest declares none. */
|
|
211
|
+
input?: Record<string, unknown>
|
|
212
|
+
/** Zero-indexed run attempt, stamped onto every row this context writes. */
|
|
213
|
+
attempt?: number
|
|
214
|
+
/**
|
|
215
|
+
* Where the service lives, when the caller knows better than the environment.
|
|
216
|
+
*
|
|
217
|
+
* The deployed runner reads `SERVICE_URL` from its own env; the CLI's dev
|
|
218
|
+
* worker knows it from the origin the developer logged into, and a
|
|
219
|
+
* module-level const read at import time cannot be told. Overriding here keeps
|
|
220
|
+
* this module usable in both processes rather than forked for one.
|
|
221
|
+
*/
|
|
222
|
+
serviceUrl?: string
|
|
223
|
+
/**
|
|
224
|
+
* The deployment-wide runner secret, or absent.
|
|
225
|
+
*
|
|
226
|
+
* PASSED IN, never read from the environment here. This module now runs in two
|
|
227
|
+
* processes, and only one of them may hold this token: the runner does, a
|
|
228
|
+
* developer's laptop must not. Reading `process.env` inside shared code moves
|
|
229
|
+
* that decision into an environment nobody reviews — a developer who has the
|
|
230
|
+
* variable exported for any reason, a copied env file, a locally-run runner,
|
|
231
|
+
* would have `automation dev` sending a workspace-wide credential from their
|
|
232
|
+
* machine with nothing on screen to say so.
|
|
233
|
+
*
|
|
234
|
+
* As a parameter the rule is structural: the dev worker cannot send it,
|
|
235
|
+
* because it has nothing to pass.
|
|
236
|
+
*/
|
|
237
|
+
runnerToken?: string
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export {
|
|
241
|
+
duplicateStepMessage,
|
|
242
|
+
duplicateSubmissionMessage,
|
|
243
|
+
emptySubmissionKeyMessage,
|
|
244
|
+
lostStepRowMessage,
|
|
245
|
+
missingGrantMessage,
|
|
246
|
+
submitOutsideStepMessage,
|
|
247
|
+
} from './messages'
|
|
248
|
+
|
|
249
|
+
export class DuplicateStepNameError extends Error {
|
|
250
|
+
constructor(readonly stepName: string) {
|
|
251
|
+
super(duplicateStepMessageText(stepName))
|
|
252
|
+
this.name = 'DuplicateStepNameError'
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
class GrantError extends Error {
|
|
257
|
+
constructor(grant: string) {
|
|
258
|
+
super(missingGrantMessageText(grant))
|
|
259
|
+
this.name = 'GrantError'
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function buildContext(deps: Deps): AutomationContext {
|
|
264
|
+
const serviceUrl = deps.serviceUrl ?? SERVICE_URL
|
|
265
|
+
const requireGrant = (grant: string) => {
|
|
266
|
+
if (!deps.grants.includes(grant)) throw new GrantError(grant)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Step and finish writes carry BOTH credentials, and the service takes either.
|
|
271
|
+
*
|
|
272
|
+
* The deployed runner has the shared secret; a dev worker on a developer's
|
|
273
|
+
* laptop must never hold it, and has only the run's own token — which is the
|
|
274
|
+
* stronger claim for a row that belongs to one run. Sending both means this
|
|
275
|
+
* module works unchanged in either process, which is the whole reason it can
|
|
276
|
+
* be reused by the CLI rather than forked.
|
|
277
|
+
*
|
|
278
|
+
* An empty runner token is omitted rather than sent blank: the service treats
|
|
279
|
+
* a PRESENT runner header as an assertion to verify, so a blank one would be
|
|
280
|
+
* a 401 instead of a fall-through to the run token.
|
|
281
|
+
*/
|
|
282
|
+
const runnerHeaders: Record<string, string> = {
|
|
283
|
+
'content-type': 'application/json',
|
|
284
|
+
'x-automation-run-token': deps.runToken,
|
|
285
|
+
...(deps.runnerToken ? { 'x-automation-runner-token': deps.runnerToken } : {}),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Which author-declared step the code writing a row is running inside.
|
|
290
|
+
*
|
|
291
|
+
* Async-local rather than a plain variable because two steps can be in flight
|
|
292
|
+
* at once — `Promise.all([ctx.step.run('a', …), ctx.step.run('b', …)])` is
|
|
293
|
+
* legal, and a shared mutable "current step" would file `a`'s ctx calls under
|
|
294
|
+
* `b` depending on interleaving. This is per-run, not module-global: two runs
|
|
295
|
+
* in one process must never see each other's scope.
|
|
296
|
+
*/
|
|
297
|
+
const stepScope = new AsyncLocalStorage<{
|
|
298
|
+
stepId: string
|
|
299
|
+
stepName: string
|
|
300
|
+
/**
|
|
301
|
+
* `action` + `submissionKey` for every submit this step body has made.
|
|
302
|
+
*
|
|
303
|
+
* The write plane CANNOT catch a repeat: two identical submissions derive
|
|
304
|
+
* one key and one semantic fingerprint, so it replays the first request and
|
|
305
|
+
* answers both calls with the same id — no error, one effect, a green run.
|
|
306
|
+
* The check has to be local, and per step EXECUTION so that a genuine
|
|
307
|
+
* resumption or retry (which re-enters the body from scratch) is unaffected.
|
|
308
|
+
*/
|
|
309
|
+
submitted: Set<string>
|
|
310
|
+
}>()
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Append a row to the run's audit trail, returning the id the service gave it.
|
|
314
|
+
*
|
|
315
|
+
* Never throws. A step row is a record OF the work, not part of it — so a
|
|
316
|
+
* service blip while recording must not turn a completed operation into a
|
|
317
|
+
* failed run, and must not replace an in-flight failure with a transport
|
|
318
|
+
* error on the way to reporting it. The same reasoning is why a failure
|
|
319
|
+
* returns an empty id rather than propagating: losing the parent link on one
|
|
320
|
+
* row is strictly better than losing the run.
|
|
321
|
+
*/
|
|
322
|
+
const recordStep = async (body: Record<string, unknown>): Promise<string> => {
|
|
323
|
+
const parentStepId = stepScope.getStore()?.stepId
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: runnerHeaders,
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
// Only when there IS a parent. A step whose own row failed to write
|
|
330
|
+
// leaves an empty id in scope, and sending that empty string reaches
|
|
331
|
+
// Postgres as `''::uuid`, which errors — so the child row would be
|
|
332
|
+
// dropped too, quietly, because this whole path is non-fatal. One
|
|
333
|
+
// lost step row must not cost the calls made inside it.
|
|
334
|
+
...(parentStepId ? { parentStepId } : {}),
|
|
335
|
+
attempt: deps.attempt ?? 0,
|
|
336
|
+
...body,
|
|
337
|
+
// Last, so it applies to whatever `body` brought — see `jsonbSafe`.
|
|
338
|
+
...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
|
|
339
|
+
// `label` and `step_name` are `text`, and a NUL is refused there too —
|
|
340
|
+
// `invalid byte sequence for encoding "UTF8": 0x00`. `ctx.log` writes
|
|
341
|
+
// the author's message as the label, so a NUL riding in from a PDF
|
|
342
|
+
// kills the row through the field NEXT to the one being scrubbed. A
|
|
343
|
+
// lone surrogate is not a hazard in a text column (the driver encodes
|
|
344
|
+
// it as U+FFFD), but it is scrubbed with it rather than reasoned about
|
|
345
|
+
// twice.
|
|
346
|
+
...(typeof body.label === 'string' ? { label: jsonbSafeText(body.label) } : {}),
|
|
347
|
+
...(typeof body.stepName === 'string'
|
|
348
|
+
? { stepName: jsonbSafeText(body.stepName) }
|
|
349
|
+
: {}),
|
|
350
|
+
}),
|
|
351
|
+
})
|
|
352
|
+
// `fetch` resolves on a 4xx/5xx, so the status is the only place a
|
|
353
|
+
// rejected step surfaces at all.
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
console.warn(`[ctx] step record failed (non-fatal): ${res.status}`)
|
|
356
|
+
return ''
|
|
357
|
+
}
|
|
358
|
+
return ((await res.json()) as { data?: { id?: string } }).data?.id ?? ''
|
|
359
|
+
} catch (err) {
|
|
360
|
+
console.warn('[ctx] step record failed (non-fatal):', (err as Error).message)
|
|
361
|
+
return ''
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Close an author-declared step row. Never throws, for the same reason. */
|
|
366
|
+
const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {
|
|
367
|
+
if (!stepId) return
|
|
368
|
+
// A row refused here is not closed at all — the service guards the update on
|
|
369
|
+
// `status = 'running'` — so the step would read `running` forever.
|
|
370
|
+
const payload = {
|
|
371
|
+
...body,
|
|
372
|
+
...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch(
|
|
376
|
+
`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
|
|
377
|
+
{ method: 'POST', headers: runnerHeaders, body: JSON.stringify(payload) },
|
|
378
|
+
)
|
|
379
|
+
if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.warn('[ctx] step complete failed (non-fatal):', (err as Error).message)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The message an author should read when a ctx call is refused.
|
|
387
|
+
*
|
|
388
|
+
* The service answers with an envelope (`{error, message, code}`), so the raw
|
|
389
|
+
* body pasted into an error reads `ctx.http → 400 {"error":true,"message":...}`
|
|
390
|
+
* — the useful sentence is in there, wrapped in JSON the author did not ask
|
|
391
|
+
* for and cannot act on. This unwraps it and falls back to the raw body when
|
|
392
|
+
* the response is not one of ours (a proxy 502, say), because an empty message
|
|
393
|
+
* would be worse than a noisy one.
|
|
394
|
+
*/
|
|
395
|
+
const refusal = async (res: Response): Promise<string> => {
|
|
396
|
+
const body = await res.text()
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(body) as { message?: unknown }
|
|
399
|
+
if (typeof parsed.message === 'string' && parsed.message) return parsed.message
|
|
400
|
+
} catch {
|
|
401
|
+
// Not JSON. Fall through to the body.
|
|
402
|
+
}
|
|
403
|
+
return body
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Record one ctx call as a row: timed, and with a bounded summary of what it
|
|
408
|
+
* did.
|
|
409
|
+
*
|
|
410
|
+
* The summary is per capability rather than generic. A reader opening
|
|
411
|
+
* `query:LoanApplication` wants the row count and the filter; a reader opening
|
|
412
|
+
* `agent:triage` wants what the agent said — a generic dump of the return
|
|
413
|
+
* value would be both larger and less useful than either. A capability whose
|
|
414
|
+
* result carries a credential (a file's signed URL) summarizes AROUND it:
|
|
415
|
+
* details are rendered verbatim in the Console.
|
|
416
|
+
*/
|
|
417
|
+
const step = async <T>(
|
|
418
|
+
kind: string,
|
|
419
|
+
label: string,
|
|
420
|
+
fn: () => Promise<T>,
|
|
421
|
+
detailOf?: (out: T) => Record<string, unknown>,
|
|
422
|
+
): Promise<T> => {
|
|
423
|
+
const t0 = Date.now()
|
|
424
|
+
try {
|
|
425
|
+
const out = await fn()
|
|
426
|
+
await recordStep({
|
|
427
|
+
kind,
|
|
428
|
+
label,
|
|
429
|
+
status: 'ok',
|
|
430
|
+
durationMs: Date.now() - t0,
|
|
431
|
+
// Omitted, not `{}`: an absent field leaves the service's own default in
|
|
432
|
+
// place rather than writing an empty object the panel would have to
|
|
433
|
+
// treat as detail.
|
|
434
|
+
detail: summarize(detailOf, out),
|
|
435
|
+
})
|
|
436
|
+
return out
|
|
437
|
+
} catch (err) {
|
|
438
|
+
await recordStep({
|
|
439
|
+
kind,
|
|
440
|
+
label,
|
|
441
|
+
status: 'error',
|
|
442
|
+
detail: { message: (err as Error).message },
|
|
443
|
+
durationMs: Date.now() - t0,
|
|
444
|
+
})
|
|
445
|
+
throw err
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Every ctx call carries the per-run token, never a workspace credential.
|
|
451
|
+
*
|
|
452
|
+
* The fixed headers go LAST so `init.headers` cannot override them — the run
|
|
453
|
+
* token is the entire authority of this call, and a caller that could replace
|
|
454
|
+
* it could replace the run's scope.
|
|
455
|
+
*/
|
|
456
|
+
const scoped = (path: string, init?: RequestInit) =>
|
|
457
|
+
// `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,
|
|
458
|
+
// which is prefixed, because they authenticate by run token rather than by
|
|
459
|
+
// session. Addressing them as `/automations/...` reaches the session-guarded
|
|
460
|
+
// router instead and 404s. This is only caught end to end: both sides pass
|
|
461
|
+
// their own tests, and the mismatch is between them.
|
|
462
|
+
fetch(`${serviceUrl}/v1/automations/runner${path}`, {
|
|
463
|
+
...init,
|
|
464
|
+
headers: {
|
|
465
|
+
...(init?.headers ?? {}),
|
|
466
|
+
'content-type': 'application/json',
|
|
467
|
+
'x-automation-run-token': deps.runToken,
|
|
468
|
+
'x-workspace-id': deps.workspaceId,
|
|
469
|
+
},
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Names used by this EXECUTION, which is what the uniqueness rule is about.
|
|
474
|
+
*
|
|
475
|
+
* A run that uses steps is executed many times — once more after each step
|
|
476
|
+
* completes — and every execution walks the handler from the top, naming the
|
|
477
|
+
* same steps again. That is not a duplicate. A duplicate is the same name
|
|
478
|
+
* twice within one walk, which is what this set sees, because a fresh context
|
|
479
|
+
* is built per execution.
|
|
480
|
+
*/
|
|
481
|
+
const namesThisExecution = new Set<string>()
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Run `fn` as a durable step.
|
|
485
|
+
*
|
|
486
|
+
* The row is written from INSIDE the step body, and that placement is the
|
|
487
|
+
* whole design rather than an implementation detail. Code after
|
|
488
|
+
* `await step.run(...)` does not run in the same execution — the platform
|
|
489
|
+
* checkpoints the step and resumes the handler in a fresh execution — so a
|
|
490
|
+
* report written there would land one execution late, time the memoized
|
|
491
|
+
* return instead of the work, and repeat on every later resumption. A body
|
|
492
|
+
* runs exactly once per real execution of the step, so a report inside it is
|
|
493
|
+
* written exactly once and times what actually happened.
|
|
494
|
+
*
|
|
495
|
+
* Open-then-close rather than one write at the end: ctx calls made inside the
|
|
496
|
+
* body need the parent row to exist before they record, and opening first also
|
|
497
|
+
* puts the step ahead of its own children in `seq`.
|
|
498
|
+
*/
|
|
499
|
+
const runStep = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
500
|
+
if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)
|
|
501
|
+
namesThisExecution.add(name)
|
|
502
|
+
|
|
503
|
+
// The cast is the honest boundary: the platform hands back the JSON round
|
|
504
|
+
// trip of what the body returned, and nothing here can verify the author's
|
|
505
|
+
// `T` survived it. Rule 2 on `StepApi` is that contract, stated where the
|
|
506
|
+
// author reads it.
|
|
507
|
+
return (await deps.step.run(name, async () => {
|
|
508
|
+
const stepId = await recordStep({
|
|
509
|
+
kind: 'step',
|
|
510
|
+
label: name,
|
|
511
|
+
stepName: name,
|
|
512
|
+
status: 'running',
|
|
513
|
+
})
|
|
514
|
+
const t0 = Date.now()
|
|
515
|
+
try {
|
|
516
|
+
// `stepName` rides alongside `stepId` because `ctx.action.submit` needs
|
|
517
|
+
// the NAME, not the row id: the id is fresh on every execution, and an
|
|
518
|
+
// idempotency key derived from it would differ on each resumption —
|
|
519
|
+
// which is the exact duplicate-submission this scope exists to prevent.
|
|
520
|
+
// The service reads the name off the row rather than trusting this
|
|
521
|
+
// copy; it travels here only so a refusal can name it.
|
|
522
|
+
const out = await stepScope.run(
|
|
523
|
+
{ stepId, stepName: name, submitted: new Set<string>() },
|
|
524
|
+
fn,
|
|
525
|
+
)
|
|
526
|
+
await completeStep(stepId, {
|
|
527
|
+
status: 'ok',
|
|
528
|
+
durationMs: Date.now() - t0,
|
|
529
|
+
detail: stepResultDetail(out),
|
|
530
|
+
})
|
|
531
|
+
return out
|
|
532
|
+
} catch (err) {
|
|
533
|
+
await completeStep(stepId, {
|
|
534
|
+
status: 'error',
|
|
535
|
+
durationMs: Date.now() - t0,
|
|
536
|
+
detail: { message: (err as Error).message },
|
|
537
|
+
})
|
|
538
|
+
throw err
|
|
539
|
+
}
|
|
540
|
+
})) as T
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Durable pause. No step row is written: a row recorded after a memoized
|
|
545
|
+
* sleep would be re-recorded by every later execution (the code after an
|
|
546
|
+
* awaited memoized step re-runs per resumption), and unlike `runStep`
|
|
547
|
+
* there is no body to write it from exactly once.
|
|
548
|
+
*/
|
|
549
|
+
const sleepStep = async (name: string, ms: number): Promise<void> => {
|
|
550
|
+
if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)
|
|
551
|
+
namesThisExecution.add(name)
|
|
552
|
+
if (deps.step.sleep) {
|
|
553
|
+
await deps.step.sleep(name, ms)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
// Host without a sleep arm (an old dev worker): wait inline. Correct,
|
|
557
|
+
// just not durable — acceptable for the host that cannot resume anyway.
|
|
558
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
runId: deps.runId,
|
|
563
|
+
workspaceId: deps.workspaceId,
|
|
564
|
+
// The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.
|
|
565
|
+
input: deps.input ?? {},
|
|
566
|
+
|
|
567
|
+
step: { run: runStep, sleep: sleepStep },
|
|
568
|
+
|
|
569
|
+
async log(message, data) {
|
|
570
|
+
// Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a
|
|
571
|
+
// blip reaching the service must not take down an otherwise-healthy run —
|
|
572
|
+
// the signature promises callers it never rejects.
|
|
573
|
+
await recordStep({ kind: 'log', label: message, detail: data ?? {} })
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
agent(slug: string) {
|
|
577
|
+
return {
|
|
578
|
+
run: (prompt: string, options?: { files?: Array<{ fileId: string }> }) =>
|
|
579
|
+
step('agent', `agent:${slug}`, async () => {
|
|
580
|
+
requireGrant(`agent:${slug}:run`)
|
|
581
|
+
// `files` hands the agent already-uploaded files by canonical id —
|
|
582
|
+
// the same `{ fileId }` a `file`-typed run input carries, so an
|
|
583
|
+
// input can be forwarded as `ctx.agent(s).run(p, { files:
|
|
584
|
+
// [ctx.input.doc] })`. The service authorizes each id against this
|
|
585
|
+
// run's workspace and stages the bytes onto the agent's computer;
|
|
586
|
+
// the agent is told the staged paths in its prompt.
|
|
587
|
+
const res = await scoped('/ctx/agent-run', {
|
|
588
|
+
method: 'POST',
|
|
589
|
+
body: JSON.stringify({
|
|
590
|
+
slug,
|
|
591
|
+
prompt,
|
|
592
|
+
...(options?.files?.length ? { files: options.files.map((f) => ({ fileId: f.fileId })) } : {}),
|
|
593
|
+
}),
|
|
594
|
+
})
|
|
595
|
+
if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
|
|
596
|
+
return ((await res.json()) as { data: { text: string } }).data
|
|
597
|
+
},
|
|
598
|
+
// The answer itself, capped. A run whose agent step is the expensive
|
|
599
|
+
// one is read to find out WHAT the agent said, and a length alone
|
|
600
|
+
// sends the reader back to re-run the automation to learn it.
|
|
601
|
+
(out) => ({
|
|
602
|
+
slug,
|
|
603
|
+
promptChars: prompt.length,
|
|
604
|
+
...(options?.files?.length ? { files: options.files.length } : {}),
|
|
605
|
+
...(typeof out?.text === 'string'
|
|
606
|
+
? { textChars: out.text.length, preview: preview(out.text) }
|
|
607
|
+
: {}),
|
|
608
|
+
}),
|
|
609
|
+
) as Promise<{ text: string }>,
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
file: (ref: { fileId: string }) =>
|
|
614
|
+
step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {
|
|
615
|
+
// No grant: this only reads a file the run was GIVEN — the route
|
|
616
|
+
// refuses any fileId that is not among this run's own inputs. Returns
|
|
617
|
+
// a short-lived signed URL plus the authoritative mime/size/name; in a
|
|
618
|
+
// dry dev run it returns null, like every other ctx call.
|
|
619
|
+
const res = await scoped('/ctx/file-resolve', {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
body: JSON.stringify({ fileId: ref.fileId }),
|
|
622
|
+
})
|
|
623
|
+
if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
|
|
624
|
+
return ((await res.json()) as {
|
|
625
|
+
data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
|
|
626
|
+
}).data.file
|
|
627
|
+
},
|
|
628
|
+
// Everything the handle carries EXCEPT `signedUrl`. That URL is a
|
|
629
|
+
// short-lived credential, and a row is exactly the wrong place for one:
|
|
630
|
+
// details are rendered verbatim and kept for as long as the run is.
|
|
631
|
+
(out) =>
|
|
632
|
+
out
|
|
633
|
+
? { fileId: out.fileId, mimeType: out.mimeType, sizeBytes: out.sizeBytes, name: out.name }
|
|
634
|
+
: { resolved: false },
|
|
635
|
+
) as Promise<import('./types').ResolvedFileHandle | null>,
|
|
636
|
+
|
|
637
|
+
plugin(install: string) {
|
|
638
|
+
return {
|
|
639
|
+
call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>
|
|
640
|
+
step('plugin', `plugin:${install}:${capability}`, async () => {
|
|
641
|
+
// Pre-flighted locally so an author reads the grant by name, in the
|
|
642
|
+
// same words the service uses. The service checks it again — this
|
|
643
|
+
// copy exists for the message, not for the authority.
|
|
644
|
+
requireGrant(`plugin:${install}:${capability}`)
|
|
645
|
+
const res = await scoped('/ctx/plugin-call', {
|
|
646
|
+
method: 'POST',
|
|
647
|
+
body: JSON.stringify({ install, capability, input: input ?? {} }),
|
|
648
|
+
})
|
|
649
|
+
if (!res.ok) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`ctx.plugin("${install}").call("${capability}") → ${res.status} ${await refusal(res)}`,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
// Two levels: the service envelope's data, then PluginCallResult's own data.
|
|
655
|
+
return ((await res.json()) as { data: PluginCallResult<T> }).data
|
|
656
|
+
},
|
|
657
|
+
(out) => {
|
|
658
|
+
const chars = jsonSize(out?.data)
|
|
659
|
+
return {
|
|
660
|
+
install,
|
|
661
|
+
capability,
|
|
662
|
+
...(chars === null ? {} : { resultChars: chars }),
|
|
663
|
+
// The plugin's own shape, while it is small enough to read at a
|
|
664
|
+
// glance. The size above stands for it when it is not.
|
|
665
|
+
...ifSmall('data', out?.data, 2_000),
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
) as Promise<PluginCallResult<T>>,
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
http: {
|
|
673
|
+
fetch: (req: HttpRequest) =>
|
|
674
|
+
step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {
|
|
675
|
+
// Pre-flight the HOST grant so an author sees it named locally, in the
|
|
676
|
+
// same wording the service uses. The SECRET grant is deliberately not
|
|
677
|
+
// pre-flighted: the service derives it, and duplicating that derivation
|
|
678
|
+
// here would be a second place to get it wrong.
|
|
679
|
+
let host: string
|
|
680
|
+
try {
|
|
681
|
+
host = new URL(req.url).hostname.toLowerCase()
|
|
682
|
+
} catch {
|
|
683
|
+
throw new Error(`ctx.http: invalid URL ${req.url}`)
|
|
684
|
+
}
|
|
685
|
+
requireGrant(`http:${host}`)
|
|
686
|
+
const res = await scoped('/ctx/http', {
|
|
687
|
+
method: 'POST',
|
|
688
|
+
body: JSON.stringify(req),
|
|
689
|
+
})
|
|
690
|
+
if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)
|
|
691
|
+
return ((await res.json()) as { data: HttpResponse }).data
|
|
692
|
+
},
|
|
693
|
+
// The upstream status, which is the fact this call is read for — an API
|
|
694
|
+
// answering 404 is data here, not a throw, so the row is the only place
|
|
695
|
+
// that outcome appears at all. Not the body: it is capped at 1 MB and
|
|
696
|
+
// may carry whatever the host sent back.
|
|
697
|
+
(out) => ({
|
|
698
|
+
...(out ? { status: out.status, bodyChars: out.body?.length ?? 0 } : {}),
|
|
699
|
+
}),
|
|
700
|
+
) as Promise<HttpResponse>,
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
action: {
|
|
704
|
+
submit: (request: ActionSubmission) =>
|
|
705
|
+
step('action', `action:${request.action}`, async () => {
|
|
706
|
+
// Step scope BEFORE the grant. Both are the author's mistake, but this
|
|
707
|
+
// one is structural: a submit outside a step is wrong even with every
|
|
708
|
+
// grant in place, and the remedy is a code change rather than a
|
|
709
|
+
// manifest change. Naming the manifest first would send them to the
|
|
710
|
+
// wrong file.
|
|
711
|
+
const scope = stepScope.getStore()
|
|
712
|
+
if (!scope) throw new Error(submitOutsideStepMessageText(request.action))
|
|
713
|
+
// A step whose own row was lost cannot be submitted from. `recordStep`
|
|
714
|
+
// is contractually non-fatal and hands back an empty id, which is
|
|
715
|
+
// right for telemetry — one lost row must not cost the calls made
|
|
716
|
+
// inside it — but a submission has nothing to key on without it, and
|
|
717
|
+
// improvising a key is how an effect happens twice. Refused HERE so
|
|
718
|
+
// the cause is named; the service would otherwise see an empty string
|
|
719
|
+
// and answer with a generic invalid-submission.
|
|
720
|
+
if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))
|
|
721
|
+
// NUL-joined because both halves are author strings; `a:b` with no
|
|
722
|
+
// key must not collide with `a` keyed `b`.
|
|
723
|
+
// Refused locally, matching the service's own field-named rejection
|
|
724
|
+
// — folding `''` into the no-key identity would make the two layers
|
|
725
|
+
// disagree about what the author asked for.
|
|
726
|
+
if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
|
|
727
|
+
throw new Error(emptySubmissionKeyMessageText(request.action))
|
|
728
|
+
}
|
|
729
|
+
// Ahead of the reservation, and synchronous so check-and-reserve still
|
|
730
|
+
// land in one tick. Below it, a missing grant left the identity
|
|
731
|
+
// reserved and the author's next attempt was told they had duplicated
|
|
732
|
+
// a submission that never left the process — pointing at
|
|
733
|
+
// `submissionKey` when the fix is one line in the manifest. A call
|
|
734
|
+
// that is both ungranted and a duplicate now reports the grant, which
|
|
735
|
+
// is the more actionable of the two.
|
|
736
|
+
requireGrant(`governed:${request.action}`)
|
|
737
|
+
const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
|
|
738
|
+
// RESERVE, synchronously. The check and the record must land in one
|
|
739
|
+
// tick: with an await between them, `Promise.all([submit(x),
|
|
740
|
+
// submit(x)])` passes both checks before either records, both reach
|
|
741
|
+
// the plane, and — same key, same fingerprint — the plane replays the
|
|
742
|
+
// first for the second. Two calls, one effect, a green run, which is
|
|
743
|
+
// the exact failure this guard exists to prevent.
|
|
744
|
+
//
|
|
745
|
+
// Released again in the catch below, so a submission that never
|
|
746
|
+
// landed does not burn its identity and the author's retry loop still
|
|
747
|
+
// works. Reserve-then-release is what satisfies both at once.
|
|
748
|
+
if (scope.submitted.has(submissionIdentity)) {
|
|
749
|
+
throw new Error(duplicateSubmissionMessageText(request.action))
|
|
750
|
+
}
|
|
751
|
+
scope.submitted.add(submissionIdentity)
|
|
752
|
+
// The step ROW id, which the service issued. The service resolves the
|
|
753
|
+
// row, takes the step NAME off it, and derives the key from that — so
|
|
754
|
+
// what identifies the submission comes from the database rather than
|
|
755
|
+
// from this process. No ordinal: a positional one made an in-body
|
|
756
|
+
// retry mint a fresh key and duplicate the effect, and reordered
|
|
757
|
+
// concurrent submits bind each other's keys. `submissionKey` is how an
|
|
758
|
+
// author says two submissions are genuinely two.
|
|
759
|
+
let res: Response
|
|
760
|
+
try {
|
|
761
|
+
res = await scoped('/ctx/action-submit', {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
body: JSON.stringify({ ...request, stepId: scope.stepId }),
|
|
764
|
+
})
|
|
765
|
+
} catch (err) {
|
|
766
|
+
// Never reached the service. Release, so a retry is a retry rather
|
|
767
|
+
// than a duplicate accusation for a step that submitted zero times.
|
|
768
|
+
scope.submitted.delete(submissionIdentity)
|
|
769
|
+
throw err
|
|
770
|
+
}
|
|
771
|
+
if (!res.ok) {
|
|
772
|
+
// Refused, so nothing was bound to this identity. A 5xx is the
|
|
773
|
+
// interesting case: the author catches it and submits again, and
|
|
774
|
+
// that second call must be allowed through to the plane, where the
|
|
775
|
+
// key — unchanged — makes it a replay rather than a second effect.
|
|
776
|
+
scope.submitted.delete(submissionIdentity)
|
|
777
|
+
throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
return ((await res.json()) as { data: ActionSubmitResult }).data
|
|
781
|
+
} catch (err) {
|
|
782
|
+
// The submission LANDED, so keeping the reservation would be
|
|
783
|
+
// defensible — but the reasoning that releases a 503 applies here
|
|
784
|
+
// with more force: the key is unchanged, so a resubmit can only
|
|
785
|
+
// replay, and replaying is the only way the author recovers a
|
|
786
|
+
// request id they never received. Keeping it ends the run accusing
|
|
787
|
+
// them of two submissions when there was one and an unreadable
|
|
788
|
+
// answer.
|
|
789
|
+
scope.submitted.delete(submissionIdentity)
|
|
790
|
+
throw err
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
// `lifecycle` is the half of the answer a reader most often wants: a
|
|
794
|
+
// submission that stopped at `awaiting_approval` is a normal return, so
|
|
795
|
+
// the row's green status does not say whether anything happened yet.
|
|
796
|
+
(out) => ({
|
|
797
|
+
action: request.action,
|
|
798
|
+
...(request.submissionKey ? { submissionKey: request.submissionKey } : {}),
|
|
799
|
+
...(out ? { requestId: out.requestId, lifecycle: out.lifecycle } : {}),
|
|
800
|
+
}),
|
|
801
|
+
) as Promise<ActionSubmitResult>,
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
blueprint: {
|
|
805
|
+
query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>
|
|
806
|
+
step('blueprint', `query:${objectType}`, async () => {
|
|
807
|
+
requireGrant('blueprint:read')
|
|
808
|
+
// Spread rather than forwarded field-by-field so adding an option to
|
|
809
|
+
// `BlueprintQueryOptions` does not silently drop it here — the
|
|
810
|
+
// service validates the body, so an unknown key is refused there
|
|
811
|
+
// rather than ignored in transit.
|
|
812
|
+
const res = await scoped('/ctx/blueprint-query', {
|
|
813
|
+
method: 'POST',
|
|
814
|
+
body: JSON.stringify({ objectType, ...(options ?? {}) }),
|
|
815
|
+
})
|
|
816
|
+
if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)
|
|
817
|
+
// `T` is an author-supplied shape for rows the warehouse returns
|
|
818
|
+
// untyped. The cast is the honest boundary: nothing here can verify
|
|
819
|
+
// it, and pretending otherwise would just move the lie deeper.
|
|
820
|
+
return ((await res.json()) as { data: BlueprintQueryResult<T> }).data
|
|
821
|
+
},
|
|
822
|
+
// Count AND filter, because the two questions a reader brings to a
|
|
823
|
+
// query step are "how many did it match" and "what did it ask for" —
|
|
824
|
+
// and `hasMore` is how they tell an empty result from a truncated one.
|
|
825
|
+
(out) => ({
|
|
826
|
+
objectType,
|
|
827
|
+
...(out ? { rowCount: out.rows?.length ?? 0, hasMore: out.hasMore ?? false } : {}),
|
|
828
|
+
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
|
829
|
+
...ifSmall('where', options?.where, 1_000),
|
|
830
|
+
...ifSmall('select', options?.select, 500),
|
|
831
|
+
...ifSmall('orderBy', options?.orderBy, 500),
|
|
832
|
+
}),
|
|
833
|
+
) as Promise<BlueprintQueryResult<T>>,
|
|
834
|
+
},
|
|
835
|
+
}
|
|
836
|
+
}
|