@frontera-sdk/automation 0.1.0 → 1.43.5

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/README.md CHANGED
@@ -29,6 +29,54 @@ A trigger is `{ cron }` or `{ manual: true }`, never both — the type refuses
29
29
  the ambiguous shape rather than leaving the runner to decide what it means.
30
30
  `ctx.log` never rejects: telemetry must not be able to fail a run.
31
31
 
32
+ ## Steps
33
+
34
+ `ctx.step.run(name, fn)` makes a piece of work durable. The platform stores the
35
+ result, and if the run is retried the step is not run again — it returns what it
36
+ returned the first time.
37
+
38
+ ```ts
39
+ async (ctx) => {
40
+ const overdue = await ctx.step.run('load-overdue', () =>
41
+ ctx.blueprint.query('Invoice', {
42
+ where: { property: 'status', op: 'eq', value: 'overdue' },
43
+ }),
44
+ )
45
+
46
+ for (const [i, row] of overdue.rows.entries()) {
47
+ await ctx.step.run(`notify:${i}`, () =>
48
+ ctx.http.fetch({ url: 'https://hooks.example.com/notify', method: 'POST',
49
+ body: JSON.stringify({ invoiceId: row.id }) }),
50
+ )
51
+ }
52
+
53
+ return { notified: overdue.rows.length }
54
+ }
55
+ ```
56
+
57
+ Three rules, and each one is a real failure mode rather than a style note:
58
+
59
+ - **Names are unique within a run.** The platform memoizes by name, so reusing
60
+ one would hand back the first step's result. It is refused instead, naming the
61
+ collision — inside a loop, put the index in the name.
62
+ - **Results are JSON.** A step's result is stored and replayed, so a `Date`
63
+ comes back as a string. Return data, not objects with behaviour.
64
+ - **Code outside a step re-runs.** After each step the handler restarts from the
65
+ top, with completed steps returning their stored results. A `ctx.http` call
66
+ that is not inside a step therefore fires once per step and spends its call
67
+ budget every time.
68
+
69
+ Each step is recorded on the run, and every `ctx` call made inside one records
70
+ the step it belongs to — so a run's trail is a tree of named work rather than a
71
+ flat list.
72
+
73
+ The Console reads your deployed code and draws the whole shape: every step, both
74
+ arms of every `if` with the condition on the edge, and a loop as a single node.
75
+ A step named with a template shows as its pattern (`items:${i}`), because one
76
+ loop in the code is one step in the picture however many times it runs. Each
77
+ card names what the step reaches — Blueprint, Agent, HTTP — read from the `ctx`
78
+ calls in its body, and clicking one shows that step's source.
79
+
32
80
  `automation()` freezes the manifest it returns, including a copy of the
33
81
  trigger — a later mutation of the object you passed in cannot silently change
34
82
  the schedule the runner registers.
@@ -37,6 +85,38 @@ the schedule the runner registers.
37
85
  you can run it in your own tests: names are kebab-case segments, and a cron
38
86
  expression is parsed rather than pattern-matched.
39
87
 
88
+ ## Testing a handler
89
+
90
+ `createTestContext` gives you a `ctx` to call your handler with, so a branch can
91
+ be exercised without deploying and waiting for a day with the right data.
92
+
93
+ ```ts
94
+ import { createTestContext } from '@frontera-sdk/automation'
95
+ import handler from './index'
96
+
97
+ const empty = createTestContext({ grants: ['blueprint:read'] })
98
+ expect(await handler.handler(empty.ctx)).toEqual({ handled: 0 })
99
+ expect(empty.steps).toEqual(['load', 'nothing-to-do'])
100
+
101
+ const busy = createTestContext({
102
+ grants: ['blueprint:read', 'agent:ava:run'],
103
+ blueprint: { Invoice: { rows: [{ id: 'INV-1' }], hasMore: false } },
104
+ agents: { ava: () => ({ text: 'looks fine' }) },
105
+ })
106
+ await handler.handler(busy.ctx)
107
+ expect(busy.calls.map((c) => c.kind)).toContain('agent')
108
+ ```
109
+
110
+ It refuses what the platform refuses, in the same words: a grant your manifest
111
+ does not declare, and a step name used twice. An agent or an HTTP call you did
112
+ not stub throws rather than answering — a fabricated `200` or an empty agent
113
+ reply is a test that passes while asserting nothing. An object type you did not
114
+ stub returns no rows, because that is a real answer and usually the branch worth
115
+ testing.
116
+
117
+ What it does not simulate is resumption: in production your handler is re-entered
118
+ after every step, and here it runs once, straight through.
119
+
40
120
  ## License
41
121
 
42
122
  Apache-2.0. See [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/automation",
3
- "version": "0.1.0",
3
+ "version": "1.43.5",
4
4
  "description": "Author Frontera automations: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
package/src/define.ts CHANGED
@@ -24,6 +24,7 @@ export function automation(
24
24
  trigger: Object.freeze(trigger),
25
25
  grants: Object.freeze([...(manifest.grants ?? [])]),
26
26
  concurrency: manifest.concurrency ?? 1,
27
+ retries: manifest.retries ?? 0,
27
28
  }),
28
29
  handler,
29
30
  })
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { automation } from './define'
2
2
  export { validateManifest } from './manifest'
3
3
  export type { ValidationResult } from './manifest'
4
+ export { duplicateStepMessage, missingGrantMessage } from './messages'
5
+ export { createTestContext } from './testing'
6
+ export type { TestCall, TestContext, TestContextOptions } from './testing'
4
7
  export type * from './types'
package/src/manifest.ts CHANGED
@@ -15,7 +15,34 @@ const NAME_RE = new RegExp(`^${SEGMENT}$`)
15
15
  */
16
16
  const GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)
17
17
 
18
- const KNOWN_KEYS = new Set(['name', 'trigger', 'grants', 'concurrency', 'description'])
18
+ /**
19
+ * `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is
20
+ * lowercase-kebab and neither value is.
21
+ *
22
+ * A host contains DOTS (`api.stripe.com`); a secret name is conventionally
23
+ * SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both
24
+ * realistic forms — found by deploying an automation that used them.
25
+ *
26
+ * Deliberately not solved by widening SEGMENT: that governs agent slugs too, and
27
+ * loosening it there would admit `agent:AGENT:run`, which the comment above says
28
+ * is rejected on purpose.
29
+ */
30
+ const HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/
31
+ // Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE
32
+ // permissive here would let a manifest declare `secret:myKey`, validate cleanly,
33
+ // and then never be satisfiable — no such secret can be created. A validator that
34
+ // accepts the unsatisfiable is worse than one that is strict.
35
+ const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/
36
+
37
+ /** Namespaces whose value is not a SEGMENT. */
38
+ const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
39
+ http: { re: HOST_RE, hint: 'a hostname, e.g. "http:api.stripe.com" (no scheme, no path, no wildcard)' },
40
+ secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. "secret:STRIPE_KEY"' },
41
+ }
42
+
43
+ const KNOWN_KEYS = new Set([
44
+ 'name', 'trigger', 'grants', 'concurrency', 'retries', 'description',
45
+ ])
19
46
 
20
47
  export interface ValidationResult {
21
48
  valid: boolean
@@ -51,6 +78,7 @@ export function validateManifest(input: unknown): ValidationResult {
51
78
  trigger?: unknown
52
79
  grants?: unknown
53
80
  concurrency?: unknown
81
+ retries?: unknown
54
82
  }
55
83
 
56
84
  if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {
@@ -99,7 +127,24 @@ export function validateManifest(input: unknown): ValidationResult {
99
127
  // String(g), not `${g}` — a template literal THROWS on a symbol, and a
100
128
  // validator that exists to absorb hostile input must not have a throwing
101
129
  // path. The message names the fix, not just the verdict.
102
- if (typeof g !== 'string' || !GRANT_RE.test(g)) {
130
+ if (typeof g !== 'string') {
131
+ errors.push(
132
+ `malformed grant "${String(g)}" — expected "<namespace>:<action>", ` +
133
+ 'e.g. "blueprint:read" or "agent:risk-analyst:run"',
134
+ )
135
+ continue
136
+ }
137
+ const colon = g.indexOf(':')
138
+ const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined
139
+ if (typed) {
140
+ // A typed namespace validates its OWN value grammar. `http:` and
141
+ // `secret:` carry hosts and secret names, neither of which is a SEGMENT.
142
+ if (!typed.re.test(g.slice(colon + 1))) {
143
+ errors.push(`malformed grant "${g}" — the part after the colon must be ${typed.hint}`)
144
+ }
145
+ continue
146
+ }
147
+ if (!GRANT_RE.test(g)) {
103
148
  errors.push(
104
149
  `malformed grant "${String(g)}" — expected "<namespace>:<action>", ` +
105
150
  'e.g. "blueprint:read" or "agent:risk-analyst:run"',
@@ -113,6 +158,13 @@ export function validateManifest(input: unknown): ValidationResult {
113
158
  errors.push('concurrency must be an integer between 1 and 50')
114
159
  }
115
160
 
161
+ // Capped at 5. Above that it is not a retry policy, it is a loop — and every
162
+ // attempt re-runs whatever side effects the previous one already performed.
163
+ const r = m.retries
164
+ if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {
165
+ errors.push('retries must be an integer between 0 and 5')
166
+ }
167
+
116
168
  if (input && typeof input === 'object' && !Array.isArray(input)) {
117
169
  for (const key of Object.keys(input)) {
118
170
  if (!KNOWN_KEYS.has(key)) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The two things an author reads when the platform turns their code away.
3
+ *
4
+ * They live in the SDK, not in the runner, because three surfaces have to say
5
+ * exactly the same sentence: the runner refusing a call before it makes it, the
6
+ * service refusing it after, and `createTestContext` refusing it on the author's
7
+ * own machine. Three copies of a message drift, and a test that fails with
8
+ * different words than production is a test that teaches the wrong lesson.
9
+ */
10
+
11
+ /**
12
+ * A `ctx` call the manifest does not permit.
13
+ *
14
+ * Names the fix, not the verdict: the author is looking at CLI output, and
15
+ * "missing grant" without the remedy costs them a round trip through the docs.
16
+ */
17
+ export function missingGrantMessage(grant: string): string {
18
+ return `Automation is missing the "${grant}" grant. Add it to the manifest and redeploy.`
19
+ }
20
+
21
+ /**
22
+ * One step name used twice in a run.
23
+ *
24
+ * The platform memoizes by name, so the second call would return the FIRST
25
+ * step's result — no error, no warning, a wrong value flowing on. The remedy
26
+ * names THIS step rather than a placeholder, because a hint that reads as code
27
+ * to paste gets pasted.
28
+ */
29
+ export function duplicateStepMessage(name: string): string {
30
+ return (
31
+ `Duplicate automation step name "${name}". Step names must be unique within a run — ` +
32
+ "the platform memoizes by name, so this call would return the first step's result " +
33
+ 'instead of running again. If this is a loop, add the index: ' +
34
+ `ctx.step.run(\`${name}:\${i}\`, ...)`
35
+ )
36
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { duplicateStepMessage, missingGrantMessage } from './messages'
2
+ import type {
3
+ AutomationContext,
4
+ BlueprintQueryOptions,
5
+ BlueprintQueryResult,
6
+ Grant,
7
+ HttpRequest,
8
+ HttpResponse,
9
+ } from './types'
10
+
11
+ /**
12
+ * A `ctx` you can hand your handler in a unit test.
13
+ *
14
+ * Until this existed the only way to find out whether an automation worked was
15
+ * to deploy it and run it — a loop measured in tens of seconds, against real
16
+ * data, for a question as small as "does the empty branch return the right
17
+ * shape".
18
+ *
19
+ * It enforces what the platform enforces, in the platform's own words: a
20
+ * missing grant and a repeated step name fail here exactly as they fail in
21
+ * production, so a green test means something.
22
+ *
23
+ * What it does NOT simulate is resumption. In production a handler is re-entered
24
+ * after every step, so code outside a step runs many times; here the handler is
25
+ * called once, straight through. Steps still memoize by name within the run, and
26
+ * everything the handler did is recorded on `calls`.
27
+ */
28
+
29
+ export interface TestCall {
30
+ kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint'
31
+ /** Step name, log message, agent slug, URL, or object type. */
32
+ label: string
33
+ /** Present on a step: how it ended. */
34
+ status?: 'ok' | 'error'
35
+ }
36
+
37
+ export interface TestContextOptions {
38
+ runId?: string
39
+ workspaceId?: string
40
+ /**
41
+ * The grants the manifest declares.
42
+ *
43
+ * Given, they are enforced — which is the point: a missing grant is one of
44
+ * the few automation bugs that only shows up in a deployed run, and it is
45
+ * exactly the kind a unit test should catch.
46
+ *
47
+ * Omitted, nothing is refused, so an existing test does not have to enumerate
48
+ * grants to keep passing.
49
+ */
50
+ grants?: readonly Grant[]
51
+ /** Per-slug agent answers. An unstubbed agent throws rather than answering. */
52
+ agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
53
+ /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */
54
+ http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse
55
+ /** Rows per object type. An unstubbed type returns no rows, which is a real
56
+ * answer and usually the branch worth testing. */
57
+ blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
58
+ }
59
+
60
+ export interface TestContext {
61
+ ctx: AutomationContext
62
+ /** Everything the handler did, in order. */
63
+ calls: TestCall[]
64
+ /** Step names, in the order they ran. */
65
+ steps: string[]
66
+ logs: Array<{ message: string; data?: Record<string, unknown> }>
67
+ }
68
+
69
+ export function createTestContext(options: TestContextOptions = {}): TestContext {
70
+ const calls: TestCall[] = []
71
+ const steps: string[] = []
72
+ const logs: TestContext['logs'] = []
73
+ const seenNames = new Set<string>()
74
+
75
+ const requireGrant = (grant: string): void => {
76
+ // No grant list means the test is not about grants. Enforcing an empty list
77
+ // would fail every existing test for a reason its author never chose.
78
+ if (!options.grants) return
79
+ if (!options.grants.includes(grant as Grant)) throw new Error(missingGrantMessage(grant))
80
+ }
81
+
82
+ const ctx: AutomationContext = {
83
+ runId: options.runId ?? 'test-run',
84
+ workspaceId: options.workspaceId ?? 'test-workspace',
85
+
86
+ step: {
87
+ async run<T>(name: string, fn: () => Promise<T>): Promise<T> {
88
+ if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
89
+ seenNames.add(name)
90
+ steps.push(name)
91
+ try {
92
+ const out = await fn()
93
+ calls.push({ kind: 'step', label: name, status: 'ok' })
94
+ return out
95
+ } catch (err) {
96
+ calls.push({ kind: 'step', label: name, status: 'error' })
97
+ throw err
98
+ }
99
+ },
100
+ },
101
+
102
+ async log(message, data) {
103
+ logs.push({ message, ...(data ? { data } : {}) })
104
+ calls.push({ kind: 'log', label: message })
105
+ },
106
+
107
+ agent(slug: string) {
108
+ return {
109
+ async run(prompt: string) {
110
+ requireGrant(`agent:${slug}:run`)
111
+ calls.push({ kind: 'agent', label: slug })
112
+ const stub = options.agents?.[slug]
113
+ // Throwing beats answering with an empty string: a test whose agent
114
+ // silently returns '' passes while asserting nothing about the step
115
+ // that matters most.
116
+ if (!stub) {
117
+ throw new Error(
118
+ `No agent stub for "${slug}". Pass agents: { '${slug}': () => ({ text: '…' }) } ` +
119
+ 'to createTestContext.',
120
+ )
121
+ }
122
+ return await stub(prompt)
123
+ },
124
+ }
125
+ },
126
+
127
+ http: {
128
+ async fetch(req: HttpRequest) {
129
+ let host: string
130
+ try {
131
+ host = new URL(req.url).hostname.toLowerCase()
132
+ } catch {
133
+ throw new Error(`ctx.http: invalid URL ${req.url}`)
134
+ }
135
+ requireGrant(`http:${host}`)
136
+ calls.push({ kind: 'http', label: req.url })
137
+ // Same reasoning as the agent: a fabricated 200 is a false pass.
138
+ if (!options.http) {
139
+ throw new Error(
140
+ `No http stub. Pass http: (req) => ({ status: 200, headers: {}, body: '' }) ` +
141
+ 'to createTestContext.',
142
+ )
143
+ }
144
+ return await options.http(req)
145
+ },
146
+ },
147
+
148
+ blueprint: {
149
+ async query<T = Record<string, unknown>>(
150
+ objectType: string,
151
+ _options?: BlueprintQueryOptions,
152
+ ): Promise<BlueprintQueryResult<T>> {
153
+ requireGrant('blueprint:read')
154
+ calls.push({ kind: 'blueprint', label: objectType })
155
+ const stub = options.blueprint?.[objectType]
156
+ // Empty is a real answer, and the branch an author most often forgets
157
+ // to test — so this one defaults rather than throwing.
158
+ return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>
159
+ },
160
+ },
161
+ }
162
+
163
+ return { ctx, calls, steps, logs }
164
+ }
package/src/types.ts CHANGED
@@ -34,12 +34,40 @@ export type AutomationTrigger =
34
34
  export type Grant =
35
35
  | 'blueprint:read'
36
36
  | `agent:${string}:run`
37
+ /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and
38
+ * nothing else — a wildcard would ask a reviewer to reason about
39
+ * subdomain-takeover risk, and the answer is usually wrong. */
40
+ | `http:${string}`
41
+ /** The NAME of a workspace secret. Its VALUE never enters this process: you
42
+ * name it, the platform injects it server-side. */
43
+ | `secret:${string}`
37
44
 
38
45
  export interface AutomationManifest {
39
46
  name: string
40
47
  trigger: AutomationTrigger
41
48
  grants?: readonly Grant[]
42
49
  concurrency?: number
50
+ /**
51
+ * Times the platform may retry a run that FAILED. Default 0, and the opt-in
52
+ * is the contract.
53
+ *
54
+ * Setting this asserts your handler is safe to run twice. With `ctx.http` that
55
+ * is a real claim rather than a formality — a retried run that charged a card
56
+ * charges it again, and the platform cannot check idempotency on your behalf.
57
+ * Per-automation, not global, because you are the only one who knows.
58
+ *
59
+ * Retries do NOT extend the ctx call budget: each attempt is a separate run
60
+ * with its own meter.
61
+ *
62
+ * With steps, this is a bound on RUN attempts, and a step that fails is what
63
+ * consumes one. Completed steps are not re-executed on the next attempt —
64
+ * they return their stored results — so a retry resumes from the failure
65
+ * rather than starting the work again. That is the point of putting a call
66
+ * that costs something inside a step: `retries: 2` on a handler whose work is
67
+ * all in steps re-runs only the step that failed, while the same setting on a
68
+ * handler with no steps re-runs everything.
69
+ */
70
+ retries?: number
43
71
  description?: string
44
72
  }
45
73
 
@@ -58,6 +86,7 @@ export interface ResolvedAutomationManifest extends AutomationManifest {
58
86
  readonly trigger: Readonly<AutomationTrigger>
59
87
  readonly grants: readonly Grant[]
60
88
  readonly concurrency: number
89
+ readonly retries: number
61
90
  readonly description?: string
62
91
  }
63
92
 
@@ -65,6 +94,38 @@ export interface AgentHandle {
65
94
  run(prompt: string): Promise<{ text: string }>
66
95
  }
67
96
 
97
+ /**
98
+ * Durable steps.
99
+ *
100
+ * A step is the unit the platform can memoize, retry and draw. Work inside one
101
+ * runs at most once per run; work outside one runs again every time the
102
+ * platform resumes the handler, which it does after every step completes.
103
+ *
104
+ * That resumption is the whole model and it is what the three rules below are
105
+ * about — none of them is a style preference.
106
+ */
107
+ export interface StepApi {
108
+ /**
109
+ * Run `fn` as a durable step and return its result.
110
+ *
111
+ * Three rules, all enforced or observable rather than advisory:
112
+ *
113
+ * 1. **`name` must be unique within a run.** The platform memoizes by it, so a
114
+ * repeated name would silently hand back the FIRST call's result. Inside a
115
+ * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the
116
+ * run naming the collision rather than returning the wrong value.
117
+ * 2. **The result must be JSON-serializable.** It is stored and replayed, so a
118
+ * `Date` comes back as a string and a class instance comes back as a plain
119
+ * object. Return data, not objects with behaviour.
120
+ * 3. **Code outside a step re-executes.** After each step the handler restarts
121
+ * from the top with completed steps returning their stored results. A
122
+ * `ctx.http` call sitting outside a step therefore fires once per step, and
123
+ * spends its call budget every time. The Console flags such calls on a run
124
+ * that used steps.
125
+ */
126
+ run<T>(name: string, fn: () => Promise<T>): Promise<T>
127
+ }
128
+
68
129
  /** Read-only for now. `governed`/`notify` arrive with Governed Writes. */
69
130
  export interface AutomationContext {
70
131
  runId: string
@@ -72,12 +133,54 @@ export interface AutomationContext {
72
133
  /** Never rejects — telemetry must not be able to fail a run. */
73
134
  log(message: string, data?: Record<string, unknown>): Promise<void>
74
135
  agent(slug: string): AgentHandle
136
+ http: {
137
+ /**
138
+ * Call an allowlisted host, optionally with a workspace secret injected
139
+ * server-side.
140
+ *
141
+ * Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`
142
+ * is used. The secret's VALUE never enters this process — that is deliberate:
143
+ * a credential this process never held cannot be leaked by a stray
144
+ * `ctx.log`, an exception serialiser, or a dependency, and step details are
145
+ * rendered verbatim in the Console.
146
+ *
147
+ * An upstream 4xx/5xx comes back as `status`, not as a throw. An API
148
+ * answering 404 is data; only failures of the mechanism reject.
149
+ */
150
+ fetch(req: HttpRequest): Promise<HttpResponse>
151
+ }
75
152
  blueprint: {
76
153
  query<T = Record<string, unknown>>(
77
154
  objectType: string,
78
155
  options?: BlueprintQueryOptions,
79
156
  ): Promise<BlueprintQueryResult<T>>
80
157
  }
158
+ /**
159
+ * Durable steps. See `StepApi`.
160
+ *
161
+ * Present on every automation — a handler that uses no steps behaves exactly
162
+ * as it did before this existed, because a run with no steps is never
163
+ * resumed.
164
+ */
165
+ step: StepApi
166
+ }
167
+
168
+ export interface HttpRequest {
169
+ url: string
170
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
171
+ headers?: Record<string, string>
172
+ /** String only — no streaming, no binary. */
173
+ body?: string
174
+ /** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */
175
+ auth?: { header: string; secret: string; prefix?: string }
176
+ }
177
+
178
+ export interface HttpResponse {
179
+ status: number
180
+ headers: Record<string, string>
181
+ /** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a
182
+ * silently shortened response is a wrong answer that looks right. */
183
+ body: string
81
184
  }
82
185
 
83
186
  export interface BlueprintQueryOptions {