@frontera-sdk/functions 1.52.39 → 1.52.41
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 +25 -2
- package/package.json +2 -2
- package/src/define.ts +24 -9
- package/src/errors.ts +82 -0
- package/src/index.ts +3 -1
- package/src/runtime-context.ts +44 -35
- package/src/subject.ts +50 -0
- package/src/test-check.ts +16 -0
- package/src/testing.ts +877 -16
- package/src/types.ts +38 -1
package/README.md
CHANGED
|
@@ -114,8 +114,31 @@ reply is a test that passes while asserting nothing. An object type you did not
|
|
|
114
114
|
stub returns no rows, because that is a real answer and usually the branch worth
|
|
115
115
|
testing.
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
after every step
|
|
117
|
+
By default the handler runs once, straight through. In production it is
|
|
118
|
+
re-entered after every step: code outside a step runs again, and each completed
|
|
119
|
+
step hands back its stored result without running. Pass `replay: true` to run it
|
|
120
|
+
that way in a test:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const replayed = createTestContext({ replay: true, grants: ['blueprint:read'] })
|
|
124
|
+
await handler.handler(replayed.ctx)
|
|
125
|
+
expect(replayed.steps).toEqual(['load', 'nothing-to-do']) // each step once
|
|
126
|
+
expect(replayed.executions).toBeGreaterThan(1) // the handler started again
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
With `replay`, the handler starts again after each step that completes for the
|
|
130
|
+
first time, and after each `ctx.step.sleep`, each `ctx.agent(…).run` outside a
|
|
131
|
+
step and each `ctx.person.confirm`. A completed step returns its stored result as
|
|
132
|
+
JSON, as the platform stores it (a `Date` comes back as a string), and a failed
|
|
133
|
+
step throws its error again. So a read outside a step that a later step changes
|
|
134
|
+
takes the other branch here, as it does on the platform. `calls` records every
|
|
135
|
+
pass's reads, `steps` lists each step once in the order it first ran, and
|
|
136
|
+
`executions` counts how many times the handler started (`1` without `replay`).
|
|
137
|
+
A `defineFunction` handler replays through `handler.handler(ctx)`; for a plain
|
|
138
|
+
handler function, use the returned `run(handler)`. A handler that starts a new
|
|
139
|
+
step on every pass, such as one whose step name includes the time, fails the
|
|
140
|
+
test instead of running forever. `replay` is off by default, so a test that
|
|
141
|
+
counts the calls made outside its steps keeps its numbers.
|
|
119
142
|
|
|
120
143
|
## License
|
|
121
144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.41",
|
|
4
4
|
"description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"typescript": "^5.9.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@frontera-sdk/blueprint": "1.52.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.52.41",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/define.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AutomationTrigger,
|
|
6
6
|
InputsSchema,
|
|
7
7
|
} from './types'
|
|
8
|
+
import { TEST_CONTEXT_CHECK, TEST_CONTEXT_RUN } from './test-check'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Declare a Function: a manifest and the handler the runner executes.
|
|
@@ -38,16 +39,30 @@ export function defineFunction(
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
const frozen = Object.freeze({
|
|
43
|
+
...manifest,
|
|
44
|
+
trigger: Object.freeze(trigger),
|
|
45
|
+
grants: Object.freeze([...(manifest.grants ?? [])]),
|
|
46
|
+
...(inputs ? { inputs } : {}),
|
|
47
|
+
concurrency: manifest.concurrency ?? 1,
|
|
48
|
+
retries: manifest.retries ?? 0,
|
|
49
|
+
})
|
|
50
|
+
|
|
41
51
|
return Object.freeze({
|
|
42
|
-
manifest:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
manifest: frozen,
|
|
53
|
+
// A unit test's ctx (`createTestContext`) checks the manifest and the
|
|
54
|
+
// input the way the platform does before the handler runs, so a test
|
|
55
|
+
// refuses what a deploy or a run would. Any other ctx passes straight
|
|
56
|
+
// through: production never carries the check.
|
|
57
|
+
// With `replay`, it also runs the handler as the platform does: again from
|
|
58
|
+
// the top after each step, until it returns.
|
|
59
|
+
handler: (async (ctx) => {
|
|
60
|
+
const check = (ctx as unknown as Record<symbol, unknown> | null)?.[TEST_CONTEXT_CHECK]
|
|
61
|
+
if (typeof check === 'function') (check as (m: AutomationManifest) => void)(frozen)
|
|
62
|
+
const run = (ctx as unknown as Record<symbol, unknown> | null)?.[TEST_CONTEXT_RUN]
|
|
63
|
+
if (typeof run === 'function') return (run as (h: AutomationHandler) => ReturnType<AutomationHandler>)(handler)
|
|
64
|
+
return handler(ctx)
|
|
65
|
+
}) as AutomationHandler,
|
|
51
66
|
})
|
|
52
67
|
}
|
|
53
68
|
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `ctx` call the platform refused.
|
|
3
|
+
*
|
|
4
|
+
* The message is the one a refused call has always thrown
|
|
5
|
+
* (`ctx.action.submit → 409 Record "SRV-1" already has an open … request`), so
|
|
6
|
+
* a log line or a trace reads the same. What it adds is what a handler branches
|
|
7
|
+
* on: the platform's error `code` (`ACTION_REQUEST_ALREADY_OPEN`), the HTTP
|
|
8
|
+
* `status` and the refusal's `details`, the same code a workflow step reads as
|
|
9
|
+
* `$steps.<id>.error.code`. Matching on the wording breaks the day it changes;
|
|
10
|
+
* the code does not.
|
|
11
|
+
*
|
|
12
|
+
* Codes are not enumerated: the platform adds new ones, and an SDK that refused
|
|
13
|
+
* unknown codes would need a release each time.
|
|
14
|
+
*/
|
|
15
|
+
export class FronteraCallError extends Error {
|
|
16
|
+
/** The platform's error code, e.g. `ACTION_REQUEST_ALREADY_OPEN`. */
|
|
17
|
+
readonly code: string
|
|
18
|
+
/** The HTTP status the platform answered with. */
|
|
19
|
+
readonly status: number
|
|
20
|
+
/** What the platform said beside the message, e.g. `{ openRequest: { requestId, lifecycle } }`; absent when it said nothing more. */
|
|
21
|
+
readonly details?: unknown
|
|
22
|
+
/** The call that was refused, e.g. `ctx.action.submit`. */
|
|
23
|
+
readonly call: string
|
|
24
|
+
|
|
25
|
+
constructor(call: string, status: number, message: string, options: { code?: string; details?: unknown } = {}) {
|
|
26
|
+
super(`${call} → ${status} ${message}`)
|
|
27
|
+
this.name = 'FronteraCallError'
|
|
28
|
+
this.call = call
|
|
29
|
+
this.status = status
|
|
30
|
+
this.code = options.code ?? codeForStatus(status)
|
|
31
|
+
if (options.details !== undefined) this.details = options.details
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The code of a refusal whose body names none: a proxy's 502, say. */
|
|
36
|
+
function codeForStatus(status: number): string {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case 400: return 'BAD_REQUEST'
|
|
39
|
+
case 401: return 'UNAUTHORIZED'
|
|
40
|
+
case 403: return 'FORBIDDEN'
|
|
41
|
+
case 404: return 'NOT_FOUND'
|
|
42
|
+
case 409: return 'CONFLICT'
|
|
43
|
+
case 410: return 'GONE'
|
|
44
|
+
case 429: return 'RATE_LIMITED'
|
|
45
|
+
case 503: return 'SERVICE_UNAVAILABLE'
|
|
46
|
+
default: return status >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_FAILED'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `err` is a refused `ctx` call, optionally with this code.
|
|
52
|
+
*
|
|
53
|
+
* Reads the fields rather than `instanceof`, so a refusal thrown by another
|
|
54
|
+
* copy of the SDK (a test helper's, a bundled one) still matches.
|
|
55
|
+
*/
|
|
56
|
+
export function isFronteraCallError(err: unknown, code?: string): err is FronteraCallError {
|
|
57
|
+
if (!err || typeof err !== 'object') return false
|
|
58
|
+
const e = err as { name?: unknown; code?: unknown; status?: unknown }
|
|
59
|
+
if (e.name !== 'FronteraCallError' || typeof e.code !== 'string' || typeof e.status !== 'number') return false
|
|
60
|
+
return code === undefined || e.code === code
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The refusal in a response body: the platform answers `{ error, message, code, details }`.
|
|
65
|
+
* A body that is not one of ours gives its text as the message, because an
|
|
66
|
+
* empty message would be worse than a noisy one.
|
|
67
|
+
*/
|
|
68
|
+
export function refusalOf(body: string): { message: string; code?: string; details?: unknown } {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(body) as { message?: unknown; code?: unknown; details?: unknown }
|
|
71
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string' && parsed.message) {
|
|
72
|
+
return {
|
|
73
|
+
message: parsed.message,
|
|
74
|
+
...(typeof parsed.code === 'string' && parsed.code ? { code: parsed.code } : {}),
|
|
75
|
+
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Not JSON. Fall through to the body.
|
|
80
|
+
}
|
|
81
|
+
return { message: body }
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,9 @@ export {
|
|
|
9
9
|
missingGrantMessage,
|
|
10
10
|
submitOutsideStepMessage,
|
|
11
11
|
} from './messages'
|
|
12
|
-
export {
|
|
12
|
+
export { FronteraCallError, isFronteraCallError } from './errors'
|
|
13
|
+
export { actionRefused, createTestContext } from './testing'
|
|
14
|
+
export { subjectOf } from './subject'
|
|
13
15
|
export type { TestCall, TestContext, TestContextOptions } from './testing'
|
|
14
16
|
export {
|
|
15
17
|
DEFAULT_MAX_FILES,
|
package/src/runtime-context.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
personConfirmTimeoutRefusal,
|
|
32
32
|
signedUrlRemovedMessage,
|
|
33
33
|
} from './messages'
|
|
34
|
+
import { FronteraCallError, refusalOf } from './errors'
|
|
34
35
|
import type {
|
|
35
36
|
ActionSubmission,
|
|
36
37
|
ActionSubmitResult,
|
|
@@ -77,7 +78,7 @@ function jsonSize(value: unknown): number | null {
|
|
|
77
78
|
* escapes: `\u0000 cannot be converted to text`, and `Unicode low surrogate
|
|
78
79
|
* must follow a high surrogate`. Both verified against the repo's own Postgres.
|
|
79
80
|
*/
|
|
80
|
-
const NUL =
|
|
81
|
+
const NUL = new RegExp(String.fromCharCode(0), 'g')
|
|
81
82
|
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
|
|
82
83
|
|
|
83
84
|
/**
|
|
@@ -446,7 +447,17 @@ type AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked'
|
|
|
446
447
|
* check, so it must come from inside the step, never from a replay's clock.
|
|
447
448
|
* `fingerprint` and `slug` identify the call it was made for — see
|
|
448
449
|
* `agentFingerprint`. Absent on a start memoized before they existed. */
|
|
449
|
-
|
|
450
|
+
/** A refusal as a step memoizes it: the error's fields, since a step's value must be plain data. */
|
|
451
|
+
type Refusal = { call: string; status: number; message: string; code: string; details?: unknown }
|
|
452
|
+
const memoizedRefusal = (err: FronteraCallError): Refusal => ({
|
|
453
|
+
call: err.call,
|
|
454
|
+
status: err.status,
|
|
455
|
+
message: err.message.slice(`${err.call} → ${err.status} `.length),
|
|
456
|
+
code: err.code,
|
|
457
|
+
...(err.details !== undefined ? { details: err.details } : {}),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
type AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string; refusal?: Refusal }) & {
|
|
450
461
|
fingerprint?: string
|
|
451
462
|
slug?: string
|
|
452
463
|
}
|
|
@@ -467,7 +478,7 @@ function agentFingerprint(slug: string, prompt: string, options: AgentRunOptions
|
|
|
467
478
|
* it, so the loop's decisions replay identically. */
|
|
468
479
|
type AgentRead =
|
|
469
480
|
| { done: true; text: string }
|
|
470
|
-
| { done: true; error: string }
|
|
481
|
+
| { done: true; error: string; refusal?: Refusal }
|
|
471
482
|
| { done: false; now: number }
|
|
472
483
|
|
|
473
484
|
interface Deps {
|
|
@@ -693,17 +704,17 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
693
704
|
* for and cannot act on. This unwraps it and falls back to the raw body when
|
|
694
705
|
* the response is not one of ours (a proxy 502, say), because an empty message
|
|
695
706
|
* would be worse than a noisy one.
|
|
707
|
+
*
|
|
708
|
+
* Thrown as a `FronteraCallError`, which carries the platform's `code`,
|
|
709
|
+
* `status` and `details`, so a handler branches on
|
|
710
|
+
* `err.code === 'ACTION_REQUEST_ALREADY_OPEN'` instead of the wording.
|
|
696
711
|
*/
|
|
697
|
-
const
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
const parsed = JSON.parse(body) as { message?: unknown }
|
|
701
|
-
if (typeof parsed.message === 'string' && parsed.message) return parsed.message
|
|
702
|
-
} catch {
|
|
703
|
-
// Not JSON. Fall through to the body.
|
|
704
|
-
}
|
|
705
|
-
return body
|
|
712
|
+
const refused = async (call: string, res: Response): Promise<FronteraCallError> => {
|
|
713
|
+
const r = refusalOf(await res.text())
|
|
714
|
+
return new FronteraCallError(call, res.status, r.message, r)
|
|
706
715
|
}
|
|
716
|
+
/** A memoized refusal thrown again on a replay, as the error it was. */
|
|
717
|
+
const rebuilt = (r: Refusal) => new FronteraCallError(r.call, r.status, r.message, r)
|
|
707
718
|
|
|
708
719
|
/**
|
|
709
720
|
* Record one ctx call as a row: timed, and with a bounded summary of what it
|
|
@@ -908,15 +919,15 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
908
919
|
})
|
|
909
920
|
// A refusal before the turn starts (token, grant, budget, files) is
|
|
910
921
|
// still a plain non-2xx, on either shape.
|
|
911
|
-
if (!res.ok) throw
|
|
922
|
+
if (!res.ok) throw await refused(`agent ${slug}`, res)
|
|
912
923
|
// The kept-alive body is leading spaces and one JSON value; `JSON.parse`
|
|
913
924
|
// skips the whitespace. The plain body is `{ data }`.
|
|
914
925
|
const body = JSON.parse(await res.text()) as
|
|
915
926
|
| { ok: true; data: { text: string } }
|
|
916
|
-
| { ok: false; status: number; message: string }
|
|
927
|
+
| { ok: false; status: number; message: string; code?: string; details?: unknown }
|
|
917
928
|
| { ok?: undefined; data: { text: string } }
|
|
918
929
|
// The same words the plain route's non-2xx would have produced.
|
|
919
|
-
if (body.ok === false) throw new
|
|
930
|
+
if (body.ok === false) throw new FronteraCallError(`agent ${slug}`, body.status, body.message, body)
|
|
920
931
|
return body.data
|
|
921
932
|
},
|
|
922
933
|
agentDetail(slug, prompt, options),
|
|
@@ -955,15 +966,15 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
955
966
|
if (stepScope.getStore()) {
|
|
956
967
|
throw new Error('ctx.person.confirm must be called at the top level of the handler, not inside ctx.step.run')
|
|
957
968
|
}
|
|
958
|
-
const
|
|
959
|
-
if (
|
|
969
|
+
const badTimeout = personConfirmTimeoutRefusal(request.timeoutMinutes)
|
|
970
|
+
if (badTimeout) throw new Error(badTimeout)
|
|
960
971
|
const base = `person.confirm:${confirmCalls++}`
|
|
961
972
|
const opened = (await deps.step.run(`${base}:open`, async () => {
|
|
962
973
|
const res = await scoped('/ctx/person-confirmation', {
|
|
963
974
|
method: 'POST',
|
|
964
975
|
body: JSON.stringify({ key: base, ...request }),
|
|
965
976
|
})
|
|
966
|
-
if (!res.ok) throw
|
|
977
|
+
if (!res.ok) throw await refused('person.confirm', res)
|
|
967
978
|
return ((await res.json()) as { data: { id?: string; status: string; expiresAt?: string } }).data
|
|
968
979
|
})) as { id?: string; status: string; expiresAt?: string }
|
|
969
980
|
if (opened.status !== 'pending' || !opened.id) return opened.status as PersonConfirmResult
|
|
@@ -971,7 +982,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
971
982
|
const read = (await deps.step.run(`${base}:read:${i}`, async () => {
|
|
972
983
|
const res = await scoped(`/ctx/person-confirmation/${opened.id}`, { method: 'GET' }).catch(() => null)
|
|
973
984
|
// Refused permanently: waiting on it would only spend the run's steps.
|
|
974
|
-
if (res && res.status >= 400 && res.status < 500) throw
|
|
985
|
+
if (res && res.status >= 400 && res.status < 500) throw await refused('person.confirm', res)
|
|
975
986
|
// Unreachable or failing for now: read again after the next wait.
|
|
976
987
|
const status = res?.ok ? ((await res.json()) as { data: { status: string } }).data.status : 'pending'
|
|
977
988
|
return { status, now: Date.now() }
|
|
@@ -1036,9 +1047,10 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1036
1047
|
// A refusal is an answer, not a transient fault: returned, so the
|
|
1037
1048
|
// platform does not retry a call the service has already turned down,
|
|
1038
1049
|
// and thrown below in the words the synchronous path uses.
|
|
1039
|
-
const
|
|
1050
|
+
const err = await refused(`agent ${slug}`, res)
|
|
1051
|
+
const message = err.message
|
|
1040
1052
|
await recordStep({ kind: 'agent', label, status: 'error', detail: { message }, durationMs: Date.now() - startedAt })
|
|
1041
|
-
return { ok: false, message, fingerprint, slug }
|
|
1053
|
+
return { ok: false, message, refusal: memoizedRefusal(err), fingerprint, slug }
|
|
1042
1054
|
}
|
|
1043
1055
|
const { invocationId } = ((await res.json()) as { data: { invocationId: string } }).data
|
|
1044
1056
|
return { ok: true, invocationId, startedAt, fingerprint, slug }
|
|
@@ -1050,13 +1062,13 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1050
1062
|
if (start.fingerprint !== undefined && start.fingerprint !== fingerprint) {
|
|
1051
1063
|
throw new Error(agentReplayMismatchMessage(slug, start.slug ?? slug, callNumber))
|
|
1052
1064
|
}
|
|
1053
|
-
if (!start.ok) throw new Error(start.message)
|
|
1065
|
+
if (!start.ok) throw start.refusal ? rebuilt(start.refusal) : new Error(start.message)
|
|
1054
1066
|
|
|
1055
1067
|
const giveUpAt = start.startedAt + waitMs + AGENT_SETTLE_GRACE_MS
|
|
1056
1068
|
for (let i = 0; ; i++) {
|
|
1057
1069
|
const read = (await deps.step.run(`${base}:result:${i}`, async (): Promise<AgentRead> => {
|
|
1058
1070
|
const now = Date.now()
|
|
1059
|
-
const settle = async (verdict: { text: string } | { error: string }): Promise<AgentRead> => {
|
|
1071
|
+
const settle = async (verdict: { text: string } | { error: string; refusal?: Refusal }): Promise<AgentRead> => {
|
|
1060
1072
|
await recordStep(
|
|
1061
1073
|
'text' in verdict
|
|
1062
1074
|
? { kind: 'agent', label, status: 'ok', durationMs: now - start.startedAt, detail: summarize(detailOf, verdict) }
|
|
@@ -1086,14 +1098,15 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1086
1098
|
} else if (res && res.status < 500) {
|
|
1087
1099
|
// Not transient: the run's token is gone, or the turn is not this
|
|
1088
1100
|
// run's. Waiting longer cannot change the answer.
|
|
1089
|
-
|
|
1101
|
+
const err = await refused(`agent ${slug}`, res)
|
|
1102
|
+
return settle({ error: err.message, refusal: memoizedRefusal(err) })
|
|
1090
1103
|
}
|
|
1091
1104
|
if (now >= giveUpAt) return settle({ error: `agent ${slug} → 400 ${agentTimeoutMessage(slug, waitMs)}` })
|
|
1092
1105
|
return { done: false, now }
|
|
1093
1106
|
})) as AgentRead
|
|
1094
1107
|
|
|
1095
1108
|
if (read.done) {
|
|
1096
|
-
if ('error' in read) throw new Error(read.error)
|
|
1109
|
+
if ('error' in read) throw read.refusal ? rebuilt(read.refusal) : new Error(read.error)
|
|
1097
1110
|
return { text: read.text }
|
|
1098
1111
|
}
|
|
1099
1112
|
const sliceMs = Math.max(1_000, Math.min(AGENT_WAIT_SLICE_MS, giveUpAt - read.now))
|
|
@@ -1149,7 +1162,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1149
1162
|
method: 'POST',
|
|
1150
1163
|
body: JSON.stringify({ fileId: ref.fileId }),
|
|
1151
1164
|
})
|
|
1152
|
-
if (!res.ok) throw
|
|
1165
|
+
if (!res.ok) throw await refused(`file ${ref.fileId}`, res)
|
|
1153
1166
|
const file = ((await res.json()) as {
|
|
1154
1167
|
data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
|
|
1155
1168
|
}).data.file
|
|
@@ -1200,11 +1213,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1200
1213
|
method: 'POST',
|
|
1201
1214
|
body: JSON.stringify({ install, capability, input: input ?? {} }),
|
|
1202
1215
|
})
|
|
1203
|
-
if (!res.ok) {
|
|
1204
|
-
throw new Error(
|
|
1205
|
-
`ctx.plugin("${install}").call("${capability}") → ${res.status} ${await refusal(res)}`,
|
|
1206
|
-
)
|
|
1207
|
-
}
|
|
1216
|
+
if (!res.ok) throw await refused(`ctx.plugin("${install}").call("${capability}")`, res)
|
|
1208
1217
|
// Two levels: the service envelope's data, then PluginCallResult's own data.
|
|
1209
1218
|
return ((await res.json()) as { data: PluginCallResult<T> }).data
|
|
1210
1219
|
},
|
|
@@ -1241,7 +1250,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1241
1250
|
method: 'POST',
|
|
1242
1251
|
body: JSON.stringify(req),
|
|
1243
1252
|
})
|
|
1244
|
-
if (!res.ok) throw
|
|
1253
|
+
if (!res.ok) throw await refused('ctx.http', res)
|
|
1245
1254
|
return ((await res.json()) as { data: HttpResponse }).data
|
|
1246
1255
|
},
|
|
1247
1256
|
// The upstream status, which is the fact this call is read for — an API
|
|
@@ -1328,7 +1337,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1328
1337
|
// that second call must be allowed through to the plane, where the
|
|
1329
1338
|
// key — unchanged — makes it a replay rather than a second effect.
|
|
1330
1339
|
scope.submitted.delete(submissionIdentity)
|
|
1331
|
-
throw
|
|
1340
|
+
throw await refused('ctx.action.submit', res)
|
|
1332
1341
|
}
|
|
1333
1342
|
try {
|
|
1334
1343
|
return ((await res.json()) as { data: ActionSubmitResult }).data
|
|
@@ -1367,7 +1376,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1367
1376
|
method: 'POST',
|
|
1368
1377
|
body: JSON.stringify({ objectType, ...(options ?? {}) }),
|
|
1369
1378
|
})
|
|
1370
|
-
if (!res.ok) throw
|
|
1379
|
+
if (!res.ok) throw await refused('blueprint query', res)
|
|
1371
1380
|
// `T` is an author-supplied shape for rows the warehouse returns
|
|
1372
1381
|
// untyped. The cast is the honest boundary: nothing here can verify
|
|
1373
1382
|
// it, and pretending otherwise would just move the lie deeper.
|
|
@@ -1397,7 +1406,7 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
1397
1406
|
// PLATFORM recorded as this run's trigger, so there is nothing an
|
|
1398
1407
|
// author could name to read someone else's.
|
|
1399
1408
|
const res = await scoped('/ctx/conversation-transcript', { method: 'POST', body: '{}' })
|
|
1400
|
-
if (!res.ok) throw
|
|
1409
|
+
if (!res.ok) throw await refused('ctx.conversation.transcript', res)
|
|
1401
1410
|
const transcript = brandTranscript(
|
|
1402
1411
|
((await res.json()) as { data: { transcript: ConversationTranscript | null } }).data.transcript,
|
|
1403
1412
|
)
|
package/src/subject.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BlueprintQueryResult } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The subject half of an Action submission, for one row of a query.
|
|
5
|
+
*
|
|
6
|
+
* An Action that changes an existing record needs `subjectRef` (which record)
|
|
7
|
+
* and `expectedSubjectVersion` (the version you read it at). Both come from the
|
|
8
|
+
* same read, so build them from it:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const found = await ctx.blueprint.query('WorkOrder', {
|
|
12
|
+
* where: { property: 'id', op: 'eq', value: ctx.input.work_order_id },
|
|
13
|
+
* limit: 1,
|
|
14
|
+
* })
|
|
15
|
+
* const order = found.rows[0]
|
|
16
|
+
* if (!order) return { submitted: false }
|
|
17
|
+
* await ctx.step.run('assign', () => ctx.action.submit({
|
|
18
|
+
* action: 'assignTechnician',
|
|
19
|
+
* ...subjectOf(found, order, order.id),
|
|
20
|
+
* input: { technician: ctx.input.technician_id },
|
|
21
|
+
* }))
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* `objectId` is the row's primary key value. Throws, naming the reason, when
|
|
25
|
+
* the row carries no version: its type has no editable properties (so no
|
|
26
|
+
* Action can change it), or the row changed while it was read (read again).
|
|
27
|
+
*/
|
|
28
|
+
export function subjectOf(
|
|
29
|
+
result: Pick<BlueprintQueryResult<unknown>, 'objectTypeId'>,
|
|
30
|
+
row: unknown,
|
|
31
|
+
objectId: string | number,
|
|
32
|
+
): { subjectRef: { objectTypeId: string; objectId: string }; expectedSubjectVersion: string } {
|
|
33
|
+
if (!result.objectTypeId) {
|
|
34
|
+
throw new Error('subjectOf: pass the query result the row came from — it carries the object type id.')
|
|
35
|
+
}
|
|
36
|
+
const meta = row && typeof row === 'object' ? (row as { _meta?: unknown })._meta : undefined
|
|
37
|
+
const version = meta && typeof meta === 'object'
|
|
38
|
+
? (meta as { recordVersion?: unknown }).recordVersion
|
|
39
|
+
: undefined
|
|
40
|
+
if (typeof version !== 'number') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'subjectOf: this row has no record version. Either its object type has no editable properties, '
|
|
43
|
+
+ 'so no Action can change it, or the record changed while it was read — query it again.',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
subjectRef: { objectTypeId: result.objectTypeId, objectId: String(objectId) },
|
|
48
|
+
expectedSubjectVersion: String(version),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a unit test's ctx keeps the check `defineFunction`'s handler runs
|
|
3
|
+
* before the author's code: the manifest's deploy rules and the run's input
|
|
4
|
+
* rules, as the platform applies them. A registered symbol, so a vendored
|
|
5
|
+
* copy of the SDK and the package find the same one; never enumerable, so it
|
|
6
|
+
* is not part of what the handler sees.
|
|
7
|
+
*/
|
|
8
|
+
export const TEST_CONTEXT_CHECK: unique symbol = Symbol.for('frontera.functions.test-context-check') as never
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a unit test's ctx keeps how to run a handler (`createTestContext`'s
|
|
12
|
+
* `run`): once, or with `replay`, again from the top after each step as the
|
|
13
|
+
* platform does. `defineFunction`'s handler hands its code to it, so
|
|
14
|
+
* `fn.handler(ctx)` replays without the test changing how it calls it.
|
|
15
|
+
*/
|
|
16
|
+
export const TEST_CONTEXT_RUN: unique symbol = Symbol.for('frontera.functions.test-context-run') as never
|