@decidio/sdk 0.4.0 → 0.4.1
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 +50 -0
- package/README.md +4 -1
- package/dist/adapters/_gate.d.ts +6 -6
- package/dist/adapters/_gate.js +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +249 -78
- package/dist/inngest.d.ts +14 -6
- package/dist/resume.d.ts +7 -1
- package/dist/resume.js +22 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ Release notes for the client SDK. The Python package `decidio` ships the same ch
|
|
|
4
4
|
version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
|
|
5
5
|
on upgrade, and what proved it.
|
|
6
6
|
|
|
7
|
+
## 0.4.1 — "no" means no
|
|
8
|
+
|
|
9
|
+
Two first-run defects found by an outside engineer within sixteen minutes of installing 0.4.0. Both were
|
|
10
|
+
in the CLI's own tour and cleanup, not in the SDK's gate, ledger or verifier; both are fixed in both
|
|
11
|
+
languages, and the two blind spots that let them ship are now test axes.
|
|
12
|
+
|
|
13
|
+
- **The tour's approval prompt is a question.** "Press Enter to approve" accepted ANY completed line —
|
|
14
|
+
the literal `no` included — as approval, because the answer was never read. Now `y` approves, `n`
|
|
15
|
+
REJECTS (the tour rehearses the rejection half: the request is refused, your function does not run, and
|
|
16
|
+
the tour says so), and anything else asks again; three non-answers stop the tour with the decision still
|
|
17
|
+
pending (exit 3) rather than guess. The rule is one pure function, `approvalAnswer`, driven by a shared
|
|
18
|
+
corpus (`packages/conformance/vectors/consent-answers.json`) in both languages, and the interactive
|
|
19
|
+
branch itself is exercised under a real terminal on the Linux gate. The automated `--auto-approve-demo`
|
|
20
|
+
path is unchanged and still labeled as automated authority.
|
|
21
|
+
- **A lost `.env` no longer loses the token.** 0.3.5's recovery journal retired a token id the moment its
|
|
22
|
+
credential file was written, so moving or deleting `.env` afterwards left `quickstart cleanup` saying the
|
|
23
|
+
journal owed nothing over a live seven-day token. Only a revocation retires a journal entry now. Cleanup
|
|
24
|
+
revokes tour tokens from the journal even when the credential file is gone, and still revokes a token
|
|
25
|
+
whose credential file never landed (the double fault the journal was built for). It never revokes an
|
|
26
|
+
`init` token that DID land: one a credential file here holds is named and left alone; one that was written
|
|
27
|
+
to a file no longer found here is named with the `tokens revoke` command (it may simply live elsewhere);
|
|
28
|
+
`doctor` shows a held token as live-and-held. **Upgrading from 0.3.5/0.4.0:** rows those versions retired as `persisted` count as
|
|
29
|
+
outstanding again. A tour you already cleaned up will show in `doctor` until you run
|
|
30
|
+
`npx @decidio/sdk quickstart cleanup` once, which reports the token as already revoked and retires the row.
|
|
31
|
+
- **Truth and disclosure.** The failure text after a refused registration no longer claims nothing was
|
|
32
|
+
written (the `.gitignore` entries were); the preflight discloses the five-minute wait for your answer and
|
|
33
|
+
that receipts are shareable but carry business metadata; cleanup names the retained journal; the
|
|
34
|
+
worker's "prints once" is "once per worker process"; the tour tells you to unset `DECIDIO_SESSION_TOKEN`
|
|
35
|
+
before launching agent code from the same shell.
|
|
36
|
+
- **`dev --target` obeys the wire rule:** the relay refuses a remote plaintext `http://` target, since the
|
|
37
|
+
forwarded body is signed resume material.
|
|
38
|
+
- **A race loser says so** (prodsec retest of 0.4.0, V040-01). A resume signal that finds the pre-invocation
|
|
39
|
+
claim held by another live attempt, or loses the invocation-boundary race to one, answers the new additive
|
|
40
|
+
status `lost_race` (it did nothing; the winner reports). It used to read `unknown` — the word for "not
|
|
41
|
+
ours" — and `no_handler` although the handler was registered. A boundary loss to a DENIAL is final and
|
|
42
|
+
answers `already_resolved` with the denial named; a store fault at the boundary answers `lost_race` with
|
|
43
|
+
"nothing ran … a later signal may retry". A signal that arrives after the entry was consumed now consults
|
|
44
|
+
the ledger: already ran or denied → `already_resolved`; in flight → `indeterminate`; a claim held by another
|
|
45
|
+
attempt → `lost_race`; only a decision with no record at all is `unknown`. Exhaustive switches over the
|
|
46
|
+
status union gain one case.
|
|
47
|
+
- **Adapter declarations aligned** (V040-02): the Temporal compatibility floor checked at bind time is now the
|
|
48
|
+
same `>=1.9 <2` the package declares; the peer-dependency comment says what the manifest does (optional
|
|
49
|
+
peers, checked at bind time); the Inngest step outcome is a discriminated union, so `confirmation` is
|
|
50
|
+
statically present when `status` is `"executed"`. Adapters stay Beta; real-engine evidence (V040-03) and
|
|
51
|
+
provenance metadata (V040-04) remain open and disclosed.
|
|
52
|
+
- Deliberately not changed, recorded in the backlog for a product decision: per-subcommand `--help`, the
|
|
53
|
+
tour showing the exact call it makes (`withApproval` in blocking mode rather than the `guard.protect`
|
|
54
|
+
line it prints as the takeaway), dot-stripping in receipt filenames, and a shared no-overwrite receipt
|
|
55
|
+
writer for the tour.
|
|
56
|
+
|
|
7
57
|
## 0.4.0 — the scope statement
|
|
8
58
|
|
|
9
59
|
- **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
|
package/README.md
CHANGED
|
@@ -81,7 +81,10 @@ fresh one (or pass `--unique` to auto-append a random suffix — collision-proof
|
|
|
81
81
|
workspaces and CI). Headless terminal (no prompts possible)? Export `DECIDIO_SESSION_TOKEN` first:
|
|
82
82
|
request a sign-in link, then exchange its `?login=` value at `POST /demo/login-consume` — the
|
|
83
83
|
response's `token` is your session token (the CLI prints these exact instructions when it
|
|
84
|
-
can't prompt).
|
|
84
|
+
can't prompt). That session token is your workspace login, not the agent's credential: unset it
|
|
85
|
+
before launching agent code from the same shell, because child processes inherit exported
|
|
86
|
+
variables. In the tour's human step, `y` approves and `n` rejects — the rejection half is worth
|
|
87
|
+
watching once: the request is refused and your function does not run.
|
|
85
88
|
|
|
86
89
|
**3. Protect one function** — save this as `quickstart.mjs`:
|
|
87
90
|
|
package/dist/adapters/_gate.d.ts
CHANGED
|
@@ -27,12 +27,12 @@ export declare function gateConfirm(config: GuardConfig, decisionId: string, res
|
|
|
27
27
|
export declare function verdictFrom(payload: any): string;
|
|
28
28
|
/** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
|
|
29
29
|
export declare function assertVerdict(verdict: string, decisionId: string, reason?: string): void;
|
|
30
|
-
/** Engine versions these adapters are written and tested against.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
30
|
+
/** Engine versions these adapters are written and tested against. The two engines that ARE declared in
|
|
31
|
+
* package.json are declared as OPTIONAL peers (peerDependenciesMeta), so a consumer who uses none of them
|
|
32
|
+
* installs nothing extra; Inngest and the OpenAI Agents SDK are not declared at all. Either way the
|
|
33
|
+
* contract is CHECKED here at bind time — a mismatch names the version it found, the range it wants, the
|
|
34
|
+
* beta status and the exact remediation, rather than failing somewhere deep in the engine later. These
|
|
35
|
+
* ranges and package.json's peer ranges are the SAME numbers (prodsec V040-02: they had drifted). */
|
|
36
36
|
export declare const ENGINE_RANGES: Record<string, {
|
|
37
37
|
pkg: string;
|
|
38
38
|
min: string;
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -105,7 +105,7 @@ export function assertVerdict(verdict, decisionId, reason) {
|
|
|
105
105
|
}
|
|
106
106
|
export const ENGINE_RANGES = {
|
|
107
107
|
langgraph: { pkg: "@langchain/langgraph", min: "0.2.0", lessThan: "2.0.0" },
|
|
108
|
-
temporal: { pkg: "@temporalio/workflow", min: "1.
|
|
108
|
+
temporal: { pkg: "@temporalio/workflow", min: "1.9.0", lessThan: "2.0.0" },
|
|
109
109
|
openai: { pkg: "@openai/agents", min: "0.0.1", lessThan: "2.0.0" },
|
|
110
110
|
inngest: { pkg: "inngest", min: "3.0.0", lessThan: "4.0.0" },
|
|
111
111
|
};
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,12 @@ import { randomBytes } from "node:crypto";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
|
+
import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
|
|
8
|
+
import { realpathSync as realPath } from "node:fs";
|
|
7
9
|
import { createRequire } from "node:module";
|
|
8
10
|
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
9
11
|
import { hardenFile } from "./resume.js";
|
|
10
|
-
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
12
|
+
import { withApproval, DecidioRateLimitError, DecidioRejectedError, didFromPrivateKey } from "./index.js";
|
|
11
13
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
14
|
const VERSION = (() => {
|
|
13
15
|
try {
|
|
@@ -246,6 +248,32 @@ function journalRows() {
|
|
|
246
248
|
}
|
|
247
249
|
return rows;
|
|
248
250
|
}
|
|
251
|
+
function credentialJtisOnDisk() {
|
|
252
|
+
const out = new Set();
|
|
253
|
+
for (const f of [".env", ".env.quickstart"]) {
|
|
254
|
+
if (!existsSync(f))
|
|
255
|
+
continue;
|
|
256
|
+
let text = "";
|
|
257
|
+
try {
|
|
258
|
+
text = readFileSync(f, "utf8");
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const m of text.matchAll(/^\s*DECIDIO_(?:ORPHANED_)?TOKEN_JTI=(.+)$/gm))
|
|
264
|
+
for (const j of m[1].split(","))
|
|
265
|
+
if (j.trim())
|
|
266
|
+
out.add(j.trim());
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
function journalOwed() {
|
|
271
|
+
return journalOutstanding().filter((j) => j.label === "quickstart" || !j.persisted);
|
|
272
|
+
}
|
|
273
|
+
function journalUnheld() {
|
|
274
|
+
const held = credentialJtisOnDisk();
|
|
275
|
+
return journalOutstanding().filter((j) => j.label !== "quickstart" && j.persisted && !held.has(j.jti));
|
|
276
|
+
}
|
|
249
277
|
const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
|
|
250
278
|
function journalProbe() {
|
|
251
279
|
try {
|
|
@@ -272,7 +300,12 @@ function journalOutstanding() {
|
|
|
272
300
|
for (const e of journalRows()) {
|
|
273
301
|
if (e.event === "minted")
|
|
274
302
|
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
275
|
-
if (e.event === "retired")
|
|
303
|
+
if (e.event === "retired" && e.how === "persisted") {
|
|
304
|
+
const m = minted.get(e.jti);
|
|
305
|
+
if (m)
|
|
306
|
+
m.persisted = true;
|
|
307
|
+
}
|
|
308
|
+
if (e.event === "retired" && e.how === "revoked")
|
|
276
309
|
minted.delete(e.jti);
|
|
277
310
|
}
|
|
278
311
|
return [...minted.values()];
|
|
@@ -337,7 +370,19 @@ function ensureGitignore() {
|
|
|
337
370
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
338
371
|
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
339
372
|
}
|
|
373
|
+
const APPROVE_WORDS = new Set(["y", "yes", "approve", "approved"]);
|
|
374
|
+
const REJECT_WORDS = new Set(["n", "no", "reject", "rejected", "deny", "denied"]);
|
|
375
|
+
export function approvalAnswer(answer) {
|
|
376
|
+
const a = String(answer ?? "").trim().toLowerCase();
|
|
377
|
+
if (APPROVE_WORDS.has(a))
|
|
378
|
+
return "approve";
|
|
379
|
+
if (REJECT_WORDS.has(a))
|
|
380
|
+
return "reject";
|
|
381
|
+
return "again";
|
|
382
|
+
}
|
|
340
383
|
function ask(question, mute = false) {
|
|
384
|
+
if (process.stdin.readableEnded || process.stdin.destroyed)
|
|
385
|
+
return Promise.resolve("");
|
|
341
386
|
return new Promise((resolve) => {
|
|
342
387
|
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
343
388
|
if (mute) {
|
|
@@ -346,8 +391,9 @@ function ask(question, mute = false) {
|
|
|
346
391
|
anyRl._writeToOutput = (s) => { if (s.includes(question))
|
|
347
392
|
write?.(s); };
|
|
348
393
|
}
|
|
349
|
-
rl.
|
|
350
|
-
|
|
394
|
+
rl.once("close", () => resolve(""));
|
|
395
|
+
rl.question(question, (answer) => { resolve(answer.trim()); rl.close(); if (mute)
|
|
396
|
+
process.stderr.write("\n"); });
|
|
351
397
|
});
|
|
352
398
|
}
|
|
353
399
|
async function acquireSession(purpose) {
|
|
@@ -581,6 +627,10 @@ async function cmdDoctor() {
|
|
|
581
627
|
}
|
|
582
628
|
for (const j of journalItems) {
|
|
583
629
|
const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
|
|
630
|
+
if (j.jti === env.DECIDIO_TOKEN_JTI) {
|
|
631
|
+
line(true, `token recovery journal: jti ${j.jti} is live and held by ${ENV_PATH} — it retires only when revoked (npx @decidio/sdk tokens revoke ${j.jti}; a tour token: quickstart cleanup).`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
584
634
|
warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
585
635
|
}
|
|
586
636
|
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
@@ -881,8 +931,9 @@ async function cmdQuickstart(opts = {}) {
|
|
|
881
931
|
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
882
932
|
log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
|
|
883
933
|
log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
|
|
884
|
-
log(" as decidio-receipt-<id>.json here (
|
|
885
|
-
log(" sealed record (records are permanent by design)
|
|
934
|
+
log(" as decidio-receipt-<id>.json here (shareable: action, amount, policy, the human's reason - no secrets; it stays)");
|
|
935
|
+
log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
|
|
936
|
+
log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
|
|
886
937
|
log("");
|
|
887
938
|
const session = await acquireSession("The quickstart");
|
|
888
939
|
if (!session)
|
|
@@ -911,7 +962,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
911
962
|
else {
|
|
912
963
|
err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
|
|
913
964
|
}
|
|
914
|
-
err(`
|
|
965
|
+
err(` No credential was written to this directory (the only write so far is the .gitignore entries above, which are safe to keep).`);
|
|
915
966
|
return 1;
|
|
916
967
|
}
|
|
917
968
|
let prevTourFile = null;
|
|
@@ -958,8 +1009,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
958
1009
|
if (prevId && prevId !== agentId) {
|
|
959
1010
|
if (prevJti) {
|
|
960
1011
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
961
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1012
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1013
|
+
journalRetire(prevJti, "revoked");
|
|
962
1014
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
1015
|
+
}
|
|
963
1016
|
else {
|
|
964
1017
|
carried.add(prevJti);
|
|
965
1018
|
log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
|
|
@@ -1050,8 +1103,26 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1050
1103
|
log(` Nothing executes until someone decides.`);
|
|
1051
1104
|
log("");
|
|
1052
1105
|
const interactive = process.stdin.isTTY;
|
|
1106
|
+
let verdict = "approve";
|
|
1053
1107
|
if (interactive) {
|
|
1054
|
-
|
|
1108
|
+
verdict = "again";
|
|
1109
|
+
for (let attempt = 0; attempt < 3 && verdict === "again"; attempt++) {
|
|
1110
|
+
const answer = await ask(attempt === 0
|
|
1111
|
+
? `③ YOU are the approver. Type y to approve or n to reject, then Enter (or decide it in your Decidio queue first, then answer here the same way): `
|
|
1112
|
+
: ` Please answer y (approve) or n (reject): `);
|
|
1113
|
+
verdict = approvalAnswer(answer);
|
|
1114
|
+
if (process.stdin.readableEnded || process.stdin.destroyed) {
|
|
1115
|
+
verdict = "again";
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (verdict === "again") {
|
|
1120
|
+
log(`③ No decision was made (no y or n answer), so the tour stops here rather than guess.`);
|
|
1121
|
+
log(` The request is still ROUTED and waiting in your queue - approve or reject it there, or re-run the tour.`);
|
|
1122
|
+
log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's key). Once you`);
|
|
1123
|
+
log(` have decided - or if you abandon the tour - remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1124
|
+
process.exit(3);
|
|
1125
|
+
}
|
|
1055
1126
|
}
|
|
1056
1127
|
else if (opts.autoApproveDemo) {
|
|
1057
1128
|
log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
|
|
@@ -1078,67 +1149,119 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1078
1149
|
log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1079
1150
|
process.exit(3);
|
|
1080
1151
|
}
|
|
1081
|
-
const
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1152
|
+
const rejected = verdict === "reject";
|
|
1153
|
+
if (rejected) {
|
|
1154
|
+
const rj = await api(`/api/decisions/${encodeURIComponent(decisionId)}/reject`, { method: "POST", body: JSON.stringify({ reason: "quickstart: rejected by the approver" }) }, session);
|
|
1155
|
+
if (!(rj.status >= 200 && rj.status < 300)) {
|
|
1156
|
+
err(`✘ reject failed (${rj.status}): ${JSON.stringify(rj.body)}`);
|
|
1157
|
+
return failTour(1);
|
|
1158
|
+
}
|
|
1159
|
+
const decidedInApp = rj.body?.alreadyResolved === true;
|
|
1160
|
+
log(decidedInApp ? ` • this decision was already decided from your app — the tour follows that decision` : ` ✔ rejected`);
|
|
1161
|
+
let leaked;
|
|
1162
|
+
let refused;
|
|
1163
|
+
try {
|
|
1164
|
+
leaked = await running;
|
|
1165
|
+
}
|
|
1166
|
+
catch (e) {
|
|
1167
|
+
refused = e;
|
|
1168
|
+
}
|
|
1169
|
+
if (refused === undefined && !decidedInApp) {
|
|
1170
|
+
err(`✘ the gated function RAN after the rejection (it returned ${JSON.stringify(leaked)}) — this is a defect, not a tour. Please report it: decidioai.com/#early-access`);
|
|
1171
|
+
return failTour(1);
|
|
1172
|
+
}
|
|
1173
|
+
const ranOnAppApproval = refused === undefined;
|
|
1174
|
+
if (ranOnAppApproval) {
|
|
1175
|
+
log(` ✔ your app APPROVED it first, so that decision stood and your function ran: ${JSON.stringify(leaked)}. Re-run the tour to watch a rejection.`);
|
|
1176
|
+
}
|
|
1177
|
+
else if (refused instanceof DecidioRejectedError) {
|
|
1178
|
+
log(` ✔ your function did NOT run — the wrapper refused: ${refused.message}`);
|
|
1179
|
+
log(` That is the product: a human said no, and nothing happened.${rj.body?.sealed === true ? " The denial is sealed on the record, as an approval would be." : ""}`);
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
err(`✘ the gated call ended with an error rather than a clean refusal: ${refused?.message ?? refused} — nothing ran, but the tour did not complete.`);
|
|
1183
|
+
return failTour(1);
|
|
1184
|
+
}
|
|
1185
|
+
log("");
|
|
1186
|
+
const secsR = Math.round((Date.now() - t0) / 1000);
|
|
1187
|
+
log(ranOnAppApproval
|
|
1188
|
+
? `Done in ${secsR}s: gated → routed to a human → decided in your app (approved) → your function ran once.`
|
|
1189
|
+
: `Done in ${secsR}s: gated → routed to a human → REJECTED → your function never ran.`);
|
|
1190
|
+
if (!ranOnAppApproval)
|
|
1191
|
+
log(`Re-run the tour and answer y to watch the approval half, with its offline-verifiable receipt.`);
|
|
1192
|
+
log("");
|
|
1193
|
+
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1113
1194
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1195
|
+
else {
|
|
1196
|
+
const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
|
|
1197
|
+
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
1198
|
+
err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
|
|
1199
|
+
return failTour(1);
|
|
1200
|
+
}
|
|
1201
|
+
log(` ✔ approved${ap.body?.alreadyResolved ? " (already decided from your app — the tour follows that decision)" : ""}`);
|
|
1202
|
+
let done;
|
|
1203
|
+
try {
|
|
1204
|
+
done = await running;
|
|
1205
|
+
}
|
|
1206
|
+
catch (e) {
|
|
1207
|
+
if (e instanceof DecidioRejectedError) {
|
|
1208
|
+
err(`✘ it was REJECTED from your app before this tour's approval landed — your function did NOT run (that is the product). Re-run the tour to watch the approval half.`);
|
|
1209
|
+
return failTour(1);
|
|
1210
|
+
}
|
|
1211
|
+
err(`✘ the gated call did not complete: ${e?.message ?? e}`);
|
|
1212
|
+
return failTour(1);
|
|
1213
|
+
}
|
|
1214
|
+
log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
|
|
1215
|
+
if (done.confirmation?.evidenceTier)
|
|
1216
|
+
log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
|
|
1217
|
+
log("");
|
|
1218
|
+
log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
|
|
1219
|
+
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
1220
|
+
if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
|
|
1221
|
+
err(`✘ receipt fetch failed (${rec.status})`);
|
|
1222
|
+
return failTour(1);
|
|
1223
|
+
}
|
|
1224
|
+
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
1225
|
+
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
1226
|
+
const issuer = rec.body?.issuer;
|
|
1227
|
+
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1228
|
+
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
|
|
1229
|
+
if (!v.valid) {
|
|
1230
|
+
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1231
|
+
return failTour(1);
|
|
1232
|
+
}
|
|
1233
|
+
if (!issuer) {
|
|
1234
|
+
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
1235
|
+
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1236
|
+
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
1237
|
+
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1238
|
+
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1239
|
+
return failTour(1);
|
|
1240
|
+
}
|
|
1241
|
+
if (issuer)
|
|
1242
|
+
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
1243
|
+
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
1244
|
+
const tier = detail.body?.record?.confirmation?.status;
|
|
1245
|
+
if (tier)
|
|
1246
|
+
log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
|
|
1247
|
+
log("");
|
|
1248
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
1249
|
+
log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
|
|
1250
|
+
log("");
|
|
1251
|
+
log("Take it to your real agent — the .env here already works:");
|
|
1252
|
+
log(` import { guard } from "@decidio/sdk";`);
|
|
1253
|
+
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
1254
|
+
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
1255
|
+
log("");
|
|
1256
|
+
log(`Before launching agent code from THIS shell, unset DECIDIO_SESSION_TOKEN if you exported it: child processes`);
|
|
1257
|
+
log(`inherit exported variables, and that credential is your workspace login, not the agent's.`);
|
|
1258
|
+
log("");
|
|
1259
|
+
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1260
|
+
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1261
|
+
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1262
|
+
log(`already works here, so skip that flow's init step.`);
|
|
1263
|
+
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
1121
1264
|
}
|
|
1122
|
-
if (issuer)
|
|
1123
|
-
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
1124
|
-
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
1125
|
-
const tier = detail.body?.record?.confirmation?.status;
|
|
1126
|
-
if (tier)
|
|
1127
|
-
log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
|
|
1128
|
-
log("");
|
|
1129
|
-
const secs = Math.round((Date.now() - t0) / 1000);
|
|
1130
|
-
log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
|
|
1131
|
-
log("");
|
|
1132
|
-
log("Take it to your real agent — the .env here already works:");
|
|
1133
|
-
log(` import { guard } from "@decidio/sdk";`);
|
|
1134
|
-
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
1135
|
-
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
1136
|
-
log("");
|
|
1137
|
-
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1138
|
-
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1139
|
-
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1140
|
-
log(`already works here, so skip that flow's init step.`);
|
|
1141
|
-
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
1142
1265
|
let doCleanup = false;
|
|
1143
1266
|
if (interactive) {
|
|
1144
1267
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -1151,7 +1274,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1151
1274
|
log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
|
|
1152
1275
|
}
|
|
1153
1276
|
const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
|
|
1154
|
-
return
|
|
1277
|
+
return cleanupCode || 0;
|
|
1155
1278
|
}
|
|
1156
1279
|
async function quickstartCleanup(session, envFile) {
|
|
1157
1280
|
const env = (() => {
|
|
@@ -1172,13 +1295,25 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1172
1295
|
let journalRevoked = 0;
|
|
1173
1296
|
const journalStuck = [];
|
|
1174
1297
|
let outstanding = [];
|
|
1298
|
+
let heldElsewhere = [];
|
|
1299
|
+
let unheld = [];
|
|
1300
|
+
let journalReconciled = 0;
|
|
1175
1301
|
try {
|
|
1176
|
-
|
|
1302
|
+
const ownIds = new Set([env.DECIDIO_TOKEN_JTI, ...(env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim())].filter(Boolean));
|
|
1303
|
+
outstanding = journalOwed().filter((j) => !ownIds.has(j.jti));
|
|
1304
|
+
unheld = journalUnheld();
|
|
1305
|
+
const owedIds = new Set(outstanding.map((j) => j.jti));
|
|
1306
|
+
const unheldIds = new Set(unheld.map((j) => j.jti));
|
|
1307
|
+
heldElsewhere = journalOutstanding().filter((j) => !owedIds.has(j.jti) && !unheldIds.has(j.jti) && !ownIds.has(j.jti));
|
|
1177
1308
|
}
|
|
1178
1309
|
catch (e) {
|
|
1179
1310
|
err(`✘ the token recovery journal cannot be trusted (${e?.message ?? e}). It may hold the id of a live token, so this cleanup cannot claim nothing is owed. Every byte is left as found: inspect ${JOURNAL_PATH}, revoke anything it names (npx @decidio/sdk tokens list --mine), then repair or move it aside and re-run.`);
|
|
1180
1311
|
return 1;
|
|
1181
1312
|
}
|
|
1313
|
+
for (const j of heldElsewhere)
|
|
1314
|
+
log(` • journalled token ${j.jti} (${j.label ?? "token"}) is live and still held by a credential file here — not a tour token, left alone (revoke it yourself: npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
1315
|
+
for (const j of unheld)
|
|
1316
|
+
err(` ⚠ journalled token ${j.jti} (${j.label ?? "token"}) is live: it was written to a credential file that is no longer here — not a tour token, so this cleanup will not revoke it. If it is yours and lost, revoke it: npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
1182
1317
|
for (const j of outstanding) {
|
|
1183
1318
|
if (j.apiUrl && j.apiUrl !== API) {
|
|
1184
1319
|
journalStuck.push(j.jti);
|
|
@@ -1188,11 +1323,14 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1188
1323
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
|
|
1189
1324
|
if (rv.status >= 200 && rv.status < 300) {
|
|
1190
1325
|
journalRetire(j.jti, "revoked");
|
|
1191
|
-
|
|
1192
|
-
|
|
1326
|
+
if (rv.body?.changed === false) {
|
|
1327
|
+
journalReconciled++;
|
|
1193
1328
|
log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
|
|
1194
|
-
|
|
1195
|
-
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
journalRevoked++;
|
|
1332
|
+
log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — from the journal: ${j.persisted ? "its credential file is gone" : "its credential file never landed"}, so the journal was the handle`);
|
|
1333
|
+
}
|
|
1196
1334
|
}
|
|
1197
1335
|
else {
|
|
1198
1336
|
journalStuck.push(j.jti);
|
|
@@ -1209,8 +1347,10 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1209
1347
|
const stillOrphaned = [];
|
|
1210
1348
|
for (const jti of orphans) {
|
|
1211
1349
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
|
|
1212
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1350
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1351
|
+
journalRetire(jti, "revoked");
|
|
1213
1352
|
log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
|
|
1353
|
+
}
|
|
1214
1354
|
else {
|
|
1215
1355
|
stillOrphaned.push(jti);
|
|
1216
1356
|
err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
|
|
@@ -1224,6 +1364,8 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1224
1364
|
}
|
|
1225
1365
|
if (env.DECIDIO_TOKEN_JTI) {
|
|
1226
1366
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
|
|
1367
|
+
if (rev.status >= 200 && rev.status < 300)
|
|
1368
|
+
journalRetire(env.DECIDIO_TOKEN_JTI, "revoked");
|
|
1227
1369
|
if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
|
|
1228
1370
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
1229
1371
|
else if (rev.status >= 200 && rev.status < 300)
|
|
@@ -1238,6 +1380,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1238
1380
|
else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
|
|
1239
1381
|
log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
|
|
1240
1382
|
}
|
|
1383
|
+
else if (!existsSync(envFile)) {
|
|
1384
|
+
if (unheld.length)
|
|
1385
|
+
err(` • no credential file here (${envFile} is already gone); the journal names ${unheld.length} live token(s) this cleanup will not revoke (above).`);
|
|
1386
|
+
else
|
|
1387
|
+
log(` • no credential file here (${envFile} is already gone) and the token recovery journal owes nothing further${journalReconciled ? ` (${journalReconciled} already-revoked row(s) reconciled)` : ""}.`);
|
|
1388
|
+
}
|
|
1241
1389
|
else {
|
|
1242
1390
|
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
1243
1391
|
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
@@ -1281,6 +1429,8 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1281
1429
|
}
|
|
1282
1430
|
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
1283
1431
|
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
1432
|
+
if (existsSync(JOURNAL_PATH))
|
|
1433
|
+
log(` • Also kept: ${JOURNAL_PATH} — the ids and timestamps of what this directory minted and revoked (no secrets). Gitignored; owner-only where the OS supports it.`);
|
|
1284
1434
|
if (existsSync(".decidio-pending")) {
|
|
1285
1435
|
log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
|
|
1286
1436
|
log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
|
|
@@ -1358,6 +1508,13 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1358
1508
|
return 0;
|
|
1359
1509
|
}
|
|
1360
1510
|
function cmdDev(target, port) {
|
|
1511
|
+
try {
|
|
1512
|
+
assertSafeTransport(target, true);
|
|
1513
|
+
}
|
|
1514
|
+
catch (e) {
|
|
1515
|
+
err(`✘ dev relay refused: ${e?.message ?? e}`);
|
|
1516
|
+
return 2;
|
|
1517
|
+
}
|
|
1361
1518
|
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
1362
1519
|
createServer((req, res) => {
|
|
1363
1520
|
if (req.method !== "POST") {
|
|
@@ -1516,7 +1673,7 @@ async function main(argv) {
|
|
|
1516
1673
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1517
1674
|
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1518
1675
|
: (() => { try {
|
|
1519
|
-
return
|
|
1676
|
+
return (journalOwed().length || journalUnheld().length) ? ".env.quickstart" : undefined;
|
|
1520
1677
|
}
|
|
1521
1678
|
catch {
|
|
1522
1679
|
return ".env.quickstart";
|
|
@@ -1614,5 +1771,19 @@ async function main(argv) {
|
|
|
1614
1771
|
return 2;
|
|
1615
1772
|
}
|
|
1616
1773
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1774
|
+
const invokedAsProgram = (() => {
|
|
1775
|
+
const entry = process.argv[1];
|
|
1776
|
+
if (!entry)
|
|
1777
|
+
return true;
|
|
1778
|
+
if (/(^|[\\/])(cli\.(js|mjs|ts)|decidio(\.cmd|\.ps1)?)$/i.test(entry))
|
|
1779
|
+
return true;
|
|
1780
|
+
try {
|
|
1781
|
+
return toFileUrl(realPath(entry)).href === toFileUrl(realPath(fromFileUrl(import.meta.url))).href;
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
return true;
|
|
1785
|
+
}
|
|
1786
|
+
})();
|
|
1787
|
+
if (invokedAsProgram)
|
|
1788
|
+
main(process.argv).then((code) => { if (code !== 0)
|
|
1789
|
+
process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
|
package/dist/inngest.d.ts
CHANGED
|
@@ -31,13 +31,21 @@ export interface InngestClientLike {
|
|
|
31
31
|
* `confirmation` is CARRIED rather than discarded. It used to be thrown away inside the confirm
|
|
32
32
|
* step, which made "it ran and Decidio recorded it" and "it ran and Decidio does not know"
|
|
33
33
|
* indistinguishable to the caller. */
|
|
34
|
-
export type GateStepOutcome<R> =
|
|
35
|
-
|
|
34
|
+
export type GateStepOutcome<R> =
|
|
35
|
+
/** The action ran: the value AND what Decidio recorded (the step confirms before it returns). Encoded in the
|
|
36
|
+
* type, not only in prose (prodsec V040-02): narrow on `status` and `confirmation` is there. */
|
|
37
|
+
{
|
|
38
|
+
status: "executed";
|
|
36
39
|
decisionId: string;
|
|
37
|
-
value
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
value: R;
|
|
41
|
+
confirmation: EvidenceOutcome;
|
|
42
|
+
}
|
|
43
|
+
/** The action never ran — a human said no, or a rule blocked it — so there is nothing to confirm. */
|
|
44
|
+
| {
|
|
45
|
+
status: "rejected" | "blocked";
|
|
46
|
+
decisionId: string;
|
|
47
|
+
value?: undefined;
|
|
48
|
+
confirmation?: undefined;
|
|
41
49
|
};
|
|
42
50
|
/**
|
|
43
51
|
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
package/dist/resume.d.ts
CHANGED
|
@@ -455,7 +455,13 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
455
455
|
* to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
|
|
456
456
|
* `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
|
|
457
457
|
* gains a case per release, which the changelog announces. */
|
|
458
|
-
|
|
458
|
+
/** `lost_race` (0.4.1): this signal lost a durable transition race to ANOTHER ATTEMPT that is still in flight
|
|
459
|
+
* (it holds the pre-invocation claim, or crossed the invocation boundary first), or the boundary could not be
|
|
460
|
+
* recorded at all - and it did NOTHING. Nothing ran on this signal; the winner reports, or the entry stays
|
|
461
|
+
* parked for a later signal. It used to read `unknown` (the word for "not ours") for the claim loser and
|
|
462
|
+
* `no_handler` for the boundary loser although a handler was registered (prodsec V040-01). A loss to a FINAL
|
|
463
|
+
* answer (a denial, an execution) stays `already_resolved`. Additive, like the two above. */
|
|
464
|
+
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved" | "lost_race";
|
|
459
465
|
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
460
466
|
* one nobody likes. */
|
|
461
467
|
export type RecoveryState =
|
package/dist/resume.js
CHANGED
|
@@ -951,7 +951,7 @@ export function createDecidioResume(opts) {
|
|
|
951
951
|
if (refusalWarned.has(decisionId))
|
|
952
952
|
return;
|
|
953
953
|
refusalWarned.add(decisionId);
|
|
954
|
-
console.error(msg + (tail ?? " (This prints once — the entry stays parked until you act.)"));
|
|
954
|
+
console.error(msg + (tail ?? " (This prints once per worker process — the entry stays parked until you act.)"));
|
|
955
955
|
};
|
|
956
956
|
const ctrl = {
|
|
957
957
|
...opts,
|
|
@@ -991,8 +991,18 @@ export function createDecidioResume(opts) {
|
|
|
991
991
|
if (idProblem)
|
|
992
992
|
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
993
993
|
const pending = await ctrl.store.get(decisionId);
|
|
994
|
-
if (!pending)
|
|
994
|
+
if (!pending) {
|
|
995
|
+
const head = await ctrl.store.getExecution(decisionId).catch(() => undefined);
|
|
996
|
+
if (head?.state === "discarded")
|
|
997
|
+
return { status: "already_resolved", decisionId, reason: `this decision was DENIED (${head.verdict ?? "rejected"}); that answer is final and this signal did nothing` };
|
|
998
|
+
if (head?.state === "executed_unreported" || head?.state === "reported")
|
|
999
|
+
return { status: "already_resolved", decisionId, reason: `this decision already ran (durable state ${head.state}); this signal arrived after it and did nothing` };
|
|
1000
|
+
if (head?.state === "invoking" || head?.state === "indeterminate")
|
|
1001
|
+
return { status: "indeterminate", decisionId, reason: `this decision is in durable state ${head.state}: its action may still be running or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).` };
|
|
1002
|
+
if (head?.state === "claimed_preinvoke")
|
|
1003
|
+
return { status: "lost_race", decisionId, reason: `another attempt holds the pre-invocation claim on this decision; this signal did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` };
|
|
995
1004
|
return { status: "unknown", decisionId };
|
|
1005
|
+
}
|
|
996
1006
|
if (verdict === "rejected" || verdict === "blocked") {
|
|
997
1007
|
await ctrl.store.markDiscarded(decisionId, verdict, reason);
|
|
998
1008
|
await ctrl.store.delete(decisionId);
|
|
@@ -1036,7 +1046,7 @@ export function createDecidioResume(opts) {
|
|
|
1036
1046
|
if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
|
|
1037
1047
|
refuseOnce(decisionId, claim.state === "discarded"
|
|
1038
1048
|
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1039
|
-
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
|
|
1049
|
+
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once per worker process. The decision is closed — no action is needed.)" : undefined);
|
|
1040
1050
|
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1041
1051
|
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
1042
1052
|
? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
|
|
@@ -1044,7 +1054,7 @@ export function createDecidioResume(opts) {
|
|
|
1044
1054
|
}
|
|
1045
1055
|
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1046
1056
|
}
|
|
1047
|
-
return { status: "
|
|
1057
|
+
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt holds the pre-invocation claim on this decision (durable state ${claim.state}); this signal lost the race and did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` });
|
|
1048
1058
|
}
|
|
1049
1059
|
try {
|
|
1050
1060
|
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
@@ -1052,7 +1062,14 @@ export function createDecidioResume(opts) {
|
|
|
1052
1062
|
catch (e) {
|
|
1053
1063
|
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1054
1064
|
console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
|
|
1055
|
-
|
|
1065
|
+
const head = await ctrl.store.getExecution(decisionId).catch(() => undefined);
|
|
1066
|
+
if (head?.state === "discarded")
|
|
1067
|
+
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: `this decision was DENIED (${head.verdict ?? "rejected"}) while this signal was about to invoke; that answer is final and nothing ran` });
|
|
1068
|
+
if (head?.state === "executed_unreported" || head?.state === "reported")
|
|
1069
|
+
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: `this decision already ran (durable state ${head.state}); this signal lost the race to it and did nothing` });
|
|
1070
|
+
if (head?.state === "invoking" || head?.state === "indeterminate")
|
|
1071
|
+
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt crossed the invocation boundary first (durable state ${head.state}); this signal did nothing - the winner reports, or reconcile() settles it` });
|
|
1072
|
+
return finish({ status: "lost_race", decisionId, action: pending.action, reason: `could not durably record the invocation boundary (${e?.message ?? e}); nothing ran, the entry stays parked, and a later signal may retry` });
|
|
1056
1073
|
}
|
|
1057
1074
|
let result;
|
|
1058
1075
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@decidio/verify": "0.4.
|
|
86
|
+
"@decidio/verify": "0.4.1"
|
|
87
87
|
},
|
|
88
88
|
"author": "OmniTwin Technologies Inc.",
|
|
89
89
|
"bugs": {
|