@decidio/sdk 0.10.0 → 0.12.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/CHANGELOG.md +113 -3
- package/README.md +18 -9
- package/dist/adapters/_gate.d.ts +18 -1
- package/dist/adapters/_gate.js +34 -27
- package/dist/adapters/langgraph.js +1 -1
- package/dist/adapters/openai.d.ts +11 -4
- package/dist/adapters/openai.js +12 -4
- package/dist/adapters/temporal.d.ts +29 -2
- package/dist/adapters/temporal.js +42 -6
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +623 -96
- package/dist/demo-gate.js +14 -0
- package/dist/errors.js +3 -1
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +3 -2
- package/dist/index.d.ts +39 -7
- package/dist/index.js +103 -20
- package/dist/inngest.d.ts +5 -0
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +52 -2
- package/dist/resume.js +74 -5
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +6 -0
- package/openapi.yaml +158 -2
- package/package.json +2 -2
package/dist/demo-gate.js
CHANGED
|
@@ -41,6 +41,7 @@ export async function startDemoGate(opts = {}) {
|
|
|
41
41
|
let relayCode = null;
|
|
42
42
|
let relayOk = null;
|
|
43
43
|
let committed = null;
|
|
44
|
+
let claimedBy = null;
|
|
44
45
|
const relayFetch = async (path, init = {}) => {
|
|
45
46
|
if (!relayBase)
|
|
46
47
|
return null;
|
|
@@ -112,6 +113,7 @@ export async function startDemoGate(opts = {}) {
|
|
|
112
113
|
return send({ ok: true, changed: true });
|
|
113
114
|
if (path === "/agent/authorize") {
|
|
114
115
|
verdict = null;
|
|
116
|
+
claimedBy = null;
|
|
115
117
|
committed = typeof body?.requestCommitment === "string" && /^[0-9a-f]{64}$/.test(body.requestCommitment) ? body.requestCommitment : null;
|
|
116
118
|
return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1", requestCommitment: committed });
|
|
117
119
|
}
|
|
@@ -128,6 +130,18 @@ export async function startDemoGate(opts = {}) {
|
|
|
128
130
|
return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting", requestCommitment: committed });
|
|
129
131
|
return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver", requestCommitment: committed } : { state: verdict === "approve" ? "approved" : "pending", requestCommitment: committed });
|
|
130
132
|
}
|
|
133
|
+
if (path.startsWith("/agent/claim/")) {
|
|
134
|
+
const key = typeof body?.claimKey === "string" && /^[A-Za-z0-9._:-]{16,128}$/.test(body.claimKey) ? body.claimKey : null;
|
|
135
|
+
if (!key)
|
|
136
|
+
return send({ code: "VALIDATION", message: "claimKey must be 16-128 characters of letters, digits, dot, colon, underscore or dash" }, 400);
|
|
137
|
+
if (verdict !== "approve")
|
|
138
|
+
return send({ code: "NOT_CLAIMABLE", decisionId: DECISION_ID, state: verdict === "reject" ? "rejected" : "pending", message: "only a person's approval is collected here" }, 409);
|
|
139
|
+
if (claimedBy && claimedBy !== key)
|
|
140
|
+
return send({ code: "ALREADY_USED", decisionId: DECISION_ID, state: "approved", message: "another caller already collected this approval - ask again for a new one" }, 409);
|
|
141
|
+
const again = claimedBy === key;
|
|
142
|
+
claimedBy = key;
|
|
143
|
+
return send({ ok: true, claimed: true, decisionId: DECISION_ID, again });
|
|
144
|
+
}
|
|
131
145
|
if (path.startsWith("/agent/withdraw/")) {
|
|
132
146
|
if (verdict)
|
|
133
147
|
return send({ code: "NOT_OPEN", state: verdict === "approve" ? "approved" : "rejected", decisionId: DECISION_ID }, 409);
|
package/dist/errors.js
CHANGED
|
@@ -30,7 +30,9 @@ export class DecidioHttpError extends Error {
|
|
|
30
30
|
? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
|
|
31
31
|
: /\/agent\/status/.test(endpoint)
|
|
32
32
|
? ` — this looks transient; polling again is safe (a status read changes nothing).`
|
|
33
|
-
:
|
|
33
|
+
: /\/agent\/authorize/.test(endpoint)
|
|
34
|
+
? ` — this looks transient. This SDK already retried it with the same request key (a request still waiting is answered with the one already waiting); retrying is safe and can never double-execute.`
|
|
35
|
+
: ` — this looks transient; retrying is safe and can never double-execute.`)
|
|
34
36
|
: ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
|
|
35
37
|
sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
|
|
36
38
|
this.status = status;
|
package/dist/guard.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface GuardFacadeConfig {
|
|
|
17
17
|
workspaceId?: string;
|
|
18
18
|
/** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
|
|
19
19
|
appUrl?: string;
|
|
20
|
+
/** The per-call id behind the default request key (0.12.0) - see GuardConfig.invocationId. Leave it unset. */
|
|
21
|
+
invocationId?: () => string;
|
|
20
22
|
fetchImpl?: typeof fetch;
|
|
21
23
|
}
|
|
22
24
|
export interface ProtectOptions {
|
|
@@ -53,6 +55,9 @@ export interface ProtectOptions {
|
|
|
53
55
|
* success indication, so instead of forcing your API into magic field names:
|
|
54
56
|
* `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
|
|
55
57
|
evidence?: (result: any) => unknown;
|
|
58
|
+
/** YOUR OWN REQUEST KEY for a call, from its arguments (0.12.0) - e.g. `(inv) => inv.id` - so the same call asked again,
|
|
59
|
+
* even from a new process, is the same request. Without it each call sends a fresh random key. See withApproval. */
|
|
60
|
+
requestKey?: (...args: any[]) => string;
|
|
56
61
|
}
|
|
57
62
|
export declare class Guard {
|
|
58
63
|
config: GuardFacadeConfig;
|
package/dist/guard.js
CHANGED
|
@@ -60,6 +60,7 @@ export class Guard {
|
|
|
60
60
|
const base = {
|
|
61
61
|
apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
|
|
62
62
|
sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
|
|
63
|
+
...(c.invocationId ? { invocationId: c.invocationId } : {}),
|
|
63
64
|
pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
|
|
64
65
|
};
|
|
65
66
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
@@ -68,12 +69,12 @@ export class Guard {
|
|
|
68
69
|
`(pollTimeoutMs), and when it does, this process stops waiting and the request is withdrawn in Decidio: ` +
|
|
69
70
|
`a later approval does nothing. Only safe for short, watched approvals — omit mode:"blocking" for ` +
|
|
70
71
|
`durable async resume on real (minutes-to-days) approvals.`);
|
|
71
|
-
return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
|
|
72
|
+
return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
|
|
72
73
|
}
|
|
73
74
|
const resume = this.ensureResume();
|
|
74
75
|
if (opts?.action)
|
|
75
76
|
resume.register(opts.action, fn, opts?.evidence, opts?.withContext);
|
|
76
|
-
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
|
|
77
|
+
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
|
|
77
78
|
}
|
|
78
79
|
protectBestEffort(fn, describe, opts) {
|
|
79
80
|
const wrapped = this.protect(fn, describe, opts);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { ResumeController, ApprovalCheck } from "./resume.js";
|
|
1
|
+
import type { ResumeController, ApprovalCheck, ClaimOutcome } from "./resume.js";
|
|
2
2
|
import { type CommittedRequest } from "./signing.js";
|
|
3
3
|
import { type EvidenceOutcome } from "./evidence.js";
|
|
4
|
-
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
|
|
5
|
-
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus } from "./resume.js";
|
|
4
|
+
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
|
|
5
|
+
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus, ClaimOutcome, ClaimCode } from "./resume.js";
|
|
6
6
|
export { guard, Guard } from "./guard.js";
|
|
7
7
|
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
8
8
|
export { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
9
|
-
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
|
|
9
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
|
|
10
10
|
export type { BindingMaterial, CommittedRequest } from "./signing.js";
|
|
11
11
|
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
12
12
|
export type { JsonValue, JsonPrimitive } from "./canonical.js";
|
|
@@ -61,6 +61,11 @@ export interface GuardConfig {
|
|
|
61
61
|
* Sent on /agent/authorize so the decision carries it. Falls back to
|
|
62
62
|
* resume.resumeUrl. Omit when using the poll worker. */
|
|
63
63
|
resumeUrl?: string;
|
|
64
|
+
/** Where the per-call id behind the DEFAULT REQUEST KEY comes from (0.12.0, 11f). Every call that sends a request
|
|
65
|
+
* commitment also sends a request key derived from a fresh random id and that commitment, so this SDK's own retry of
|
|
66
|
+
* the authorize is the same request. Leave it unset: a fixed id makes every call the SAME request. It exists so a
|
|
67
|
+
* test or a conformance harness can pin the bytes it compares. */
|
|
68
|
+
invocationId?: () => string;
|
|
64
69
|
fetchImpl?: typeof fetch;
|
|
65
70
|
}
|
|
66
71
|
export interface ApprovalContext {
|
|
@@ -115,11 +120,15 @@ export declare function coreRequest(ctx: {
|
|
|
115
120
|
export declare class DecidioTimeoutError extends Error {
|
|
116
121
|
decisionId: string;
|
|
117
122
|
withdrawn: boolean;
|
|
123
|
+
shared: boolean;
|
|
118
124
|
/** `withdrawn` (0.6.4+): true when the SDK told Decidio it gave up and the request was closed as
|
|
119
125
|
* WITHDRAWN (the queue no longer carries it; a later approval does nothing); false when that
|
|
120
126
|
* best-effort call failed, so the request may still be open in Decidio. Nothing was executed
|
|
121
|
-
* locally either way.
|
|
122
|
-
|
|
127
|
+
* locally either way.
|
|
128
|
+
* `shared` (0.12.0): true when the request was left open on purpose because it may be shared - the authorize
|
|
129
|
+
* answered an earlier request (`idempotentReplay`) or joined one another caller is waiting on (`openTwin`), or the
|
|
130
|
+
* withdraw was refused because another caller joined it (409 SHARED_REQUEST). A person still decides it. */
|
|
131
|
+
constructor(decisionId: string, withdrawn?: boolean, shared?: boolean);
|
|
123
132
|
}
|
|
124
133
|
/** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
|
|
125
134
|
* operation, the underlying code, and the safety state a caller cares about most: whether
|
|
@@ -132,7 +141,10 @@ export declare class DecidioRateLimitError extends Error {
|
|
|
132
141
|
limit: number | undefined;
|
|
133
142
|
remaining: number | undefined;
|
|
134
143
|
resetAt: string | undefined;
|
|
135
|
-
|
|
144
|
+
code?: string | undefined;
|
|
145
|
+
/** `code` (0.12.0): the server's own code - `AGENT_RATE` for the workspace's hourly allowance, which is never retried;
|
|
146
|
+
* a 429 without it is a transport throttle the SDK retries before it gives up. */
|
|
147
|
+
constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string, code?: string | undefined);
|
|
136
148
|
}
|
|
137
149
|
export declare class DecidioNetworkError extends Error {
|
|
138
150
|
endpoint: string;
|
|
@@ -145,6 +157,18 @@ export declare class DecidioNetworkError extends Error {
|
|
|
145
157
|
* every engine door reached the mock authorization path with a request the core would have refused.
|
|
146
158
|
* Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
|
|
147
159
|
export declare function assertDescribed(ctx: ApprovalContext): void;
|
|
160
|
+
/** THE ONE AUTHORIZE POST (0.12.0, 11f) - the core wrapper and every engine adapter send through here. A network failure
|
|
161
|
+
* or a transient answer is retried a bounded number of times with the SAME body: under a request key that is the same
|
|
162
|
+
* request (the gate answers the decision it already made, `idempotentReplay`), and a keyless request asked again while
|
|
163
|
+
* it waits joins the one waiting (`openTwin`). A body that carries a commitment WITHOUT a key is never retried - nothing
|
|
164
|
+
* would make the retry the same request. `rateLimit: "http"` keeps an adapter's 429 a DecidioHttpError, as it always was. */
|
|
165
|
+
export declare function sendAuthorize(config: GuardConfig, first: Record<string, unknown>, opts?: {
|
|
166
|
+
rateLimit?: "typed" | "http";
|
|
167
|
+
resign?: () => unknown;
|
|
168
|
+
}): Promise<any>;
|
|
169
|
+
/** The typed refusal for a claim that did not let this run go ahead (0.12.0) - DecidioBlockedError, so code that treats
|
|
170
|
+
* a block as "did not run" keeps doing so; the words say what happened and what to do ("ask again"). */
|
|
171
|
+
export declare function claimRefusal(claim: ClaimOutcome): DecidioBlockedError;
|
|
148
172
|
/**
|
|
149
173
|
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
150
174
|
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
@@ -172,6 +196,9 @@ export interface ProtectedExecution<R> {
|
|
|
172
196
|
/** What Decidio recorded. `reportRecorded` false means the execution happened and Decidio does
|
|
173
197
|
* NOT know; `executionConfirmed` false means it knows but could not attest a commit. */
|
|
174
198
|
confirmation: EvidenceOutcome;
|
|
199
|
+
/** The request key this call sent with its authorize (0.12.0, 11f) - absent when none was sent (no key material, so no
|
|
200
|
+
* commitment). It names the REQUEST to Decidio; your downstream idempotency key stays `decisionId`. */
|
|
201
|
+
requestKey?: string;
|
|
175
202
|
}
|
|
176
203
|
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
|
|
177
204
|
/** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
|
|
@@ -183,4 +210,9 @@ export declare function withApproval<A extends any[], R>(fn: (...args: A) => Pro
|
|
|
183
210
|
* idempotencyKey }`. Opt-in, never inferred from the function's arity — guessing wrong means
|
|
184
211
|
* passing a context object as someone's first business argument. */
|
|
185
212
|
withContext?: boolean;
|
|
213
|
+
/** YOUR OWN REQUEST KEY for a call (0.12.0, 11f), from its arguments - an order or invoice id - so the same call asked
|
|
214
|
+
* again, even from a new process, is the same request. Keyed with this agent's material before it is sent (your id
|
|
215
|
+
* never reaches Decidio in the clear). The same key for a CHANGED request is refused by the gate (409
|
|
216
|
+
* IDEMPOTENCY_CONFLICT), never a silent second request. Without it each call sends a fresh random key. */
|
|
217
|
+
requestKey?: (...args: A) => string;
|
|
186
218
|
}): (...args: A) => Promise<ProtectedExecution<R>>;
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DecidioSuspendedError, checkApproval, claimApproval, newClaimKey, RETRY_DELAYS_MS } from "./resume.js";
|
|
3
|
+
import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment, invocationRequestKey, ownRequestKey } from "./signing.js";
|
|
3
4
|
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
5
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
5
|
-
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
|
|
6
|
+
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
|
|
6
7
|
export { guard, Guard } from "./guard.js";
|
|
7
8
|
export { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
8
9
|
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
9
|
-
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
|
|
10
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
|
|
10
11
|
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
11
12
|
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
12
13
|
export class DecidioBlockedError extends Error {
|
|
@@ -66,10 +67,12 @@ export function coreRequest(ctx, args) {
|
|
|
66
67
|
export class DecidioTimeoutError extends Error {
|
|
67
68
|
decisionId;
|
|
68
69
|
withdrawn;
|
|
69
|
-
|
|
70
|
+
shared;
|
|
71
|
+
constructor(decisionId, withdrawn = false, shared = false) {
|
|
70
72
|
super(`Timed out waiting for human approval (${decisionId})`);
|
|
71
73
|
this.decisionId = decisionId;
|
|
72
74
|
this.withdrawn = withdrawn;
|
|
75
|
+
this.shared = shared;
|
|
73
76
|
this.name = "DecidioTimeoutError";
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -78,12 +81,14 @@ export class DecidioRateLimitError extends Error {
|
|
|
78
81
|
limit;
|
|
79
82
|
remaining;
|
|
80
83
|
resetAt;
|
|
81
|
-
|
|
84
|
+
code;
|
|
85
|
+
constructor(limit, remaining, resetAt, serverMessage, code) {
|
|
82
86
|
super(`[decidio] governed-action rate limit reached${limit != null ? ` (${limit}/hour on this workspace)` : ""}${resetAt ? ` — resets ${resetAt}` : ""}. ` +
|
|
83
87
|
`${serverMessage ? serverMessage + " " : ""}Nothing was routed or executed; retry after the reset.`);
|
|
84
88
|
this.limit = limit;
|
|
85
89
|
this.remaining = remaining;
|
|
86
90
|
this.resetAt = resetAt;
|
|
91
|
+
this.code = code;
|
|
87
92
|
this.name = "DecidioRateLimitError";
|
|
88
93
|
}
|
|
89
94
|
}
|
|
@@ -97,8 +102,9 @@ export class DecidioNetworkError extends Error {
|
|
|
97
102
|
? `A refused localhost connection usually means the SDK never saw your config: if your settings live in .env, run node with --env-file=.env (or export DECIDIO_API_URL — the hosted sandbox is https://decidio-api.onrender.com). `
|
|
98
103
|
: "") +
|
|
99
104
|
`Nothing was executed locally and no verdict was received — if a request reached Decidio it is ` +
|
|
100
|
-
`visible in your queue.
|
|
101
|
-
|
|
105
|
+
`visible in your queue. ` +
|
|
106
|
+
(/\/agent\/authorize/.test(op) ? `This SDK already retried it with the same request key. ` : "") +
|
|
107
|
+
`Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe — it can never double-execute.`);
|
|
102
108
|
this.endpoint = endpoint;
|
|
103
109
|
this.op = op;
|
|
104
110
|
this.code = code;
|
|
@@ -132,7 +138,7 @@ async function post(cfg, path, body) {
|
|
|
132
138
|
});
|
|
133
139
|
if (res.status === 429) {
|
|
134
140
|
const b = await res.json().catch(() => null);
|
|
135
|
-
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
|
|
141
|
+
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message, typeof b?.code === "string" ? b.code : undefined);
|
|
136
142
|
}
|
|
137
143
|
if (!res.ok) {
|
|
138
144
|
const raw = await res.text().catch(() => "");
|
|
@@ -145,6 +151,56 @@ async function post(cfg, path, body) {
|
|
|
145
151
|
}
|
|
146
152
|
return res.json();
|
|
147
153
|
}
|
|
154
|
+
function retriesSafely(e) {
|
|
155
|
+
if (e instanceof DecidioNetworkError)
|
|
156
|
+
return true;
|
|
157
|
+
if (e instanceof DecidioRateLimitError)
|
|
158
|
+
return e.code !== "AGENT_RATE";
|
|
159
|
+
if (e instanceof DecidioHttpError)
|
|
160
|
+
return (e.status >= 500 && e.status !== 501) || (e.status === 429 && e.body?.code !== "AGENT_RATE");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
export async function sendAuthorize(config, first, opts = {}) {
|
|
164
|
+
const mayRetry = !!first.idempotencyKey || !first.requestCommitment;
|
|
165
|
+
let body = first;
|
|
166
|
+
const once = async () => {
|
|
167
|
+
if ((opts.rateLimit ?? "typed") === "typed")
|
|
168
|
+
return post(config, "/agent/authorize", body);
|
|
169
|
+
const f = config.fetchImpl ?? fetch;
|
|
170
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
171
|
+
const res = await reach(f, config.apiUrl.replace(/\/$/, "") + "/agent/authorize", "POST /agent/authorize", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const raw = await res.text().catch(() => "");
|
|
178
|
+
let parsed = raw;
|
|
179
|
+
try {
|
|
180
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
|
|
184
|
+
}
|
|
185
|
+
return res.json();
|
|
186
|
+
};
|
|
187
|
+
for (let attempt = 0;; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
return await once();
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (!mayRetry || attempt >= RETRY_DELAYS_MS.length || !retriesSafely(e))
|
|
193
|
+
throw e;
|
|
194
|
+
await sleep(RETRY_DELAYS_MS[attempt]);
|
|
195
|
+
const fresh = first.requesterIdentity && opts.resign ? opts.resign() : undefined;
|
|
196
|
+
if (fresh)
|
|
197
|
+
body = { ...first, requesterIdentity: fresh };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function claimRefusal(claim) {
|
|
202
|
+
return new DecidioBlockedError(claim.ok ? "the approval was collected" : claim.reason, claim.decisionId);
|
|
203
|
+
}
|
|
148
204
|
async function get(cfg, path) {
|
|
149
205
|
const f = cfg.fetchImpl ?? fetch;
|
|
150
206
|
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
@@ -190,13 +246,30 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
190
246
|
const material = bindingMaterial(config);
|
|
191
247
|
const committed = coreRequest(ctx, args);
|
|
192
248
|
const sentCommitment = material ? requestCommitment(material, committed) : undefined;
|
|
193
|
-
|
|
249
|
+
let requestKey;
|
|
250
|
+
if (opts?.requestKey) {
|
|
251
|
+
const own = opts.requestKey(...args);
|
|
252
|
+
if (typeof own !== "string" || own.length === 0)
|
|
253
|
+
throw new Error(`[decidio] requestKey(...) must return a non-empty string naming this call (got ${JSON.stringify(own ?? null)}). Nothing was routed or executed.`);
|
|
254
|
+
if (!material)
|
|
255
|
+
throw new Error("[decidio] requestKey needs this agent's key material (DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID, or DECIDIO_API_TOKEN): a key rides only beside the request commitment that material makes, and Decidio refuses one alone. Nothing was routed or executed.");
|
|
256
|
+
requestKey = ownRequestKey(material, own);
|
|
257
|
+
}
|
|
258
|
+
else if (material && sentCommitment) {
|
|
259
|
+
requestKey = invocationRequestKey(material, config.invocationId ? String(config.invocationId()) : randomUUID(), sentCommitment);
|
|
260
|
+
}
|
|
261
|
+
const auth = await sendAuthorize(config, {
|
|
194
262
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
195
263
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
196
264
|
...(ctx.context ? { context: ctx.context } : {}),
|
|
197
265
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
198
266
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
199
267
|
...(sentCommitment ? { requestCommitment: sentCommitment } : {}),
|
|
268
|
+
...(requestKey ? { idempotencyKey: requestKey } : {}),
|
|
269
|
+
}, {
|
|
270
|
+
resign: () => (config.agentKey && config.agentDid)
|
|
271
|
+
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
272
|
+
: undefined,
|
|
200
273
|
});
|
|
201
274
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
202
275
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
@@ -238,13 +311,18 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
238
311
|
if (Date.now() > deadline) {
|
|
239
312
|
let withdrawn = false;
|
|
240
313
|
let lateState = null;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
314
|
+
let shared = auth.idempotentReplay === true || auth.openTwin === true;
|
|
315
|
+
if (!shared) {
|
|
316
|
+
try {
|
|
317
|
+
await post(config, `/agent/withdraw/${encodeURIComponent(auth.decisionId)}`, { reason: "timeout" });
|
|
318
|
+
withdrawn = true;
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (e instanceof DecidioHttpError && e.status === 409 && typeof e.body?.state === "string")
|
|
322
|
+
lateState = e.body.state;
|
|
323
|
+
else if (e instanceof DecidioHttpError && e.status === 409 && e.body?.code === "SHARED_REQUEST")
|
|
324
|
+
shared = true;
|
|
325
|
+
}
|
|
248
326
|
}
|
|
249
327
|
if (lateState === "approved" || lateState === "auto_approved")
|
|
250
328
|
break;
|
|
@@ -252,14 +330,19 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
252
330
|
throw new DecidioRejectedError("rejected by approver", auth.decisionId);
|
|
253
331
|
if (lateState === "blocked")
|
|
254
332
|
throw new DecidioBlockedError("blocked", auth.decisionId);
|
|
255
|
-
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : "is still open in Decidio (the withdraw call failed), but this process stopped waiting"} and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
|
|
256
|
-
throw new DecidioTimeoutError(auth.decisionId, withdrawn);
|
|
333
|
+
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : shared ? "is shared with another caller (or an earlier try of this one), so it was left open for a person to decide - this process stopped waiting" : "is still open in Decidio (the withdraw call failed), but this process stopped waiting"} and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
|
|
334
|
+
throw new DecidioTimeoutError(auth.decisionId, withdrawn, shared);
|
|
257
335
|
}
|
|
258
336
|
await sleep(pollInterval);
|
|
259
337
|
}
|
|
260
338
|
const check = await checkApproval(config, auth.decisionId, committed, { sent: !!sentCommitment, ...(approvedStatus ? { status: approvedStatus } : {}) });
|
|
261
339
|
if (!check.ok)
|
|
262
340
|
throw approvalRefusal(check);
|
|
341
|
+
if (check.state === "approved") {
|
|
342
|
+
const claimed = await claimApproval(config, auth.decisionId, newClaimKey());
|
|
343
|
+
if (!claimed.ok)
|
|
344
|
+
throw claimRefusal(claimed);
|
|
345
|
+
}
|
|
263
346
|
}
|
|
264
347
|
const result = opts?.withContext
|
|
265
348
|
? await fn({ decisionId: auth.decisionId, attemptId: null, idempotencyKey: auth.decisionId }, ...args)
|
|
@@ -299,6 +382,6 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
299
382
|
}
|
|
300
383
|
if (evidenceProblem)
|
|
301
384
|
confirmation.evidenceError = evidenceProblem;
|
|
302
|
-
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
385
|
+
return { value: result, decisionId: auth.decisionId, confirmation, ...(requestKey ? { requestKey } : {}) };
|
|
303
386
|
};
|
|
304
387
|
}
|
package/dist/inngest.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export type GateStepOutcome<R> =
|
|
|
39
39
|
decisionId: string;
|
|
40
40
|
value: R;
|
|
41
41
|
confirmation: EvidenceOutcome;
|
|
42
|
+
requestKey?: string;
|
|
42
43
|
}
|
|
43
44
|
/** The action never ran — a human said no, or a rule blocked it — so there is nothing to confirm. */
|
|
44
45
|
| {
|
|
@@ -70,6 +71,10 @@ export declare function decidioGateStep<R>(args: {
|
|
|
70
71
|
* path takes. This door's own confirm chokepoint tells callers to use it; until 0.2.0 no
|
|
71
72
|
* adapter door accepted one, so the advice was unfollowable everywhere it was printed. */
|
|
72
73
|
evidence?: (result: R) => unknown;
|
|
74
|
+
/** Inngest's id for THIS function run - the handler's `runId` (0.12.0, 11f). The request key is keyed on it, so Inngest
|
|
75
|
+
* retrying the authorize step is the same request while it waits. Without it the key is
|
|
76
|
+
* over a fresh id for the call (a retried step may then ask again). */
|
|
77
|
+
runId?: string;
|
|
73
78
|
}): Promise<GateStepOutcome<R>>;
|
|
74
79
|
/**
|
|
75
80
|
* Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
|
package/dist/inngest.js
CHANGED
|
@@ -4,7 +4,7 @@ import { approvalRefusal } from "./index.js";
|
|
|
4
4
|
export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
5
5
|
export async function decidioGateStep(args) {
|
|
6
6
|
const { step, config, ctx } = args;
|
|
7
|
-
const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
|
|
7
|
+
const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx, typeof args.runId === "string" && args.runId ? { naturalId: { engine: "inngest", runId: args.runId, step: "decidio-authorize" } } : {}));
|
|
8
8
|
assertAuthorizeVerdict(auth, auth?.decisionId);
|
|
9
9
|
if (auth.decision === "block")
|
|
10
10
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
@@ -31,7 +31,7 @@ export async function decidioGateStep(args) {
|
|
|
31
31
|
}
|
|
32
32
|
const result = await step.run("agent-action", args.run);
|
|
33
33
|
const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
|
|
34
|
-
return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
|
|
34
|
+
return { status: "executed", decisionId: auth.decisionId, value: result, confirmation, ...(typeof auth.requestKey === "string" ? { requestKey: auth.requestKey } : {}) };
|
|
35
35
|
}
|
|
36
36
|
export function createInngestResumeBridge(opts) {
|
|
37
37
|
const secret = opts.webhookSecret ?? "";
|
package/dist/resume.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface PendingAction {
|
|
|
38
38
|
* compared with the commitment Decidio returns. Absent on entries parked before 0.7.0 (and by
|
|
39
39
|
* an agent with no key material), which are checked on Decidio's state alone. */
|
|
40
40
|
requestCommitment?: string;
|
|
41
|
+
/** THE RUN'S CLAIM KEY (0.12.0, founder call 24): made at random when the action is parked, and the key this parked
|
|
42
|
+
* action collects a person's approval with (POST /agent/claim) right before it runs. It lives HERE, with the parked
|
|
43
|
+
* action, so every resume of it - a worker that restarts, a signal after a lost answer, a claim an operator released -
|
|
44
|
+
* is the same run to Decidio (`again: true`), while another process that parked its own copy of the request holds its
|
|
45
|
+
* own key and is told the approval was used. Covered by the payload signature. Absent on entries parked before 0.12.0:
|
|
46
|
+
* those claim with a fresh key each time (a lost answer then costs a new approval, never a second run). */
|
|
47
|
+
claimKey?: string;
|
|
41
48
|
}
|
|
42
49
|
/** Where suspended actions live between request and approval. The default is a
|
|
43
50
|
* file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
|
|
@@ -636,7 +643,11 @@ export interface ResumeController extends ResumeConfig {
|
|
|
636
643
|
* /agent/status + the request commitment — `checkApproval`). Decidio still pending → `not_approved`;
|
|
637
644
|
* Decidio says rejected/blocked → recorded as that denial; an approval that cannot be tied to these
|
|
638
645
|
* arguments → `request_unverified`. A transport failure during that check throws (nothing ran). A
|
|
639
|
-
* `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution.
|
|
646
|
+
* `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution.
|
|
647
|
+
*
|
|
648
|
+
* A PERSON'S approval is then COLLECTED ONCE (0.12.0, `claimApproval`) with the claim key parked with the action:
|
|
649
|
+
* another run already used it, or it expired → `blocked` with the reason (recorded like a block; nothing ran); a 404
|
|
650
|
+
* or a claim that could not be sent → throws (nothing ran, the entry stays parked for the next signal). */
|
|
640
651
|
resolve(input: {
|
|
641
652
|
decisionId: string;
|
|
642
653
|
verdict: "approved" | "rejected" | "blocked";
|
|
@@ -704,7 +715,9 @@ export declare function assertConfirmRecorded(body: any, decisionId: string, sig
|
|
|
704
715
|
* authorized with, or null for a decision whose request was never bound. `spent` and `grantExpiresAt` (0.8.0,
|
|
705
716
|
* mini-approvals): the grant is ONE-TIME - `spent` once an execution was recorded on it, `grantExpiresAt` when
|
|
706
717
|
* it lapses (the seal's time plus the gate's TTL) for a granted decision, null otherwise. A gate that predates
|
|
707
|
-
* them answers neither.
|
|
718
|
+
* them answers neither. `handedBackAt` (when a replay handed the grant to a retry) and `claimed` (0.12.0: whether a
|
|
719
|
+
* run has collected a person's approval - never by whom) are informational: reading the status claims NOTHING, and
|
|
720
|
+
* this SDK decides a claim only by claiming (claimApproval). */
|
|
708
721
|
export interface DecisionStatus {
|
|
709
722
|
state: string;
|
|
710
723
|
decisionId?: string;
|
|
@@ -713,6 +726,8 @@ export interface DecisionStatus {
|
|
|
713
726
|
requestCommitment?: string | null;
|
|
714
727
|
spent?: boolean;
|
|
715
728
|
grantExpiresAt?: string | null;
|
|
729
|
+
handedBackAt?: string | null;
|
|
730
|
+
claimed?: boolean;
|
|
716
731
|
}
|
|
717
732
|
type StatusConfig = {
|
|
718
733
|
apiUrl: string;
|
|
@@ -764,6 +779,41 @@ export declare function checkApproval(cfg: StatusConfig, decisionId: string, req
|
|
|
764
779
|
sent?: boolean;
|
|
765
780
|
status?: DecisionStatus;
|
|
766
781
|
}): Promise<ApprovalCheck>;
|
|
782
|
+
/** How long the SDK waits before each retry of a call it may safely repeat (11f): the authorize POST (with the same
|
|
783
|
+
* body, so the same request key) and the claim (with the same claim key). Bounded: two retries, then the failure is the
|
|
784
|
+
* caller's to see. Python twin: RETRY_DELAYS. */
|
|
785
|
+
export declare const RETRY_DELAYS_MS: readonly number[];
|
|
786
|
+
/** The grammar the gate takes for a request key and a claim key alike. */
|
|
787
|
+
export declare const REQUEST_KEY_RE: RegExp;
|
|
788
|
+
/** A claim key: RANDOM PER RUN (two workers with one key would be one run to Decidio, and both would run). The durable
|
|
789
|
+
* path makes one when it parks an action and keeps it there; the blocking wait and the OpenAI resume make one per call. */
|
|
790
|
+
export declare function newClaimKey(): string;
|
|
791
|
+
/** Why a claim did not let this run go ahead. already_used / expired / not_claimable are Decidio's FINAL answers (409):
|
|
792
|
+
* this approval will never run here. not_found (404: no such decision for this credential, or a gate that predates
|
|
793
|
+
* claims), refused (any other 4xx), unreachable (a network failure or a 5xx after the retries) and unrecognized (an
|
|
794
|
+
* answer this SDK does not understand) may change - nothing ran, and a later try may succeed. */
|
|
795
|
+
export type ClaimCode = "already_used" | "expired" | "not_claimable" | "not_found" | "refused" | "unreachable" | "unrecognized";
|
|
796
|
+
/** The outcome of one claim. Plain JSON on purpose, like ApprovalCheck: an engine that must run HTTP in an activity
|
|
797
|
+
* (Temporal) returns it across its serialization boundary. */
|
|
798
|
+
export type ClaimOutcome = {
|
|
799
|
+
ok: true;
|
|
800
|
+
decisionId: string;
|
|
801
|
+
again: boolean;
|
|
802
|
+
} | {
|
|
803
|
+
ok: false;
|
|
804
|
+
decisionId: string;
|
|
805
|
+
code: ClaimCode;
|
|
806
|
+
final: boolean;
|
|
807
|
+
reason: string;
|
|
808
|
+
status?: number;
|
|
809
|
+
};
|
|
810
|
+
/** COLLECT A PERSON'S APPROVAL FOR THIS RUN, ONCE (POST /agent/claim/:decisionId) - the step every door that RUNS on a
|
|
811
|
+
* person's approval takes right before it runs: the blocking wait, the durable resume, Temporal and the OpenAI resume. The
|
|
812
|
+
* first claim wins; the same key claiming again is the same run retrying (`again: true`); any other key is told the
|
|
813
|
+
* approval was already used. Reading the status claims nothing, so nothing that only watches ever calls this. An
|
|
814
|
+
* auto-approval is never claimed (it is never shared). Retried with the SAME key on a network failure or a 5xx; every
|
|
815
|
+
* answer other than a 200 that says `claimed: true` means DO NOT RUN. Never throws for an answer - a refusal is a value. */
|
|
816
|
+
export declare function claimApproval(cfg: StatusConfig, decisionId: string, claimKey: string): Promise<ClaimOutcome>;
|
|
767
817
|
/** The webhook handlers' refusal when no webhook secret is configured - with the remedy. */
|
|
768
818
|
/** The one remedy every webhook-secret refusal names (#4B review W3): the dedicated webhook secret - NEVER the
|
|
769
819
|
* server's API_AUTH_TOKEN, the admin bearer, which a refusal that said "the value your deployment signs
|