@frontera-sdk/automation 0.1.0 → 1.43.6
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 +80 -0
- package/package.json +1 -1
- package/src/define.ts +1 -0
- package/src/index.ts +3 -0
- package/src/manifest.ts +54 -2
- package/src/messages.ts +36 -0
- package/src/testing.ts +164 -0
- package/src/types.ts +103 -0
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
package/src/define.ts
CHANGED
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
|
-
|
|
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'
|
|
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)) {
|
package/src/messages.ts
ADDED
|
@@ -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 {
|