@frontera-sdk/automation 1.43.9 → 1.44.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/package.json +2 -2
- package/src/index.ts +8 -1
- package/src/manifest.ts +11 -0
- package/src/messages.ts +73 -1
- package/src/runtime-context.ts +130 -3
- package/src/testing.ts +105 -11
- package/src/types.ts +148 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/automation",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Author Frontera automations: manifest, triggers and the typed handler contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"typescript": "^5.9.3"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@frontera-sdk/blueprint": "1.43.
|
|
41
|
+
"@frontera-sdk/blueprint": "1.43.10",
|
|
42
42
|
"cron-parser": "^5.0.6"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export { automation } from './define'
|
|
2
2
|
export { validateManifest } from './manifest'
|
|
3
3
|
export type { ValidationResult } from './manifest'
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
duplicateStepMessage,
|
|
6
|
+
duplicateSubmissionMessage,
|
|
7
|
+
emptySubmissionKeyMessage,
|
|
8
|
+
lostStepRowMessage,
|
|
9
|
+
missingGrantMessage,
|
|
10
|
+
submitOutsideStepMessage,
|
|
11
|
+
} from './messages'
|
|
5
12
|
export { createTestContext } from './testing'
|
|
6
13
|
export type { TestCall, TestContext, TestContextOptions } from './testing'
|
|
7
14
|
export type * from './types'
|
package/src/manifest.ts
CHANGED
|
@@ -33,11 +33,22 @@ const HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0
|
|
|
33
33
|
// and then never be satisfiable — no such secret can be created. A validator that
|
|
34
34
|
// accepts the unsatisfiable is worse than one that is strict.
|
|
35
35
|
const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/
|
|
36
|
+
// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.
|
|
37
|
+
// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept
|
|
38
|
+
// `governed:Approve_Invoice`, validate cleanly, and name an Action that can
|
|
39
|
+
// never exist — no published Action carries that apiName, so the grant is
|
|
40
|
+
// unsatisfiable and the automation fails at its first submit instead of at
|
|
41
|
+
// deploy.
|
|
42
|
+
const ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/
|
|
36
43
|
|
|
37
44
|
/** Namespaces whose value is not a SEGMENT. */
|
|
38
45
|
const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
|
|
39
46
|
http: { re: HOST_RE, hint: 'a hostname, e.g. "http:api.stripe.com" (no scheme, no path, no wildcard)' },
|
|
40
47
|
secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. "secret:STRIPE_KEY"' },
|
|
48
|
+
governed: {
|
|
49
|
+
re: ACTION_API_NAME_RE,
|
|
50
|
+
hint: 'one published Action apiName, e.g. "governed:approveInvoice" (camelCase, no wildcard)',
|
|
51
|
+
},
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
const KNOWN_KEYS = new Set([
|
package/src/messages.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Everything an author reads when the platform turns their code away.
|
|
3
3
|
*
|
|
4
4
|
* They live in the SDK, not in the runner, because three surfaces have to say
|
|
5
5
|
* exactly the same sentence: the runner refusing a call before it makes it, the
|
|
@@ -34,3 +34,75 @@ export function duplicateStepMessage(name: string): string {
|
|
|
34
34
|
`ctx.step.run(\`${name}:\${i}\`, ...)`
|
|
35
35
|
)
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A submit inside a step whose own row never recorded.
|
|
40
|
+
*
|
|
41
|
+
* Recording a step is contractually non-fatal — telemetry must not fail a run —
|
|
42
|
+
* so the id comes back empty and everything else carries on. A submission
|
|
43
|
+
* cannot: the row is what the idempotency key is derived from, and improvising
|
|
44
|
+
* one is how the same effect happens twice. Named here rather than left to the
|
|
45
|
+
* service's generic invalid-submission, which would blame the payload.
|
|
46
|
+
*/
|
|
47
|
+
export function lostStepRowMessage(stepName: string): string {
|
|
48
|
+
return (
|
|
49
|
+
`ctx.action.submit cannot run in step "${stepName}": the step's own record failed to write, ` +
|
|
50
|
+
'so there is nothing stable to key the submission on and it is refused rather than sent ' +
|
|
51
|
+
'twice. This is a transient service failure — retry the run.'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The same Action submitted twice from one step with no way to tell them apart.
|
|
57
|
+
*
|
|
58
|
+
* Refused HERE, locally, rather than left to the write plane, because the plane
|
|
59
|
+
* cannot refuse it: two identical submissions derive one idempotency key AND
|
|
60
|
+
* one semantic fingerprint, so it replays the first request and answers both
|
|
61
|
+
* calls with the same request id. Nothing errors, one effect happens, and the
|
|
62
|
+
* run reports success — the failure mode a batch loop hits on its second row
|
|
63
|
+
* and not on a one-row fixture.
|
|
64
|
+
*
|
|
65
|
+
* Naming the remedy matters more than usual: `submissionKey` is the one field
|
|
66
|
+
* an author has to reach for to fix this, and it is not guessable from a 409.
|
|
67
|
+
*/
|
|
68
|
+
export function duplicateSubmissionMessage(apiName: string): string {
|
|
69
|
+
return (
|
|
70
|
+
`Step already submitted "${apiName}" with the same submissionKey. Two submissions the ` +
|
|
71
|
+
'platform cannot tell apart become ONE request — the plane replays the first and the second ' +
|
|
72
|
+
'effect never happens. If this is a batch, give each submission a distinct submissionKey ' +
|
|
73
|
+
"keyed on what it acts on: ctx.action.submit({ action: '" + apiName + "', submissionKey: " +
|
|
74
|
+
'row.id, ... }). If it is a retry, it is already idempotent — drop the loop.'
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An empty `submissionKey`.
|
|
80
|
+
*
|
|
81
|
+
* Refused rather than treated as absent, and refused in both layers: the
|
|
82
|
+
* service rejects it by name, so folding it into the no-key identity here
|
|
83
|
+
* would make the SDK and the service disagree about what the author asked for.
|
|
84
|
+
*/
|
|
85
|
+
export function emptySubmissionKeyMessage(apiName: string): string {
|
|
86
|
+
return (
|
|
87
|
+
`ctx.action.submit("${apiName}") was given an empty submissionKey. Omit it entirely to mean ` +
|
|
88
|
+
'"this step submits once", or pass a value identifying what this submission acts on.'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `ctx.action.submit` called outside a step.
|
|
94
|
+
*
|
|
95
|
+
* States the consequence rather than the rule, because the rule on its own
|
|
96
|
+
* reads as ceremony: code outside a step runs again after EVERY step the
|
|
97
|
+
* handler completes, so a submit there is not one submission with a retry
|
|
98
|
+
* risk — it is one submission per step boundary, every time the run resumes.
|
|
99
|
+
* The step row is also what the idempotency key is derived from, so there is
|
|
100
|
+
* nothing to derive one from out here.
|
|
101
|
+
*/
|
|
102
|
+
export function submitOutsideStepMessage(apiName: string): string {
|
|
103
|
+
return (
|
|
104
|
+
`ctx.action.submit("${apiName}") must be called inside ctx.step.run. Code outside a step ` +
|
|
105
|
+
're-executes every time the run resumes, so this would submit once per step boundary. ' +
|
|
106
|
+
`Wrap it: ctx.step.run('submit-${apiName}', () => ctx.action.submit({ ... }))`
|
|
107
|
+
)
|
|
108
|
+
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -20,8 +20,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
20
20
|
import {
|
|
21
21
|
duplicateStepMessage as duplicateStepMessageText,
|
|
22
22
|
missingGrantMessage as missingGrantMessageText,
|
|
23
|
+
submitOutsideStepMessage as submitOutsideStepMessageText,
|
|
24
|
+
lostStepRowMessage as lostStepRowMessageText,
|
|
25
|
+
duplicateSubmissionMessage as duplicateSubmissionMessageText,
|
|
26
|
+
emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
|
|
23
27
|
} from './messages'
|
|
24
28
|
import type {
|
|
29
|
+
ActionSubmission,
|
|
30
|
+
ActionSubmitResult,
|
|
25
31
|
AutomationContext,
|
|
26
32
|
BlueprintQueryOptions,
|
|
27
33
|
BlueprintQueryResult,
|
|
@@ -89,7 +95,14 @@ interface Deps {
|
|
|
89
95
|
runnerToken?: string
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
export {
|
|
98
|
+
export {
|
|
99
|
+
duplicateStepMessage,
|
|
100
|
+
duplicateSubmissionMessage,
|
|
101
|
+
emptySubmissionKeyMessage,
|
|
102
|
+
lostStepRowMessage,
|
|
103
|
+
missingGrantMessage,
|
|
104
|
+
submitOutsideStepMessage,
|
|
105
|
+
} from './messages'
|
|
93
106
|
|
|
94
107
|
export class DuplicateStepNameError extends Error {
|
|
95
108
|
constructor(readonly stepName: string) {
|
|
@@ -139,7 +152,20 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
139
152
|
* `b` depending on interleaving. This is per-run, not module-global: two runs
|
|
140
153
|
* in one process must never see each other's scope.
|
|
141
154
|
*/
|
|
142
|
-
const stepScope = new AsyncLocalStorage<{
|
|
155
|
+
const stepScope = new AsyncLocalStorage<{
|
|
156
|
+
stepId: string
|
|
157
|
+
stepName: string
|
|
158
|
+
/**
|
|
159
|
+
* `action` + `submissionKey` for every submit this step body has made.
|
|
160
|
+
*
|
|
161
|
+
* The write plane CANNOT catch a repeat: two identical submissions derive
|
|
162
|
+
* one key and one semantic fingerprint, so it replays the first request and
|
|
163
|
+
* answers both calls with the same id — no error, one effect, a green run.
|
|
164
|
+
* The check has to be local, and per step EXECUTION so that a genuine
|
|
165
|
+
* resumption or retry (which re-enters the body from scratch) is unaffected.
|
|
166
|
+
*/
|
|
167
|
+
submitted: Set<string>
|
|
168
|
+
}>()
|
|
143
169
|
|
|
144
170
|
/**
|
|
145
171
|
* Append a row to the run's audit trail, returning the id the service gave it.
|
|
@@ -301,7 +327,16 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
301
327
|
})
|
|
302
328
|
const t0 = Date.now()
|
|
303
329
|
try {
|
|
304
|
-
|
|
330
|
+
// `stepName` rides alongside `stepId` because `ctx.action.submit` needs
|
|
331
|
+
// the NAME, not the row id: the id is fresh on every execution, and an
|
|
332
|
+
// idempotency key derived from it would differ on each resumption —
|
|
333
|
+
// which is the exact duplicate-submission this scope exists to prevent.
|
|
334
|
+
// The service reads the name off the row rather than trusting this
|
|
335
|
+
// copy; it travels here only so a refusal can name it.
|
|
336
|
+
const out = await stepScope.run(
|
|
337
|
+
{ stepId, stepName: name, submitted: new Set<string>() },
|
|
338
|
+
fn,
|
|
339
|
+
)
|
|
305
340
|
await completeStep(stepId, { status: 'ok', durationMs: Date.now() - t0 })
|
|
306
341
|
return out
|
|
307
342
|
} catch (err) {
|
|
@@ -366,6 +401,98 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
366
401
|
}) as Promise<HttpResponse>,
|
|
367
402
|
},
|
|
368
403
|
|
|
404
|
+
action: {
|
|
405
|
+
submit: (request: ActionSubmission) =>
|
|
406
|
+
step('action', `action:${request.action}`, async () => {
|
|
407
|
+
// Step scope BEFORE the grant. Both are the author's mistake, but this
|
|
408
|
+
// one is structural: a submit outside a step is wrong even with every
|
|
409
|
+
// grant in place, and the remedy is a code change rather than a
|
|
410
|
+
// manifest change. Naming the manifest first would send them to the
|
|
411
|
+
// wrong file.
|
|
412
|
+
const scope = stepScope.getStore()
|
|
413
|
+
if (!scope) throw new Error(submitOutsideStepMessageText(request.action))
|
|
414
|
+
// A step whose own row was lost cannot be submitted from. `recordStep`
|
|
415
|
+
// is contractually non-fatal and hands back an empty id, which is
|
|
416
|
+
// right for telemetry — one lost row must not cost the calls made
|
|
417
|
+
// inside it — but a submission has nothing to key on without it, and
|
|
418
|
+
// improvising a key is how an effect happens twice. Refused HERE so
|
|
419
|
+
// the cause is named; the service would otherwise see an empty string
|
|
420
|
+
// and answer with a generic invalid-submission.
|
|
421
|
+
if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))
|
|
422
|
+
// NUL-joined because both halves are author strings; `a:b` with no
|
|
423
|
+
// key must not collide with `a` keyed `b`.
|
|
424
|
+
// Refused locally, matching the service's own field-named rejection
|
|
425
|
+
// — folding `''` into the no-key identity would make the two layers
|
|
426
|
+
// disagree about what the author asked for.
|
|
427
|
+
if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
|
|
428
|
+
throw new Error(emptySubmissionKeyMessageText(request.action))
|
|
429
|
+
}
|
|
430
|
+
// Ahead of the reservation, and synchronous so check-and-reserve still
|
|
431
|
+
// land in one tick. Below it, a missing grant left the identity
|
|
432
|
+
// reserved and the author's next attempt was told they had duplicated
|
|
433
|
+
// a submission that never left the process — pointing at
|
|
434
|
+
// `submissionKey` when the fix is one line in the manifest. A call
|
|
435
|
+
// that is both ungranted and a duplicate now reports the grant, which
|
|
436
|
+
// is the more actionable of the two.
|
|
437
|
+
requireGrant(`governed:${request.action}`)
|
|
438
|
+
const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
|
|
439
|
+
// RESERVE, synchronously. The check and the record must land in one
|
|
440
|
+
// tick: with an await between them, `Promise.all([submit(x),
|
|
441
|
+
// submit(x)])` passes both checks before either records, both reach
|
|
442
|
+
// the plane, and — same key, same fingerprint — the plane replays the
|
|
443
|
+
// first for the second. Two calls, one effect, a green run, which is
|
|
444
|
+
// the exact failure this guard exists to prevent.
|
|
445
|
+
//
|
|
446
|
+
// Released again in the catch below, so a submission that never
|
|
447
|
+
// landed does not burn its identity and the author's retry loop still
|
|
448
|
+
// works. Reserve-then-release is what satisfies both at once.
|
|
449
|
+
if (scope.submitted.has(submissionIdentity)) {
|
|
450
|
+
throw new Error(duplicateSubmissionMessageText(request.action))
|
|
451
|
+
}
|
|
452
|
+
scope.submitted.add(submissionIdentity)
|
|
453
|
+
// The step ROW id, which the service issued. The service resolves the
|
|
454
|
+
// row, takes the step NAME off it, and derives the key from that — so
|
|
455
|
+
// what identifies the submission comes from the database rather than
|
|
456
|
+
// from this process. No ordinal: a positional one made an in-body
|
|
457
|
+
// retry mint a fresh key and duplicate the effect, and reordered
|
|
458
|
+
// concurrent submits bind each other's keys. `submissionKey` is how an
|
|
459
|
+
// author says two submissions are genuinely two.
|
|
460
|
+
let res: Response
|
|
461
|
+
try {
|
|
462
|
+
res = await scoped('/ctx/action-submit', {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
body: JSON.stringify({ ...request, stepId: scope.stepId }),
|
|
465
|
+
})
|
|
466
|
+
} catch (err) {
|
|
467
|
+
// Never reached the service. Release, so a retry is a retry rather
|
|
468
|
+
// than a duplicate accusation for a step that submitted zero times.
|
|
469
|
+
scope.submitted.delete(submissionIdentity)
|
|
470
|
+
throw err
|
|
471
|
+
}
|
|
472
|
+
if (!res.ok) {
|
|
473
|
+
// Refused, so nothing was bound to this identity. A 5xx is the
|
|
474
|
+
// interesting case: the author catches it and submits again, and
|
|
475
|
+
// that second call must be allowed through to the plane, where the
|
|
476
|
+
// key — unchanged — makes it a replay rather than a second effect.
|
|
477
|
+
scope.submitted.delete(submissionIdentity)
|
|
478
|
+
throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
return ((await res.json()) as { data: ActionSubmitResult }).data
|
|
482
|
+
} catch (err) {
|
|
483
|
+
// The submission LANDED, so keeping the reservation would be
|
|
484
|
+
// defensible — but the reasoning that releases a 503 applies here
|
|
485
|
+
// with more force: the key is unchanged, so a resubmit can only
|
|
486
|
+
// replay, and replaying is the only way the author recovers a
|
|
487
|
+
// request id they never received. Keeping it ends the run accusing
|
|
488
|
+
// them of two submissions when there was one and an unreadable
|
|
489
|
+
// answer.
|
|
490
|
+
scope.submitted.delete(submissionIdentity)
|
|
491
|
+
throw err
|
|
492
|
+
}
|
|
493
|
+
}) as Promise<ActionSubmitResult>,
|
|
494
|
+
},
|
|
495
|
+
|
|
369
496
|
blueprint: {
|
|
370
497
|
query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>
|
|
371
498
|
step('blueprint', `query:${objectType}`, async () => {
|
package/src/testing.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import {
|
|
3
|
+
duplicateStepMessage,
|
|
4
|
+
duplicateSubmissionMessage,
|
|
5
|
+
emptySubmissionKeyMessage,
|
|
6
|
+
missingGrantMessage,
|
|
7
|
+
submitOutsideStepMessage,
|
|
8
|
+
} from './messages'
|
|
2
9
|
import type {
|
|
10
|
+
ActionSubmission,
|
|
11
|
+
ActionSubmitResult,
|
|
3
12
|
AutomationContext,
|
|
4
13
|
BlueprintQueryOptions,
|
|
5
14
|
BlueprintQueryResult,
|
|
@@ -26,14 +35,25 @@ import type {
|
|
|
26
35
|
* everything the handler did is recorded on `calls`.
|
|
27
36
|
*/
|
|
28
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A call REFUSED before it happened is not recorded.
|
|
40
|
+
*
|
|
41
|
+
* A missing grant, a missing stub, a submit outside a step, a duplicate
|
|
42
|
+
* submission — none of these appear in `calls`, because none of them did
|
|
43
|
+
* anything. A real run differs here in one direction worth knowing: it writes
|
|
44
|
+
* an errored ctx-call row for a refused `ctx.action.submit`, so the Console
|
|
45
|
+
* trace shows the attempt where this list does not. Assert on the thrown error
|
|
46
|
+
* for a refusal, and on `calls` for what ran.
|
|
47
|
+
*/
|
|
29
48
|
export interface TestCall {
|
|
30
|
-
kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint'
|
|
31
|
-
/** Step name, log message, agent slug, URL, or
|
|
49
|
+
kind: 'step' | 'log' | 'agent' | 'http' | 'blueprint' | 'action'
|
|
50
|
+
/** Step name, log message, agent slug, URL, object type, or Action apiName. */
|
|
32
51
|
label: string
|
|
33
52
|
/** Present on a step: how it ended. */
|
|
34
53
|
status?: 'ok' | 'error'
|
|
35
54
|
}
|
|
36
55
|
|
|
56
|
+
|
|
37
57
|
export interface TestContextOptions {
|
|
38
58
|
runId?: string
|
|
39
59
|
workspaceId?: string
|
|
@@ -55,6 +75,19 @@ export interface TestContextOptions {
|
|
|
55
75
|
/** Rows per object type. An unstubbed type returns no rows, which is a real
|
|
56
76
|
* answer and usually the branch worth testing. */
|
|
57
77
|
blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
|
|
78
|
+
/**
|
|
79
|
+
* Per-apiName Action outcomes. An unstubbed Action throws rather than
|
|
80
|
+
* answering.
|
|
81
|
+
*
|
|
82
|
+
* Throws for the same reason the agent stub does, and the reason is sharper
|
|
83
|
+
* here: the returned `lifecycle` is a branch an author writes code against —
|
|
84
|
+
* `awaiting_approval` means a human still has to decide — so inventing
|
|
85
|
+
* `ready` would silently pick one arm and pass.
|
|
86
|
+
*/
|
|
87
|
+
actions?: Record<
|
|
88
|
+
string,
|
|
89
|
+
(request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult
|
|
90
|
+
>
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
export interface TestContext {
|
|
@@ -71,6 +104,21 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
71
104
|
const steps: string[] = []
|
|
72
105
|
const logs: TestContext['logs'] = []
|
|
73
106
|
const seenNames = new Set<string>()
|
|
107
|
+
/**
|
|
108
|
+
* Which step the running code is inside.
|
|
109
|
+
*
|
|
110
|
+
* `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.
|
|
111
|
+
* A stack gets the concurrent case wrong in the direction that matters:
|
|
112
|
+
* `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and
|
|
113
|
+
* with a shared mutable stack the bare submit sees `a` open and is allowed —
|
|
114
|
+
* so the test double passes what production refuses, which is the one failure
|
|
115
|
+
* mode a test double must not have.
|
|
116
|
+
*
|
|
117
|
+
* Enforced here for the same reason grants and duplicate names are: a rule
|
|
118
|
+
* the unit test does not apply is a rule the author meets for the first time
|
|
119
|
+
* in a deployed run.
|
|
120
|
+
*/
|
|
121
|
+
const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()
|
|
74
122
|
|
|
75
123
|
const requireGrant = (grant: string): void => {
|
|
76
124
|
// No grant list means the test is not about grants. Enforcing an empty list
|
|
@@ -88,14 +136,16 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
88
136
|
if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
|
|
89
137
|
seenNames.add(name)
|
|
90
138
|
steps.push(name)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
|
|
140
|
+
try {
|
|
141
|
+
const out = await fn()
|
|
142
|
+
calls.push({ kind: 'step', label: name, status: 'ok' })
|
|
143
|
+
return out
|
|
144
|
+
} catch (err) {
|
|
145
|
+
calls.push({ kind: 'step', label: name, status: 'error' })
|
|
146
|
+
throw err
|
|
147
|
+
}
|
|
148
|
+
})
|
|
99
149
|
},
|
|
100
150
|
},
|
|
101
151
|
|
|
@@ -145,6 +195,50 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
|
|
|
145
195
|
},
|
|
146
196
|
},
|
|
147
197
|
|
|
198
|
+
action: {
|
|
199
|
+
async submit(request: ActionSubmission): Promise<ActionSubmitResult> {
|
|
200
|
+
const scope = stepScope.getStore()
|
|
201
|
+
if (!scope) throw new Error(submitOutsideStepMessage(request.action))
|
|
202
|
+
// The batch loop is the shape this catches, and a one-row fixture never
|
|
203
|
+
// reaches it — so the double has to enforce it or an author meets it
|
|
204
|
+
// for the first time on their second production row, after the first
|
|
205
|
+
// has already been applied.
|
|
206
|
+
if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
|
|
207
|
+
throw new Error(emptySubmissionKeyMessage(request.action))
|
|
208
|
+
}
|
|
209
|
+
requireGrant(`governed:${request.action}`)
|
|
210
|
+
const stub = options.actions?.[request.action]
|
|
211
|
+
// Both refusals that mean "this never happened" come BEFORE the
|
|
212
|
+
// reservation, matching the runtime's grant check: reserving first left
|
|
213
|
+
// an author who fixed the missing stub and re-ran a loop facing a
|
|
214
|
+
// duplicate accusation for a call that never answered.
|
|
215
|
+
if (!stub) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`No action stub for "${request.action}". Pass actions: { '${request.action}': ` +
|
|
218
|
+
"() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.",
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
|
|
222
|
+
// Reserved synchronously and released on failure, matching the runtime
|
|
223
|
+
// exactly. A double that checked and recorded across an await would let
|
|
224
|
+
// `Promise.all([submit(x), submit(x)])` through — and a double that
|
|
225
|
+
// permits what production refuses is the one failure mode a double must
|
|
226
|
+
// not have.
|
|
227
|
+
if (scope.submitted.has(submissionIdentity)) {
|
|
228
|
+
throw new Error(duplicateSubmissionMessage(request.action))
|
|
229
|
+
}
|
|
230
|
+
scope.submitted.add(submissionIdentity)
|
|
231
|
+
calls.push({ kind: 'action', label: request.action })
|
|
232
|
+
try {
|
|
233
|
+
return await stub(request)
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// A throwing stub stands in for a submission that never landed.
|
|
236
|
+
scope.submitted.delete(submissionIdentity)
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
148
242
|
blueprint: {
|
|
149
243
|
async query<T = Record<string, unknown>>(
|
|
150
244
|
objectType: string,
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,13 @@ export type Grant =
|
|
|
41
41
|
/** The NAME of a workspace secret. Its VALUE never enters this process: you
|
|
42
42
|
* name it, the platform injects it server-side. */
|
|
43
43
|
| `secret:${string}`
|
|
44
|
+
/** One EXACT published Action apiName. `governed:approveInvoice` permits
|
|
45
|
+
* submitting that Action and nothing else.
|
|
46
|
+
*
|
|
47
|
+
* Not wildcardable, for the same reason `http:` is not: a reviewer reading
|
|
48
|
+
* `governed:*` would have to know the whole current Action catalog — and the
|
|
49
|
+
* answer changes with every release — to know what the automation may do. */
|
|
50
|
+
| `governed:${string}`
|
|
44
51
|
|
|
45
52
|
export interface AutomationManifest {
|
|
46
53
|
name: string
|
|
@@ -126,7 +133,112 @@ export interface StepApi {
|
|
|
126
133
|
run<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
127
134
|
}
|
|
128
135
|
|
|
129
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* The 13 lifecycle states a governed Action Request can hold.
|
|
138
|
+
*
|
|
139
|
+
* A deliberate copy of a WIRE contract, not shared code — same reasoning as
|
|
140
|
+
* `RegistryEntry` in the runner: this package must install from public npm with
|
|
141
|
+
* a three-package dependency list, and importing the service's own enum would
|
|
142
|
+
* drag drizzle and the schema into an author's `bun install`. The service's
|
|
143
|
+
* `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves
|
|
144
|
+
* the two agree.
|
|
145
|
+
*/
|
|
146
|
+
export type ActionRequestLifecycle =
|
|
147
|
+
| 'received'
|
|
148
|
+
| 'awaiting_approval'
|
|
149
|
+
| 'ready'
|
|
150
|
+
| 'executing'
|
|
151
|
+
| 'finalizing'
|
|
152
|
+
| 'succeeded'
|
|
153
|
+
| 'rejected'
|
|
154
|
+
| 'expired'
|
|
155
|
+
| 'cancelled'
|
|
156
|
+
| 'failed'
|
|
157
|
+
| 'outcome_unknown'
|
|
158
|
+
| 'awaiting_resolution'
|
|
159
|
+
| 'closed_unknown'
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `subjectRef` and `expectedSubjectVersion` are paired deliberately.
|
|
163
|
+
*
|
|
164
|
+
* The plane requires BOTH for an Action over an existing subject and refuses
|
|
165
|
+
* BOTH for a create Action, so independently-optional fields would let an
|
|
166
|
+
* author write a submission that cannot be accepted and only find out at
|
|
167
|
+
* runtime. Which arm applies is the Action's decision, not the caller's — read
|
|
168
|
+
* it off the Action's `subject.mode`.
|
|
169
|
+
*/
|
|
170
|
+
export type ActionSubmission = {
|
|
171
|
+
/** Published Action apiName. Requires a `governed:<apiName>` grant. */
|
|
172
|
+
action: string
|
|
173
|
+
input: Record<string, unknown>
|
|
174
|
+
/** Required when the Action's definition says so. */
|
|
175
|
+
reason?: string
|
|
176
|
+
/**
|
|
177
|
+
* Tells two submissions from the SAME step apart.
|
|
178
|
+
*
|
|
179
|
+
* A step submits once by default. The idempotency key is derived from the run
|
|
180
|
+
* and the step alone, so a submission re-reached by a resumption or by a
|
|
181
|
+
* retried attempt is the SAME key and the plane hands back the original
|
|
182
|
+
* request instead of making a second one. Your own retry loop behaves the
|
|
183
|
+
* same way: a submission that FAILED is not recorded, so submitting again
|
|
184
|
+
* after catching a transport error re-sends and the plane replays.
|
|
185
|
+
*
|
|
186
|
+
* What you cannot do by default is submit twice on purpose. Two submissions
|
|
187
|
+
* the platform cannot tell apart derive one key AND one semantic
|
|
188
|
+
* fingerprint, so the plane would replay the first and answer both calls with
|
|
189
|
+
* the same id — no error, one effect, a green run. Rather than let that
|
|
190
|
+
* happen, the second call is refused before it leaves your process, naming
|
|
191
|
+
* this field.
|
|
192
|
+
*
|
|
193
|
+
* Pass a distinct `submissionKey` per submission to say you meant it — a
|
|
194
|
+
* business identity is the right value, not a counter:
|
|
195
|
+
*
|
|
196
|
+
* ```ts
|
|
197
|
+
* await ctx.step.run('flag', async () => {
|
|
198
|
+
* for (const row of rows) {
|
|
199
|
+
* await ctx.action.submit({
|
|
200
|
+
* action: 'flagForAudit',
|
|
201
|
+
* // Stable for THIS row across every attempt. An array index is not:
|
|
202
|
+
* // if the re-read returns the rows in another order, an index would
|
|
203
|
+
* // bind row B's submission to row A's key.
|
|
204
|
+
* submissionKey: row.id,
|
|
205
|
+
* input: { rowId: row.id },
|
|
206
|
+
* })
|
|
207
|
+
* }
|
|
208
|
+
* })
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* It must be stable across attempts for the same intended submission, which
|
|
212
|
+
* is why the platform cannot derive it for you — only your code knows which
|
|
213
|
+
* of two submissions is "the same one again". An empty string is refused;
|
|
214
|
+
* omit it entirely to mean "this step submits once".
|
|
215
|
+
*/
|
|
216
|
+
submissionKey?: string
|
|
217
|
+
} & (
|
|
218
|
+
| {
|
|
219
|
+
subjectRef: { objectTypeId: string; objectId: string }
|
|
220
|
+
/** The version you believe the subject is at: a submission built from a
|
|
221
|
+
* stale read must lose rather than overwrite. */
|
|
222
|
+
expectedSubjectVersion: string
|
|
223
|
+
}
|
|
224
|
+
| { subjectRef?: never; expectedSubjectVersion?: never }
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
export interface ActionSubmitResult {
|
|
228
|
+
requestId: string
|
|
229
|
+
/**
|
|
230
|
+
* Where the request stopped, NOT whether the effect happened.
|
|
231
|
+
*
|
|
232
|
+
* `ready` means accepted and queued for dispatch. `awaiting_approval` means
|
|
233
|
+
* the Action requires a human and one has not decided yet — a normal return,
|
|
234
|
+
* not an error. Neither is a completed business fact.
|
|
235
|
+
*/
|
|
236
|
+
lifecycle: ActionRequestLifecycle
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* `notify` still arrives with a later slice; `governed` is here.
|
|
241
|
+
*/
|
|
130
242
|
export interface AutomationContext {
|
|
131
243
|
runId: string
|
|
132
244
|
workspaceId: string
|
|
@@ -163,6 +275,41 @@ export interface AutomationContext {
|
|
|
163
275
|
* resumed.
|
|
164
276
|
*/
|
|
165
277
|
step: StepApi
|
|
278
|
+
action: {
|
|
279
|
+
/**
|
|
280
|
+
* Ask the governed write plane to perform one named business change.
|
|
281
|
+
*
|
|
282
|
+
* This is the ONLY way an automation changes a system of record. Your code
|
|
283
|
+
* never holds a write handle: you describe the change, and the plane
|
|
284
|
+
* authorizes it, approves it if the Action says so, dispatches it, confirms
|
|
285
|
+
* it and records it. An Action that declares `approval: required` cannot be
|
|
286
|
+
* talked out of it by the caller.
|
|
287
|
+
*
|
|
288
|
+
* Two rules:
|
|
289
|
+
*
|
|
290
|
+
* 1. **It must be called inside `ctx.step.run`.** Code outside a step
|
|
291
|
+
* re-executes after every step boundary, so a submit sitting there would
|
|
292
|
+
* fire once per boundary. Inside a step it runs once, and the step —
|
|
293
|
+
* identified by the row the service itself issued — is what makes the
|
|
294
|
+
* idempotency key stable across resumption and across a retried run.
|
|
295
|
+
*
|
|
296
|
+
* The service checks this rather than taking your word for it: the
|
|
297
|
+
* submission carries a step row id, and a submission whose id names no
|
|
298
|
+
* open step of this run is refused. What that check cannot do is make a
|
|
299
|
+
* determined bundle behave — your code runs unsandboxed in the same
|
|
300
|
+
* process as the run token, so it could open a step row purely to submit
|
|
301
|
+
* inside it. The bound is that such a step is a real row and shows up in
|
|
302
|
+
* the run trace, not that it is impossible.
|
|
303
|
+
* 2. **It never waits.** It returns as soon as the request is durably
|
|
304
|
+
* accepted. A run has nobody to ask for an approval and ten minutes to
|
|
305
|
+
* live, so blocking on a human is not something this can offer —
|
|
306
|
+
* `awaiting_approval` is a normal return value.
|
|
307
|
+
*
|
|
308
|
+
* The returned `lifecycle` is where the request stopped, not proof of
|
|
309
|
+
* effect. Poll the ledger, or let the Action's Business Event tell you.
|
|
310
|
+
*/
|
|
311
|
+
submit(request: ActionSubmission): Promise<ActionSubmitResult>
|
|
312
|
+
}
|
|
166
313
|
}
|
|
167
314
|
|
|
168
315
|
export interface HttpRequest {
|