@decidio/sdk 0.3.5 → 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 +142 -0
- package/README.md +45 -2
- package/dist/adapters/_gate.d.ts +6 -6
- package/dist/adapters/_gate.js +1 -1
- package/dist/adapters/temporal.d.ts +5 -2
- package/dist/adapters/temporal.js +4 -3
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +347 -92
- package/dist/inngest.d.ts +14 -4
- package/dist/resume.d.ts +12 -4
- package/dist/resume.js +28 -6
- package/dist/signing.d.ts +4 -0
- package/dist/signing.js +5 -2
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Changelog — @decidio/sdk
|
|
2
|
+
|
|
3
|
+
Release notes for the client SDK. The Python package `decidio` ships the same changes under the same
|
|
4
|
+
version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
|
|
5
|
+
on upgrade, and what proved it.
|
|
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
|
+
|
|
57
|
+
## 0.4.0 — the scope statement
|
|
58
|
+
|
|
59
|
+
- **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
|
|
60
|
+
stated: what is stable, what is Beta (the engine adapters), what is deprecated and kept until 1.0
|
|
61
|
+
(`protectBestEffort`, `gateInterruptions`), and the five things reviewers keep finding that only
|
|
62
|
+
product decisions close (trust-on-first-use issuer DID, the unshipped schema, the unshipped
|
|
63
|
+
conformance suites, provenance and the repository link). 1.0.0 follows user-acceptance testing.
|
|
64
|
+
- **The tour fails closed on an unpinned check.** If the API omits the issuer DID, the tour used to warn
|
|
65
|
+
and still report success; it now fails and cleans up.
|
|
66
|
+
- **The tour's preflight names everything it writes**, including `.decidio-token-journal` and
|
|
67
|
+
`.decidio-pending/`.
|
|
68
|
+
- **`--` ends option parsing on all three verifier doors**, so a receipt saved under a dash-leading
|
|
69
|
+
name can be verified.
|
|
70
|
+
- **The token recovery journal treats corruption as uncertainty, never absence** (prodsec retest of
|
|
71
|
+
0.3.5). Every row is parsed and schema-checked before any mint; a row that cannot be trusted refuses
|
|
72
|
+
`init` and `quickstart`, turns `doctor` red and makes `quickstart cleanup` exit non-zero - the bytes are
|
|
73
|
+
left as found. Appends are framed (a delimiter first if the previous row lost its newline, a schema
|
|
74
|
+
version, a write loop before the fsync), so a torn row stays discoverable.
|
|
75
|
+
- **A resume signal that finds the decision `invoking` answers `indeterminate`**, not `already_resolved`
|
|
76
|
+
with "already ran": the action may still be running, may have crashed before any effect, or may have
|
|
77
|
+
finished without reporting. `already_resolved` is reserved for the final states.
|
|
78
|
+
- **Temporal (Beta): a custom `confirm` may return the gate's confirm outcome** (validated with
|
|
79
|
+
`readConfirmResponse`) and it becomes the step's confirmation instead of being discarded.
|
|
80
|
+
- **Transport: every refusal redacts URL userinfo**, including the opaque `scheme:user:secret@` spelling,
|
|
81
|
+
and Unicode space and format characters (NBSP, ideographic space, zero-width space, …) are refused in
|
|
82
|
+
both runtimes from one explicit list. Four cases joined the shared transport corpus.
|
|
83
|
+
- **This file ships** in the tarball (and in the Python sdist), as a deliberate publish-boundary line.
|
|
84
|
+
|
|
85
|
+
## 0.3.5 — the retest residuals
|
|
86
|
+
|
|
87
|
+
- **Transport:** any control character or whitespace anywhere in the URL is refused before anything is
|
|
88
|
+
sent; refusals echo a redacted spelling (URL userinfo is a credential).
|
|
89
|
+
- **Credential lifecycle:** the quickstart inspects a previous `.env.quickstart` before minting; secret
|
|
90
|
+
files are written temp + fsync + rename; **the token recovery journal** (`.decidio-token-journal`,
|
|
91
|
+
owner-only, append-only) records every minted token id before the credential file exists and retires
|
|
92
|
+
it on a durable write or any proven revoke. Credential creation fails closed if the journal cannot be
|
|
93
|
+
written. `quickstart cleanup` sweeps it; `doctor` lists outstanding entries.
|
|
94
|
+
- **Verifiers (all three doors):** the `verificationMethod` must be the issuer's exact `did:key` method
|
|
95
|
+
identifier, and `created` must be a real calendar date-time. Correctly signed receipts naming a
|
|
96
|
+
nonexistent method fragment or an impossible time are now INVALID.
|
|
97
|
+
- **Adapter gate:** the same describe validation as the core (a blank action or negative amount is
|
|
98
|
+
refused before signing or posting).
|
|
99
|
+
- **Outcomes:** a resume signal that lost the race to a final answer is `already_resolved`; a parked row
|
|
100
|
+
whose payload is gone is `orphaned_state`.
|
|
101
|
+
- Upgrade: no action. Custom stores are unaffected.
|
|
102
|
+
|
|
103
|
+
## 0.3.4 — first-run consistency
|
|
104
|
+
|
|
105
|
+
- Tour and README pointers, routing-by-default vs the universal-backstop block named as two states, the
|
|
106
|
+
stale "unsafe values park and fail on resume" paragraph corrected, cleanup discloses the durable
|
|
107
|
+
store, help never loads `.env` (and only when usage will actually print), adapter headers current.
|
|
108
|
+
- Upgrade: no action.
|
|
109
|
+
|
|
110
|
+
## 0.3.2 / 0.3.3 — the transition itself is atomic (COORDINATED UPGRADE)
|
|
111
|
+
|
|
112
|
+
- **The shipped file stores are an append-only revision chain** (`<key>.rev.<N>`), published with a
|
|
113
|
+
hard link that refuses an existing name: exactly one writer wins each transition, and a loser
|
|
114
|
+
re-validates against what actually happened. This closed the pre-0.3.2 race in which a denial could
|
|
115
|
+
be written and returned and the denied action still run.
|
|
116
|
+
- **Upgrade — read this if a directory is shared by more than one worker:** the first 0.3.2+ store
|
|
117
|
+
migrates the older marker layout one way. **Stop every older worker that shares the directory before
|
|
118
|
+
the first 0.3.2+ process starts**, then upgrade them together. An older worker writing into an
|
|
119
|
+
upgraded directory leaves a marker beside the chain, and that decision reads as unreadable by design.
|
|
120
|
+
Nothing deletes a revision; each decision keeps a handful of small files (see the README's sizing
|
|
121
|
+
envelope).
|
|
122
|
+
- 0.3.3: one snapshot of `describe()`'s answer for signing and posting; the shared id grammar at every
|
|
123
|
+
exported resume door; a compensating revoke when a minted token's credential file fails to write;
|
|
124
|
+
`reconfirm()` on a reported decision tells the truth about confirmation.
|
|
125
|
+
|
|
126
|
+
## 0.3.1
|
|
127
|
+
|
|
128
|
+
- The verifier says what it proves (canonical signed claims, not exact bytes); proof options
|
|
129
|
+
(`proofPurpose`, `verificationMethod`, `created`) are part of the claim on every door; duplicate
|
|
130
|
+
`--issuer` flags are refused everywhere; orphaned token ids survive every replacement.
|
|
131
|
+
|
|
132
|
+
## 0.3.0
|
|
133
|
+
|
|
134
|
+
- A claim that gates nothing: `reportRecorded` and `executionConfirmed` are distinct, honest booleans.
|
|
135
|
+
A denial is final on every path. The decision-id grammar is defined server-side and mirrored here.
|
|
136
|
+
- Upgrade for custom stores: `markDiscarded` is required (a hard construction failure without it).
|
|
137
|
+
|
|
138
|
+
## 0.2.x
|
|
139
|
+
|
|
140
|
+
- The execution state machine (`parked → claimed_preinvoke → invoking → executed_unreported |
|
|
141
|
+
indeterminate → reported`), `reconcile()`, `ExecutionContext` with a stable idempotency key, and the
|
|
142
|
+
composite return (`{ value, decisionId, confirmation }`) with `protectBestEffort` keeping the old shape.
|
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another De
|
|
|
66
66
|
`init` signs you into your workspace (email + password, or paste the sign-in link from your
|
|
67
67
|
workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
|
|
68
68
|
is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
|
|
69
|
-
Every later command reads `.env` from this directory automatically
|
|
69
|
+
Every later command that talks to Decidio reads `.env` from this directory automatically (`verify` is
|
|
70
|
+
offline and never does). Re-running init for an
|
|
70
71
|
agent that already has a token REPLACES it — the server keeps one active token per agent, so
|
|
71
72
|
the previous one is revoked and init says so.
|
|
72
73
|
|
|
@@ -80,7 +81,10 @@ fresh one (or pass `--unique` to auto-append a random suffix — collision-proof
|
|
|
80
81
|
workspaces and CI). Headless terminal (no prompts possible)? Export `DECIDIO_SESSION_TOKEN` first:
|
|
81
82
|
request a sign-in link, then exchange its `?login=` value at `POST /demo/login-consume` — the
|
|
82
83
|
response's `token` is your session token (the CLI prints these exact instructions when it
|
|
83
|
-
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.
|
|
84
88
|
|
|
85
89
|
**3. Protect one function** — save this as `quickstart.mjs`:
|
|
86
90
|
|
|
@@ -677,6 +681,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
|
|
|
677
681
|
timed-out action is always findable in
|
|
678
682
|
your queue.
|
|
679
683
|
|
|
684
|
+
## What 0.4 does not yet claim
|
|
685
|
+
|
|
686
|
+
The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
|
|
687
|
+
0.3.3 and 0.3.5 agree on what is left, and none of it is a defect a patch release closes. Stated here so
|
|
688
|
+
the next reader grades a promise, not a moving target:
|
|
689
|
+
|
|
690
|
+
- **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
|
|
691
|
+
battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
|
|
692
|
+
exists yet. Use the core path for production until it does.
|
|
693
|
+
- **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
|
|
694
|
+
from the same authenticated response that carries the receipt. Pinned verification is only as strong
|
|
695
|
+
as where you keep that DID: record it through a channel you trust independently (your app's
|
|
696
|
+
record-verify panel today; a published well-known location is on the roadmap).
|
|
697
|
+
- **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
|
|
698
|
+
canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
|
|
699
|
+
structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
|
|
700
|
+
the roadmap.
|
|
701
|
+
- **The conformance suites are not in the package.** The deterministic two-process schedules that
|
|
702
|
+
prove the durable store, and the corpus every door is held to, live in the Decidio source repository.
|
|
703
|
+
A customer cannot rerun them from this tarball; a public conformance kit is on the roadmap.
|
|
704
|
+
- **No provenance attestation, no repository link.** The source repository is private; npm refuses
|
|
705
|
+
provenance for private repositories, and a link that 404s is worse than none. Registry signatures and
|
|
706
|
+
lockfile integrity are what you can check today.
|
|
707
|
+
|
|
708
|
+
## Compatibility and support
|
|
709
|
+
|
|
710
|
+
- **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
|
|
711
|
+
controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
|
|
712
|
+
and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
|
|
713
|
+
revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
|
|
714
|
+
`@decidio/sdk/resume` subpath), additive growth only; the error classes; the CLI's commands, flags and exit codes; the receipt verifier's verdict and
|
|
715
|
+
reasons. Changes to any of these announce themselves in the changelog first.
|
|
716
|
+
- **Beta:** the engine adapters (above).
|
|
717
|
+
- **Deprecated, kept until 1.0:** `protectBestEffort` (the value-only shape) and the old adapter
|
|
718
|
+
name `gateInterruptions`. They will be removed in 1.0.0, not before.
|
|
719
|
+
- **Versioning:** 0.x minor releases may still carry deliberate breaking cleanups, announced in the
|
|
720
|
+
changelog with the upgrade step. 1.0.0 follows user-acceptance testing of this release and freezes the
|
|
721
|
+
surface above.
|
|
722
|
+
|
|
680
723
|
## Notes
|
|
681
724
|
|
|
682
725
|
- **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
|
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
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome } from "../index.js";
|
|
2
2
|
import { type GateAuth } from "./_gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
@@ -23,7 +23,10 @@ export declare function installDecidioSignal(): Map<string, {
|
|
|
23
23
|
}>;
|
|
24
24
|
export interface DecidioGateOpts<R = unknown> {
|
|
25
25
|
authorize?: () => Promise<GateAuth>;
|
|
26
|
-
|
|
26
|
+
/** Reports through YOUR activity. Return the gate's confirm response body (validated with
|
|
27
|
+
* `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
|
|
28
|
+
* not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
|
|
29
|
+
confirm?: (decisionId: string, result: unknown) => Promise<EvidenceOutcome | void>;
|
|
27
30
|
/** Map your action's return value to the evidence Decidio records — the same option the core
|
|
28
31
|
* path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
|
|
29
32
|
* path the advice could not be followed. */
|
|
@@ -25,12 +25,13 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
25
25
|
if (opts.confirm) {
|
|
26
26
|
if (opts.evidence)
|
|
27
27
|
console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
|
|
28
|
-
await opts.confirm(auth.decisionId, result);
|
|
29
|
-
|
|
28
|
+
const returned = await opts.confirm(auth.decisionId, result);
|
|
29
|
+
const usable = !!returned && typeof returned === "object" && typeof returned.reportRecorded === "boolean";
|
|
30
|
+
confirmation = usable ? returned : {
|
|
30
31
|
reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
|
|
31
32
|
diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
|
|
32
33
|
};
|
|
33
|
-
if (!confirmWarned) {
|
|
34
|
+
if (!usable && !confirmWarned) {
|
|
34
35
|
confirmWarned = true;
|
|
35
36
|
console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
|
|
36
37
|
}
|
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 {
|
|
@@ -168,11 +170,25 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
|
168
170
|
return false;
|
|
169
171
|
}
|
|
170
172
|
const JOURNAL_PATH = ".decidio-token-journal";
|
|
173
|
+
const JOURNAL_SCHEMA = 1;
|
|
174
|
+
const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
|
|
171
175
|
function journalAppend(entry) {
|
|
172
176
|
const fresh = !existsSync(JOURNAL_PATH);
|
|
177
|
+
const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
|
|
173
178
|
const fd = openSync(JOURNAL_PATH, "a", 0o600);
|
|
174
179
|
try {
|
|
175
|
-
|
|
180
|
+
if (!fresh) {
|
|
181
|
+
const st = readFileSync(JOURNAL_PATH);
|
|
182
|
+
if (st.length && st[st.length - 1] !== 0x0a) {
|
|
183
|
+
let w = 0;
|
|
184
|
+
const nl = Buffer.from("\n");
|
|
185
|
+
while (w < nl.length)
|
|
186
|
+
w += writeSync(fd, nl, w);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let written = 0;
|
|
190
|
+
while (written < row.length)
|
|
191
|
+
written += writeSync(fd, row, written);
|
|
176
192
|
fsyncSync(fd);
|
|
177
193
|
}
|
|
178
194
|
finally {
|
|
@@ -186,7 +202,86 @@ function journalAppend(entry) {
|
|
|
186
202
|
hardenFile(JOURNAL_PATH);
|
|
187
203
|
}
|
|
188
204
|
}
|
|
205
|
+
function journalRowProblem(e) {
|
|
206
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
207
|
+
return "not a JSON object";
|
|
208
|
+
if (e.v !== undefined && e.v !== JOURNAL_SCHEMA)
|
|
209
|
+
return `schema version ${JSON.stringify(e.v)} is not ${JOURNAL_SCHEMA}`;
|
|
210
|
+
if (typeof e.event !== "string" || !JOURNAL_EVENTS.has(e.event))
|
|
211
|
+
return `unknown event ${JSON.stringify(e.event ?? null)}`;
|
|
212
|
+
if (e.event !== "probe" && (typeof e.jti !== "string" || !e.jti.trim()))
|
|
213
|
+
return `${e.event} row without a string jti`;
|
|
214
|
+
if (e.event === "retired" && e.how !== "persisted" && e.how !== "revoked")
|
|
215
|
+
return `retired row with how=${JSON.stringify(e.how ?? null)}`;
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
class JournalCorruptError extends Error {
|
|
219
|
+
line;
|
|
220
|
+
why;
|
|
221
|
+
constructor(line, why) {
|
|
222
|
+
super(`${JOURNAL_PATH} line ${line}: ${why}`);
|
|
223
|
+
this.line = line;
|
|
224
|
+
this.why = why;
|
|
225
|
+
this.name = "JournalCorruptError";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function journalRows() {
|
|
229
|
+
if (!existsSync(JOURNAL_PATH))
|
|
230
|
+
return [];
|
|
231
|
+
const rows = [];
|
|
232
|
+
const lines = readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/);
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
234
|
+
const line = lines[i];
|
|
235
|
+
if (!line.trim())
|
|
236
|
+
continue;
|
|
237
|
+
let e;
|
|
238
|
+
try {
|
|
239
|
+
e = JSON.parse(line);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new JournalCorruptError(i + 1, "not valid JSON (a torn or hand-edited row)");
|
|
243
|
+
}
|
|
244
|
+
const why = journalRowProblem(e);
|
|
245
|
+
if (why)
|
|
246
|
+
throw new JournalCorruptError(i + 1, why);
|
|
247
|
+
rows.push(e);
|
|
248
|
+
}
|
|
249
|
+
return rows;
|
|
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
|
+
}
|
|
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.`;
|
|
189
278
|
function journalProbe() {
|
|
279
|
+
try {
|
|
280
|
+
journalRows();
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
return `CORRUPT: ${e?.message ?? e}`;
|
|
284
|
+
}
|
|
190
285
|
try {
|
|
191
286
|
journalAppend({ event: "probe" });
|
|
192
287
|
return null;
|
|
@@ -201,27 +296,23 @@ function journalRetire(jti, how) { try {
|
|
|
201
296
|
}
|
|
202
297
|
catch { } }
|
|
203
298
|
function journalOutstanding() {
|
|
204
|
-
if (!existsSync(JOURNAL_PATH))
|
|
205
|
-
return [];
|
|
206
299
|
const minted = new Map();
|
|
207
|
-
for (const
|
|
208
|
-
if (
|
|
209
|
-
continue;
|
|
210
|
-
let e;
|
|
211
|
-
try {
|
|
212
|
-
e = JSON.parse(line);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (e?.event === "minted" && typeof e.jti === "string")
|
|
300
|
+
for (const e of journalRows()) {
|
|
301
|
+
if (e.event === "minted")
|
|
218
302
|
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
219
|
-
if (e
|
|
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")
|
|
220
309
|
minted.delete(e.jti);
|
|
221
310
|
}
|
|
222
311
|
return [...minted.values()];
|
|
223
312
|
}
|
|
224
|
-
const REFUSE_MINT_NO_JOURNAL = (why) =>
|
|
313
|
+
const REFUSE_MINT_NO_JOURNAL = (why) => why.startsWith("CORRUPT: ")
|
|
314
|
+
? REFUSE_MINT_CORRUPT_JOURNAL(new Error(why.slice("CORRUPT: ".length)))
|
|
315
|
+
: `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
|
|
225
316
|
function upsertEnv(updates) {
|
|
226
317
|
const remaining = { ...updates };
|
|
227
318
|
const updatedKeys = new Set();
|
|
@@ -279,7 +370,19 @@ function ensureGitignore() {
|
|
|
279
370
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
280
371
|
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
281
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
|
+
}
|
|
282
383
|
function ask(question, mute = false) {
|
|
384
|
+
if (process.stdin.readableEnded || process.stdin.destroyed)
|
|
385
|
+
return Promise.resolve("");
|
|
283
386
|
return new Promise((resolve) => {
|
|
284
387
|
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
285
388
|
if (mute) {
|
|
@@ -288,8 +391,9 @@ function ask(question, mute = false) {
|
|
|
288
391
|
anyRl._writeToOutput = (s) => { if (s.includes(question))
|
|
289
392
|
write?.(s); };
|
|
290
393
|
}
|
|
291
|
-
rl.
|
|
292
|
-
|
|
394
|
+
rl.once("close", () => resolve(""));
|
|
395
|
+
rl.question(question, (answer) => { resolve(answer.trim()); rl.close(); if (mute)
|
|
396
|
+
process.stderr.write("\n"); });
|
|
293
397
|
});
|
|
294
398
|
}
|
|
295
399
|
async function acquireSession(purpose) {
|
|
@@ -514,15 +618,19 @@ async function cmdDoctor() {
|
|
|
514
618
|
let warned = 0;
|
|
515
619
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
516
620
|
const warn = (label) => { warned++; log(`⚠ ${label}`); };
|
|
517
|
-
let
|
|
621
|
+
let journalItems = [];
|
|
518
622
|
try {
|
|
519
|
-
|
|
623
|
+
journalItems = journalOutstanding();
|
|
520
624
|
}
|
|
521
625
|
catch (e) {
|
|
522
|
-
|
|
626
|
+
line(false, `token recovery journal: ${e?.message ?? e} — it may hold the id of a live token, and this directory cannot mint until it is repaired or moved aside (every byte is left as found).`);
|
|
523
627
|
}
|
|
524
|
-
for (const j of
|
|
628
|
+
for (const j of journalItems) {
|
|
525
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
|
+
}
|
|
526
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})`);
|
|
527
635
|
}
|
|
528
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)"}`);
|
|
@@ -762,6 +870,10 @@ function cmdVerify(argsIn) {
|
|
|
762
870
|
const files = [];
|
|
763
871
|
for (let i = 0; i < argsIn.length; i++) {
|
|
764
872
|
const a = argsIn[i];
|
|
873
|
+
if (a === "--") {
|
|
874
|
+
files.push(...argsIn.slice(i + 1));
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
765
877
|
if (a === "--issuer") {
|
|
766
878
|
const v = argsIn[i + 1];
|
|
767
879
|
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
@@ -817,8 +929,11 @@ async function cmdQuickstart(opts = {}) {
|
|
|
817
929
|
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
818
930
|
log("");
|
|
819
931
|
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
820
|
-
log(" write .env (or .env.quickstart)
|
|
821
|
-
log("
|
|
932
|
+
log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
|
|
933
|
+
log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
|
|
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.");
|
|
822
937
|
log("");
|
|
823
938
|
const session = await acquireSession("The quickstart");
|
|
824
939
|
if (!session)
|
|
@@ -847,7 +962,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
847
962
|
else {
|
|
848
963
|
err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
|
|
849
964
|
}
|
|
850
|
-
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).`);
|
|
851
966
|
return 1;
|
|
852
967
|
}
|
|
853
968
|
let prevTourFile = null;
|
|
@@ -894,8 +1009,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
894
1009
|
if (prevId && prevId !== agentId) {
|
|
895
1010
|
if (prevJti) {
|
|
896
1011
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
897
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1012
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1013
|
+
journalRetire(prevJti, "revoked");
|
|
898
1014
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
1015
|
+
}
|
|
899
1016
|
else {
|
|
900
1017
|
carried.add(prevJti);
|
|
901
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}`);
|
|
@@ -986,8 +1103,26 @@ async function cmdQuickstart(opts = {}) {
|
|
|
986
1103
|
log(` Nothing executes until someone decides.`);
|
|
987
1104
|
log("");
|
|
988
1105
|
const interactive = process.stdin.isTTY;
|
|
1106
|
+
let verdict = "approve";
|
|
989
1107
|
if (interactive) {
|
|
990
|
-
|
|
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
|
+
}
|
|
991
1126
|
}
|
|
992
1127
|
else if (opts.autoApproveDemo) {
|
|
993
1128
|
log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
|
|
@@ -1014,61 +1149,119 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1014
1149
|
log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
|
|
1015
1150
|
process.exit(3);
|
|
1016
1151
|
}
|
|
1017
|
-
const
|
|
1018
|
-
if (
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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")}`);
|
|
1030
1194
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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`);
|
|
1040
1264
|
}
|
|
1041
|
-
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
1042
|
-
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
1043
|
-
const issuer = rec.body?.issuer;
|
|
1044
|
-
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1045
|
-
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
|
|
1046
|
-
if (!issuer) {
|
|
1047
|
-
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
1048
|
-
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1049
|
-
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
1050
|
-
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1051
|
-
}
|
|
1052
|
-
if (issuer)
|
|
1053
|
-
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
1054
|
-
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
1055
|
-
const tier = detail.body?.record?.confirmation?.status;
|
|
1056
|
-
if (tier)
|
|
1057
|
-
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.)`);
|
|
1058
|
-
log("");
|
|
1059
|
-
const secs = Math.round((Date.now() - t0) / 1000);
|
|
1060
|
-
log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
|
|
1061
|
-
log("");
|
|
1062
|
-
log("Take it to your real agent — the .env here already works:");
|
|
1063
|
-
log(` import { guard } from "@decidio/sdk";`);
|
|
1064
|
-
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
1065
|
-
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
1066
|
-
log("");
|
|
1067
|
-
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1068
|
-
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1069
|
-
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1070
|
-
log(`already works here, so skip that flow's init step.`);
|
|
1071
|
-
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
1072
1265
|
let doCleanup = false;
|
|
1073
1266
|
if (interactive) {
|
|
1074
1267
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -1081,7 +1274,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1081
1274
|
log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
|
|
1082
1275
|
}
|
|
1083
1276
|
const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
|
|
1084
|
-
return
|
|
1277
|
+
return cleanupCode || 0;
|
|
1085
1278
|
}
|
|
1086
1279
|
async function quickstartCleanup(session, envFile) {
|
|
1087
1280
|
const env = (() => {
|
|
@@ -1101,7 +1294,27 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1101
1294
|
})();
|
|
1102
1295
|
let journalRevoked = 0;
|
|
1103
1296
|
const journalStuck = [];
|
|
1104
|
-
|
|
1297
|
+
let outstanding = [];
|
|
1298
|
+
let heldElsewhere = [];
|
|
1299
|
+
let unheld = [];
|
|
1300
|
+
let journalReconciled = 0;
|
|
1301
|
+
try {
|
|
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));
|
|
1308
|
+
}
|
|
1309
|
+
catch (e) {
|
|
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.`);
|
|
1311
|
+
return 1;
|
|
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}`);
|
|
1317
|
+
for (const j of outstanding) {
|
|
1105
1318
|
if (j.apiUrl && j.apiUrl !== API) {
|
|
1106
1319
|
journalStuck.push(j.jti);
|
|
1107
1320
|
err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
@@ -1110,11 +1323,14 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1110
1323
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
|
|
1111
1324
|
if (rv.status >= 200 && rv.status < 300) {
|
|
1112
1325
|
journalRetire(j.jti, "revoked");
|
|
1113
|
-
|
|
1114
|
-
|
|
1326
|
+
if (rv.body?.changed === false) {
|
|
1327
|
+
journalReconciled++;
|
|
1115
1328
|
log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
+
}
|
|
1118
1334
|
}
|
|
1119
1335
|
else {
|
|
1120
1336
|
journalStuck.push(j.jti);
|
|
@@ -1131,8 +1347,10 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1131
1347
|
const stillOrphaned = [];
|
|
1132
1348
|
for (const jti of orphans) {
|
|
1133
1349
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
|
|
1134
|
-
if (rv.status >= 200 && rv.status < 300)
|
|
1350
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1351
|
+
journalRetire(jti, "revoked");
|
|
1135
1352
|
log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
|
|
1353
|
+
}
|
|
1136
1354
|
else {
|
|
1137
1355
|
stillOrphaned.push(jti);
|
|
1138
1356
|
err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
|
|
@@ -1146,6 +1364,8 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1146
1364
|
}
|
|
1147
1365
|
if (env.DECIDIO_TOKEN_JTI) {
|
|
1148
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");
|
|
1149
1369
|
if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
|
|
1150
1370
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
1151
1371
|
else if (rev.status >= 200 && rev.status < 300)
|
|
@@ -1160,6 +1380,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1160
1380
|
else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
|
|
1161
1381
|
log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
|
|
1162
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
|
+
}
|
|
1163
1389
|
else {
|
|
1164
1390
|
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
1165
1391
|
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
@@ -1203,6 +1429,8 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1203
1429
|
}
|
|
1204
1430
|
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
1205
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.`);
|
|
1206
1434
|
if (existsSync(".decidio-pending")) {
|
|
1207
1435
|
log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
|
|
1208
1436
|
log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
|
|
@@ -1280,6 +1508,13 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1280
1508
|
return 0;
|
|
1281
1509
|
}
|
|
1282
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
|
+
}
|
|
1283
1518
|
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
1284
1519
|
createServer((req, res) => {
|
|
1285
1520
|
if (req.method !== "POST") {
|
|
@@ -1437,7 +1672,12 @@ async function main(argv) {
|
|
|
1437
1672
|
if (argsIn.includes("cleanup")) {
|
|
1438
1673
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1439
1674
|
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1440
|
-
:
|
|
1675
|
+
: (() => { try {
|
|
1676
|
+
return (journalOwed().length || journalUnheld().length) ? ".env.quickstart" : undefined;
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
return ".env.quickstart";
|
|
1680
|
+
} })();
|
|
1441
1681
|
if (!envFile) {
|
|
1442
1682
|
log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
|
|
1443
1683
|
return 0;
|
|
@@ -1458,7 +1698,8 @@ async function main(argv) {
|
|
|
1458
1698
|
err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
|
|
1459
1699
|
return 2;
|
|
1460
1700
|
}
|
|
1461
|
-
const
|
|
1701
|
+
const ddIdx = argsIn.indexOf("--");
|
|
1702
|
+
const nameArg = ddIdx >= 0 ? argsIn[ddIdx + 1] : argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
|
|
1462
1703
|
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
1463
1704
|
if (unique)
|
|
1464
1705
|
name = `${name}-${randomBytes(2).toString("hex")}`;
|
|
@@ -1530,5 +1771,19 @@ async function main(argv) {
|
|
|
1530
1771
|
return 2;
|
|
1531
1772
|
}
|
|
1532
1773
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
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,11 +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
|
-
confirmation
|
|
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;
|
|
39
49
|
};
|
|
40
50
|
/**
|
|
41
51
|
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
package/dist/resume.d.ts
CHANGED
|
@@ -450,10 +450,18 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
450
450
|
/** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
|
|
451
451
|
* effect committed is not knowable from here, so the action is never retried automatically —
|
|
452
452
|
* it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
|
|
453
|
-
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier,
|
|
454
|
-
* (a denial,
|
|
455
|
-
*
|
|
456
|
-
|
|
453
|
+
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, FINAL answer
|
|
454
|
+
* (a denial, an execution that already ran, or a report already recorded) — `reason` names which. It used
|
|
455
|
+
* to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
|
|
456
|
+
* `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
|
|
457
|
+
* gains a case per release, which the changelog announces. */
|
|
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";
|
|
457
465
|
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
458
466
|
* one nobody likes. */
|
|
459
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,10 +1046,15 @@ 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);
|
|
1040
|
-
|
|
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);
|
|
1050
|
+
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1051
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
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}", …).`
|
|
1053
|
+
: claim.reason });
|
|
1054
|
+
}
|
|
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` });
|
|
1041
1056
|
}
|
|
1042
|
-
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` });
|
|
1043
1058
|
}
|
|
1044
1059
|
try {
|
|
1045
1060
|
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
@@ -1047,7 +1062,14 @@ export function createDecidioResume(opts) {
|
|
|
1047
1062
|
catch (e) {
|
|
1048
1063
|
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1049
1064
|
console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
|
|
1050
|
-
|
|
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` });
|
|
1051
1073
|
}
|
|
1052
1074
|
let result;
|
|
1053
1075
|
try {
|
package/dist/signing.d.ts
CHANGED
|
@@ -78,6 +78,10 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
78
78
|
* (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
|
|
79
79
|
* this, not the raw string (invariant-guard, 0.3.5). */
|
|
80
80
|
export declare function redactUrl(u: string): string;
|
|
81
|
+
/** Unicode SPACE and FORMAT characters a URL must never contain: the Zs/Zl/Zp separators (NBSP, ideographic
|
|
82
|
+
* space, …) and the zero-width formats. Spelled as an explicit list in BOTH runtimes so the answers cannot
|
|
83
|
+
* drift with a library's notion of whitespace (Python's urlsplit strips a leading NBSP; WHATWG does not). */
|
|
84
|
+
export declare const URL_UNICODE_SPACE: RegExp;
|
|
81
85
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
82
86
|
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
83
87
|
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
package/dist/signing.js
CHANGED
|
@@ -106,14 +106,17 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
106
106
|
};
|
|
107
107
|
walk(value, path);
|
|
108
108
|
}
|
|
109
|
-
export function redactUrl(u) {
|
|
109
|
+
export function redactUrl(u) {
|
|
110
|
+
return u.replace(/\/\/[^/?#]*@/, "//***@").replace(/^([^/?#@:]*:)(?!\/\/)[\/\\]*[^/?#]*@/, "$1***@");
|
|
111
|
+
}
|
|
112
|
+
export const URL_UNICODE_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\u200b-\u200d\u2060]/;
|
|
110
113
|
export function assertSafeTransport(url, hasCredential) {
|
|
111
114
|
if (typeof url !== "string") {
|
|
112
115
|
throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
113
116
|
}
|
|
114
117
|
for (const ch of url) {
|
|
115
118
|
const cp = ch.codePointAt(0);
|
|
116
|
-
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff) {
|
|
119
|
+
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff || URL_UNICODE_SPACE.test(ch)) {
|
|
117
120
|
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
|
|
118
121
|
}
|
|
119
122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dist",
|
|
41
41
|
"openapi.yaml",
|
|
42
42
|
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
45
46
|
"engines": {
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
83
84
|
},
|
|
84
85
|
"dependencies": {
|
|
85
|
-
"@decidio/verify": "0.
|
|
86
|
+
"@decidio/verify": "0.4.1"
|
|
86
87
|
},
|
|
87
88
|
"author": "OmniTwin Technologies Inc.",
|
|
88
89
|
"bugs": {
|