@decidio/sdk 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -15
- package/dist/adapters/_gate.d.ts +8 -2
- package/dist/adapters/_gate.js +16 -7
- package/dist/adapters/langgraph.d.ts +3 -1
- package/dist/adapters/langgraph.js +2 -2
- package/dist/adapters/openai.d.ts +7 -2
- package/dist/adapters/openai.js +11 -3
- package/dist/adapters/temporal.d.ts +6 -2
- package/dist/adapters/temporal.js +3 -1
- package/dist/canonical.d.ts +1 -1
- package/dist/canonical.js +8 -4
- package/dist/cli.js +68 -35
- package/dist/errors.d.ts +12 -5
- package/dist/errors.js +37 -5
- package/dist/evidence.d.ts +19 -0
- package/dist/evidence.js +7 -4
- package/dist/guard.d.ts +19 -2
- package/dist/guard.js +7 -5
- package/dist/index.d.ts +27 -2
- package/dist/index.js +32 -12
- package/dist/inngest.d.ts +4 -0
- package/dist/inngest.js +1 -1
- package/dist/resume.d.ts +331 -57
- package/dist/resume.js +678 -139
- package/dist/signing.js +19 -1
- package/openapi.yaml +89 -6
- package/package.json +13 -8
package/dist/resume.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
|
|
2
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
1
|
+
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync, openSync, closeSync, fsyncSync, linkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -42,34 +42,202 @@ function verifyArgs(p, agentKey, webhookSecret) {
|
|
|
42
42
|
return "mismatch";
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
const evidenceBindingBytes = (decisionId, attemptId, evidence) => Buffer.from(jcs({ decisionId, attemptId: attemptId ?? null, evidence: evidence ?? null }), "utf8");
|
|
46
|
+
export function signEvidence(decisionId, attemptId, evidence, agentKey, webhookSecret) {
|
|
47
|
+
const bytes = evidenceBindingBytes(decisionId, attemptId, evidence);
|
|
48
|
+
if (agentKey) {
|
|
49
|
+
try {
|
|
50
|
+
const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
|
|
51
|
+
return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
if (webhookSecret)
|
|
56
|
+
return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
export function verifyEvidence(rec, agentKey, webhookSecret) {
|
|
60
|
+
if (!rec.evidenceSig)
|
|
61
|
+
return "unbound";
|
|
62
|
+
const bytes = evidenceBindingBytes(rec.decisionId, rec.attemptId, rec.evidence ?? null);
|
|
63
|
+
try {
|
|
64
|
+
if (rec.evidenceSig.kind === "ed25519") {
|
|
65
|
+
if (!agentKey)
|
|
66
|
+
return "unverifiable";
|
|
67
|
+
const pub = createPublicKey(createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" }));
|
|
68
|
+
return edVerify(null, bytes, pub, Buffer.from(rec.evidenceSig.sig, "base64url")) ? "ok" : "mismatch";
|
|
69
|
+
}
|
|
70
|
+
if (!webhookSecret)
|
|
71
|
+
return "unverifiable";
|
|
72
|
+
const expected = createHmac("sha256", webhookSecret).update(bytes).digest("hex");
|
|
73
|
+
const x = Buffer.from(expected), y = Buffer.from(rec.evidenceSig.sig);
|
|
74
|
+
return x.length === y.length && timingSafeEqual(x, y) ? "ok" : "mismatch";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "mismatch";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export const EXECUTION_STATES = [
|
|
81
|
+
"parked", "claimed_preinvoke", "invoking", "executed_unreported", "indeterminate", "reported",
|
|
82
|
+
];
|
|
83
|
+
export const NON_REPLAYABLE = ["invoking", "executed_unreported", "indeterminate", "reported"];
|
|
84
|
+
export class DecidioStaleAttemptError extends Error {
|
|
85
|
+
decisionId;
|
|
86
|
+
owner;
|
|
87
|
+
state;
|
|
88
|
+
constructor(decisionId, owner, state) {
|
|
89
|
+
super(`[decidio] ${decisionId}: this attempt no longer owns the decision — attempt ${owner ?? "(unknown)"} holds it in state ${state}. Refusing a state write from a superseded attempt.`);
|
|
90
|
+
this.decisionId = decisionId;
|
|
91
|
+
this.owner = owner;
|
|
92
|
+
this.state = state;
|
|
93
|
+
this.name = "DecidioStaleAttemptError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export class DecidioAlreadySettledError extends Error {
|
|
97
|
+
decisionId;
|
|
98
|
+
constructor(decisionId) {
|
|
99
|
+
super(`[decidio] ${decisionId}: an operator already settled this decision as committed and its report is recorded — refusing a second execution report. Nothing was re-run; this write is the duplicate, not the execution.`);
|
|
100
|
+
this.decisionId = decisionId;
|
|
101
|
+
this.name = "DecidioAlreadySettledError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function assertNotSettled(id, cur, next) {
|
|
105
|
+
if (cur.reconciled?.outcome === "committed" && (next === "executed_unreported" || next === "indeterminate")) {
|
|
106
|
+
throw new DecidioAlreadySettledError(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export const SETTLE_OUTCOMES = ["committed", "not_committed"];
|
|
110
|
+
export function assertSettlable(id, state, outcome, hasObservedEvidence = true) {
|
|
111
|
+
if (!SETTLE_OUTCOMES.includes(outcome)) {
|
|
112
|
+
throw new Error(`[decidio] ${id}: outcome must be "committed" or "not_committed", not ${JSON.stringify(outcome)}.`);
|
|
113
|
+
}
|
|
114
|
+
if (outcome === "committed") {
|
|
115
|
+
if (state === "reported")
|
|
116
|
+
throw new Error(`[decidio] ${id}: already reported — there is nothing left to reconcile.`);
|
|
117
|
+
if (state === "executed_unreported" && hasObservedEvidence)
|
|
118
|
+
throw new Error(`[decidio] ${id}: this decision already holds evidence this SDK OBSERVED — an operator assertion must not replace a captured result. If the report simply has not reached Decidio, re-send the real one: reconfirm("${id}").`);
|
|
119
|
+
if (rank(state) < rank("invoking"))
|
|
120
|
+
throw new Error(`[decidio] ${id}: refusing to record a COMMITTED outcome from state ${state} — the handler was never invoked, so no downstream write can have happened. If you mean "run it", let it resume normally.`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (state === "reported")
|
|
124
|
+
throw new Error(`[decidio] ${id}: already reported — releasing it would re-run an execution Decidio has already recorded.`);
|
|
125
|
+
if (state === "executed_unreported")
|
|
126
|
+
throw new Error(`[decidio] ${id}: this decision holds CAPTURED EVIDENCE of an execution — releasing it would re-run a proven write. Re-send the evidence with reconfirm("${id}") instead.`);
|
|
127
|
+
if (state === "parked")
|
|
128
|
+
throw new Error(`[decidio] ${id}: already parked — nothing to release.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const rank = (s) => EXECUTION_STATES.indexOf(s);
|
|
45
132
|
export class MemoryPendingStore {
|
|
46
133
|
m = new Map();
|
|
47
|
-
|
|
48
|
-
|
|
134
|
+
x = new Map();
|
|
135
|
+
rec(id) {
|
|
136
|
+
return this.x.get(id) ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
137
|
+
}
|
|
138
|
+
advance(id, attemptId, state, patch = {}) {
|
|
139
|
+
const cur = this.rec(id);
|
|
140
|
+
if (cur.attemptId && cur.attemptId !== attemptId && rank(cur.state) >= rank("invoking")) {
|
|
141
|
+
throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
|
|
142
|
+
}
|
|
143
|
+
assertNotSettled(id, cur, state);
|
|
144
|
+
if (rank(state) < rank(cur.state)) {
|
|
145
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
|
|
146
|
+
}
|
|
147
|
+
this.x.set(id, { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() });
|
|
148
|
+
}
|
|
149
|
+
async put(p) {
|
|
150
|
+
this.m.set(p.decisionId, p);
|
|
151
|
+
if (!this.x.has(p.decisionId))
|
|
152
|
+
this.x.set(p.decisionId, { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
153
|
+
}
|
|
49
154
|
async get(id) { return this.m.get(id) ?? null; }
|
|
50
|
-
async delete(id) { this.m.delete(id);
|
|
51
|
-
async list() { return [...this.m.values()].filter((p) =>
|
|
52
|
-
async claim(id) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
155
|
+
async delete(id) { this.m.delete(id); }
|
|
156
|
+
async list() { return [...this.m.values()].filter((p) => this.rec(p.decisionId).state === "parked"); }
|
|
157
|
+
async claim(id, attemptId) {
|
|
158
|
+
if (!this.m.has(id))
|
|
159
|
+
return { ok: false, reason: "no parked entry" };
|
|
160
|
+
const cur = this.rec(id);
|
|
161
|
+
if (NON_REPLAYABLE.includes(cur.state))
|
|
162
|
+
return { ok: false, state: cur.state, reason: `already ${cur.state}` };
|
|
163
|
+
if (cur.state === "claimed_preinvoke")
|
|
164
|
+
return { ok: false, state: cur.state, reason: "claimed by another attempt" };
|
|
165
|
+
this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action });
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
168
|
+
async markInvoking(id, attemptId) { this.advance(id, attemptId, "invoking"); }
|
|
169
|
+
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.advance(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
170
|
+
async markIndeterminate(id, attemptId, error) { this.advance(id, attemptId, "indeterminate", { error }); }
|
|
171
|
+
async markReported(id, attemptId, confirmation) { this.advance(id, attemptId, "reported", { confirmation }); }
|
|
172
|
+
async getExecution(id) { return this.x.get(id) ?? null; }
|
|
173
|
+
async listExecutions() { return [...this.x.values()]; }
|
|
174
|
+
async settle(id, outcome, opts = {}) {
|
|
175
|
+
const cur = this.rec(id);
|
|
176
|
+
assertSettlable(id, cur.state, outcome, !cur.evidenceError);
|
|
177
|
+
const at = new Date().toISOString();
|
|
178
|
+
const reconciled = { outcome, reason: opts.reason, at };
|
|
179
|
+
this.x.set(id, outcome === "committed"
|
|
180
|
+
? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
|
|
181
|
+
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at });
|
|
182
|
+
}
|
|
183
|
+
async releasePreinvoke(id, attemptId) {
|
|
184
|
+
const cur = this.rec(id);
|
|
185
|
+
if (rank(cur.state) >= rank("invoking"))
|
|
186
|
+
throw new Error(`[decidio] ${id}: refusing to release a claim after invocation began (state ${cur.state}).`);
|
|
187
|
+
if (cur.attemptId && cur.attemptId !== attemptId)
|
|
188
|
+
return;
|
|
189
|
+
this.x.set(id, { ...cur, state: "parked", attemptId: null, at: new Date().toISOString() });
|
|
190
|
+
}
|
|
56
191
|
}
|
|
57
192
|
export function hardenDir(dir) {
|
|
58
193
|
if (process.platform !== "win32")
|
|
59
194
|
return;
|
|
195
|
+
const warn = (why) => console.warn(`[decidio] WARNING: could not restrict ${dir} to owner-only (${why}). It keeps the directory's inherited permissions, so other accounts on this machine may be able to read parked action arguments. Move the pending store to a protected location, or set DECIDIO_PENDING_DIR to one.`);
|
|
60
196
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
197
|
+
const sid = (() => {
|
|
198
|
+
const sysRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
|
|
199
|
+
for (const exe of [join(sysRoot, "System32", "whoami.exe"), "whoami"]) {
|
|
200
|
+
try {
|
|
201
|
+
const out = execFileSync(exe, ["/user", "/fo", "csv", "/nh"], { encoding: "utf8" });
|
|
202
|
+
const m = out.match(/S-1-[0-9-]+/);
|
|
203
|
+
if (m)
|
|
204
|
+
return m[0];
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
})();
|
|
210
|
+
const principal = sid ? `*${sid}` : (() => {
|
|
211
|
+
try {
|
|
212
|
+
return userInfo().username;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
if (!principal)
|
|
68
219
|
throw new Error("could not determine the current user");
|
|
69
|
-
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${
|
|
220
|
+
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${principal}:(OI)(CI)F`, "/grant:r", "*S-1-5-18:(OI)(CI)F"], { stdio: "ignore" });
|
|
221
|
+
const probe = join(dir, `.decidio-acl-probe-${process.pid}`);
|
|
222
|
+
try {
|
|
223
|
+
writeFileSync(probe, "probe", { mode: 0o600 });
|
|
224
|
+
readFileSync(probe, "utf8");
|
|
225
|
+
rmSync(probe, { force: true });
|
|
226
|
+
}
|
|
227
|
+
catch (verifyError) {
|
|
228
|
+
try {
|
|
229
|
+
execFileSync("icacls", [dir, "/inheritance:e"], { stdio: "ignore" });
|
|
230
|
+
}
|
|
231
|
+
catch { }
|
|
232
|
+
try {
|
|
233
|
+
rmSync(probe, { force: true });
|
|
234
|
+
}
|
|
235
|
+
catch { }
|
|
236
|
+
warn(`the restriction applied but this process could no longer write there (${verifyError?.message ?? verifyError}), so it was rolled back`);
|
|
237
|
+
}
|
|
70
238
|
}
|
|
71
239
|
catch (e) {
|
|
72
|
-
|
|
240
|
+
warn(String(e?.message ?? e));
|
|
73
241
|
}
|
|
74
242
|
}
|
|
75
243
|
export class FilePendingStore {
|
|
@@ -78,62 +246,303 @@ export class FilePendingStore {
|
|
|
78
246
|
this.dir = dir;
|
|
79
247
|
mkdirSync(dir, { recursive: true });
|
|
80
248
|
hardenDir(dir);
|
|
249
|
+
try {
|
|
250
|
+
this.migrateLegacy();
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
console.error(`[decidio] could not upgrade the 0.1.x pending layout in ${dir} (${e?.message ?? e}). Entries written by 0.1.x may not appear in recover() — inspect the directory before assuming it is empty.`);
|
|
254
|
+
}
|
|
81
255
|
}
|
|
82
256
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
83
257
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
84
|
-
|
|
85
|
-
|
|
258
|
+
statePath(id, s) { return join(this.dir, `${this.key(id)}.${s}`); }
|
|
259
|
+
writeAtomic(file, text) {
|
|
260
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
261
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(fd, text);
|
|
264
|
+
fsyncSync(fd);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
renameSync(tmp, file);
|
|
270
|
+
}
|
|
271
|
+
resolve(id) {
|
|
272
|
+
const corrupt = [];
|
|
273
|
+
let best = null;
|
|
274
|
+
for (const s of EXECUTION_STATES) {
|
|
275
|
+
const f = this.statePath(id, s);
|
|
276
|
+
if (!existsSync(f))
|
|
277
|
+
continue;
|
|
278
|
+
try {
|
|
279
|
+
const r = JSON.parse(readFileSync(f, "utf8"));
|
|
280
|
+
if (!best || rank(s) >= rank(best.state))
|
|
281
|
+
best = { ...r, state: s };
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
corrupt.push(`${f}: ${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { rec: best, corrupt };
|
|
288
|
+
}
|
|
289
|
+
setState(id, attemptId, state, patch = {}) {
|
|
290
|
+
const { rec, corrupt } = this.resolve(id);
|
|
291
|
+
if (corrupt.length)
|
|
292
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
293
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
294
|
+
if (cur.attemptId && cur.attemptId !== attemptId && rank(cur.state) >= rank("invoking")) {
|
|
295
|
+
throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
|
|
296
|
+
}
|
|
297
|
+
assertNotSettled(id, cur, state);
|
|
298
|
+
if (rank(state) < rank(cur.state)) {
|
|
299
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
|
|
300
|
+
}
|
|
301
|
+
const next = { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
|
|
302
|
+
this.writeAtomic(this.statePath(id, state), JSON.stringify(next, null, 2));
|
|
303
|
+
for (const s of EXECUTION_STATES) {
|
|
304
|
+
if (s === state)
|
|
305
|
+
continue;
|
|
306
|
+
if (rank(s) >= rank(state))
|
|
307
|
+
continue;
|
|
308
|
+
const f = this.statePath(id, s);
|
|
309
|
+
try {
|
|
310
|
+
if (existsSync(f))
|
|
311
|
+
rmSync(f);
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async put(p) {
|
|
317
|
+
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
318
|
+
if (!this.resolve(p.decisionId).rec) {
|
|
319
|
+
this.writeAtomic(this.statePath(p.decisionId, "parked"), JSON.stringify({ decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() }, null, 2));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
86
322
|
async get(id) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
323
|
+
const f = this.path(id);
|
|
324
|
+
return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
|
|
325
|
+
}
|
|
326
|
+
async delete(id) { try {
|
|
327
|
+
if (existsSync(this.path(id)))
|
|
328
|
+
rmSync(this.path(id));
|
|
329
|
+
}
|
|
330
|
+
catch { } }
|
|
95
331
|
async list() {
|
|
96
332
|
if (!existsSync(this.dir))
|
|
97
333
|
return [];
|
|
98
334
|
const out = [];
|
|
99
335
|
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
336
|
+
let p;
|
|
100
337
|
try {
|
|
101
|
-
|
|
338
|
+
p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
102
339
|
}
|
|
103
340
|
catch (e) {
|
|
104
341
|
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
342
|
+
continue;
|
|
105
343
|
}
|
|
344
|
+
const { rec } = this.resolve(p.decisionId);
|
|
345
|
+
if (!rec || rec.state === "parked")
|
|
346
|
+
out.push(p);
|
|
106
347
|
}
|
|
107
348
|
return out;
|
|
108
349
|
}
|
|
109
|
-
async claim(id) {
|
|
350
|
+
async claim(id, attemptId) {
|
|
351
|
+
if (!existsSync(this.path(id)))
|
|
352
|
+
return { ok: false, reason: "no parked entry" };
|
|
353
|
+
const { rec, corrupt } = this.resolve(id);
|
|
354
|
+
if (corrupt.length)
|
|
355
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
356
|
+
const state = rec?.state ?? "parked";
|
|
357
|
+
if (NON_REPLAYABLE.includes(state))
|
|
358
|
+
return { ok: false, state, reason: `already ${state}` };
|
|
359
|
+
if (state === "claimed_preinvoke")
|
|
360
|
+
return { ok: false, state, reason: "claimed by another attempt" };
|
|
361
|
+
const target = this.statePath(id, "claimed_preinvoke");
|
|
362
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
363
|
+
const payload = JSON.stringify({ decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
|
|
364
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
365
|
+
try {
|
|
366
|
+
writeFileSync(fd, payload);
|
|
367
|
+
fsyncSync(fd);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
closeSync(fd);
|
|
371
|
+
}
|
|
110
372
|
try {
|
|
111
|
-
|
|
112
|
-
return true;
|
|
373
|
+
linkSync(tmp, target);
|
|
113
374
|
}
|
|
114
375
|
catch {
|
|
115
|
-
|
|
376
|
+
try {
|
|
377
|
+
rmSync(tmp);
|
|
378
|
+
}
|
|
379
|
+
catch { }
|
|
380
|
+
return { ok: false, state: "claimed_preinvoke", reason: "lost the claim race" };
|
|
116
381
|
}
|
|
117
|
-
}
|
|
118
|
-
async release(id) {
|
|
119
382
|
try {
|
|
120
|
-
|
|
383
|
+
rmSync(tmp);
|
|
384
|
+
}
|
|
385
|
+
catch { }
|
|
386
|
+
try {
|
|
387
|
+
const p = this.statePath(id, "parked");
|
|
388
|
+
if (existsSync(p))
|
|
389
|
+
rmSync(p);
|
|
121
390
|
}
|
|
122
391
|
catch { }
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}
|
|
394
|
+
async markInvoking(id, attemptId) { this.setState(id, attemptId, "invoking"); }
|
|
395
|
+
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
396
|
+
async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
|
|
397
|
+
async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
|
|
398
|
+
async getExecution(id) {
|
|
399
|
+
const { rec, corrupt } = this.resolve(id);
|
|
400
|
+
if (corrupt.length)
|
|
401
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
402
|
+
return rec;
|
|
123
403
|
}
|
|
124
|
-
async
|
|
404
|
+
async listExecutions() {
|
|
125
405
|
if (!existsSync(this.dir))
|
|
126
406
|
return [];
|
|
407
|
+
const ids = new Set();
|
|
408
|
+
for (const name of readdirSync(this.dir)) {
|
|
409
|
+
const dot = name.lastIndexOf(".");
|
|
410
|
+
if (dot < 0)
|
|
411
|
+
continue;
|
|
412
|
+
const suffix = name.slice(dot + 1);
|
|
413
|
+
if (!EXECUTION_STATES.includes(suffix))
|
|
414
|
+
continue;
|
|
415
|
+
ids.add(name.slice(0, dot));
|
|
416
|
+
}
|
|
127
417
|
const out = [];
|
|
418
|
+
for (const key of ids) {
|
|
419
|
+
const { rec, corrupt } = this.resolve(key);
|
|
420
|
+
if (corrupt.length) {
|
|
421
|
+
out.push({ decisionId: key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (rec)
|
|
425
|
+
out.push(rec);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
async settle(id, outcome, opts = {}) {
|
|
430
|
+
const { rec, corrupt } = this.resolve(id);
|
|
431
|
+
if (corrupt.length)
|
|
432
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
433
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
434
|
+
assertSettlable(id, cur.state, outcome, !cur.evidenceError);
|
|
435
|
+
const at = new Date().toISOString();
|
|
436
|
+
const next = outcome === "committed"
|
|
437
|
+
? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled: { outcome, reason: opts.reason, at }, at }
|
|
438
|
+
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled: { outcome, reason: opts.reason, at }, at };
|
|
439
|
+
this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
|
|
440
|
+
for (const st of EXECUTION_STATES) {
|
|
441
|
+
if (st === next.state)
|
|
442
|
+
continue;
|
|
443
|
+
try {
|
|
444
|
+
const f = this.statePath(id, st);
|
|
445
|
+
if (existsSync(f))
|
|
446
|
+
rmSync(f);
|
|
447
|
+
}
|
|
448
|
+
catch { }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async releasePreinvoke(id, attemptId) {
|
|
452
|
+
const { rec } = this.resolve(id);
|
|
453
|
+
const state = rec?.state ?? "parked";
|
|
454
|
+
if (rank(state) >= rank("invoking"))
|
|
455
|
+
throw new Error(`[decidio] ${id}: refusing to release a claim after invocation began (state ${state}).`);
|
|
456
|
+
if (rec?.attemptId && rec.attemptId !== attemptId)
|
|
457
|
+
return;
|
|
458
|
+
this.writeAtomic(this.statePath(id, "parked"), JSON.stringify({ decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() }, null, 2));
|
|
459
|
+
try {
|
|
460
|
+
const c = this.statePath(id, "claimed_preinvoke");
|
|
461
|
+
if (existsSync(c))
|
|
462
|
+
rmSync(c);
|
|
463
|
+
}
|
|
464
|
+
catch { }
|
|
465
|
+
}
|
|
466
|
+
migrateLegacy() {
|
|
467
|
+
if (!existsSync(this.dir))
|
|
468
|
+
return { migrated: 0, indeterminate: 0, reports: 0 };
|
|
469
|
+
let migrated = 0, indeterminate = 0;
|
|
128
470
|
for (const name of readdirSync(this.dir)) {
|
|
129
|
-
if (
|
|
471
|
+
if (name.endsWith(".claimed")) {
|
|
472
|
+
const key = name.slice(0, -".claimed".length);
|
|
473
|
+
try {
|
|
474
|
+
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
475
|
+
this.writeAtomic(this.statePath(p.decisionId, "indeterminate"), JSON.stringify({ decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action, error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString() }, null, 2));
|
|
476
|
+
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
477
|
+
rmSync(join(this.dir, name));
|
|
478
|
+
indeterminate++;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
console.error(`[decidio] could not migrate legacy claimed entry ${key} — inspect it by hand.`);
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (!name.endsWith(".json"))
|
|
130
486
|
continue;
|
|
131
487
|
try {
|
|
132
|
-
|
|
488
|
+
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
489
|
+
if (!p?.decisionId)
|
|
490
|
+
continue;
|
|
491
|
+
if (this.resolve(p.decisionId).rec)
|
|
492
|
+
continue;
|
|
493
|
+
this.writeAtomic(this.statePath(p.decisionId, "parked"), JSON.stringify({ decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() }, null, 2));
|
|
494
|
+
migrated++;
|
|
133
495
|
}
|
|
134
496
|
catch { }
|
|
135
497
|
}
|
|
136
|
-
|
|
498
|
+
if (indeterminate)
|
|
499
|
+
console.error(`[decidio] upgraded ${indeterminate} in-flight 0.1.x entr${indeterminate === 1 ? "y" : "ies"} to INDETERMINATE — they will not auto-run; review them with guard.resume.recover().`);
|
|
500
|
+
const reports = this.migrateLegacyReports();
|
|
501
|
+
return { migrated, indeterminate, reports };
|
|
502
|
+
}
|
|
503
|
+
migrateLegacyReports() {
|
|
504
|
+
const reportsDir = join(this.dir, "reports");
|
|
505
|
+
if (!existsSync(reportsDir))
|
|
506
|
+
return 0;
|
|
507
|
+
let moved = 0;
|
|
508
|
+
for (const name of readdirSync(reportsDir)) {
|
|
509
|
+
if (!name.endsWith(".json"))
|
|
510
|
+
continue;
|
|
511
|
+
const file = join(reportsDir, name);
|
|
512
|
+
try {
|
|
513
|
+
const e = JSON.parse(readFileSync(file, "utf8"));
|
|
514
|
+
if (!e?.decisionId)
|
|
515
|
+
continue;
|
|
516
|
+
const existing = this.resolve(e.decisionId).rec;
|
|
517
|
+
if (existing && rank(existing.state) >= rank("executed_unreported")) {
|
|
518
|
+
renameSync(file, `${file}.migrated`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
|
|
522
|
+
decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
|
|
523
|
+
evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
|
|
524
|
+
at: e.at ?? new Date().toISOString(),
|
|
525
|
+
}, null, 2));
|
|
526
|
+
renameSync(file, `${file}.migrated`);
|
|
527
|
+
moved++;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
console.error(`[decidio] could not migrate the 0.1.x execution report ${file} — it records an action that RAN. Inspect it by hand before deleting it.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (moved)
|
|
534
|
+
console.error(`[decidio] recovered ${moved} execution report${moved === 1 ? "" : "s"} written by 0.1.x into the execution ledger — re-send them with guard.resume.recover() / reconfirm(). The original files are kept alongside as .migrated.`);
|
|
535
|
+
return moved;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
export class DecidioStateCorruptError extends Error {
|
|
539
|
+
decisionId;
|
|
540
|
+
details;
|
|
541
|
+
constructor(decisionId, details) {
|
|
542
|
+
super(`[decidio] ${decisionId}: durable execution state is unreadable (${details.join("; ")}). Refusing to guess whether this action ran — inspect these files; reconcile() once you know.`);
|
|
543
|
+
this.decisionId = decisionId;
|
|
544
|
+
this.details = details;
|
|
545
|
+
this.name = "DecidioStateCorruptError";
|
|
137
546
|
}
|
|
138
547
|
}
|
|
139
548
|
export class DecidioSuspendedError extends Error {
|
|
@@ -158,13 +567,14 @@ export function verifyResumeSignature(secret, body, header) {
|
|
|
158
567
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
159
568
|
}
|
|
160
569
|
export function assertConfirmRecorded(body, decisionId, signed) {
|
|
161
|
-
|
|
162
|
-
|
|
570
|
+
const ev = readConfirmResponse(body, { signed, journalled: false });
|
|
571
|
+
if (!ev.reportRecorded || !ev.evidenceTier) {
|
|
572
|
+
throw new Error(`[decidio] ${decisionId}: ${ev.diagnostic ?? "confirm answered 2xx but reported no recorded evidence tier — treating the confirmation as NOT recorded"}`);
|
|
163
573
|
}
|
|
164
|
-
if (signed &&
|
|
165
|
-
console.warn(`[decidio] execution evidence for ${decisionId} was recorded as "${
|
|
574
|
+
if (signed && ev.evidenceTier !== "application_confirmed") {
|
|
575
|
+
console.warn(`[decidio] execution evidence for ${decisionId} was recorded as "${ev.evidenceTier}", not application_confirmed, despite a signed report — the server did not accept the identity proof (key/DID/registration mismatch?). Run \`npx @decidio/sdk doctor\` and re-init if this persists.`);
|
|
166
576
|
}
|
|
167
|
-
return
|
|
577
|
+
return ev.evidenceTier;
|
|
168
578
|
}
|
|
169
579
|
function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
170
580
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
@@ -202,64 +612,49 @@ async function statusOf(cfg, decisionId) {
|
|
|
202
612
|
throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
|
|
203
613
|
return r.json();
|
|
204
614
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
rmSync(this.p(id));
|
|
237
|
-
}
|
|
238
|
-
catch { } }
|
|
239
|
-
list() {
|
|
240
|
-
try {
|
|
241
|
-
if (!existsSync(this.dir))
|
|
242
|
-
return [];
|
|
243
|
-
return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
|
|
244
|
-
return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
return null;
|
|
248
|
-
} }).filter(Boolean);
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
return [];
|
|
252
|
-
}
|
|
253
|
-
}
|
|
615
|
+
const V2_STORE_METHODS = [
|
|
616
|
+
"put", "get", "delete", "list",
|
|
617
|
+
"claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
|
|
618
|
+
"getExecution", "listExecutions", "releasePreinvoke", "settle",
|
|
619
|
+
];
|
|
620
|
+
function assertStoreV2(store) {
|
|
621
|
+
const missing = V2_STORE_METHODS.filter((m) => typeof store[m] !== "function");
|
|
622
|
+
if (!missing.length)
|
|
623
|
+
return;
|
|
624
|
+
throw new Error(`[decidio] this PendingStore is missing ${missing.length} required method${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.\n` +
|
|
625
|
+
` 0.2.0 makes the durable execution state machine MANDATORY (parked → claimed_preinvoke → invoking → executed_unreported | indeterminate → reported).\n` +
|
|
626
|
+
` The 0.1.x contract left claim() optional and fell back to a process-local guard, which cannot survive a restart — so it never carried the single-execution guarantee it appeared to. A store that cannot record that invocation BEGAN cannot prevent a committed action from running twice.\n` +
|
|
627
|
+
` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
|
|
628
|
+
}
|
|
629
|
+
let reportDirWarned = false;
|
|
630
|
+
function webhookBody(o) {
|
|
631
|
+
return {
|
|
632
|
+
ok: true,
|
|
633
|
+
outcome: {
|
|
634
|
+
status: o.status, decisionId: o.decisionId,
|
|
635
|
+
...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
|
|
636
|
+
...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
|
|
637
|
+
...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
|
|
638
|
+
...(o.executionReportVerified !== undefined ? { executionReportVerified: o.executionReportVerified } : {}),
|
|
639
|
+
...(o.executionReportReason ? { executionReportReason: o.executionReportReason } : {}),
|
|
640
|
+
...(o.confirmationRetryable !== undefined ? { confirmationRetryable: o.confirmationRetryable } : {}),
|
|
641
|
+
...(o.reportJournalled !== undefined ? { reportJournalled: o.reportJournalled } : {}),
|
|
642
|
+
...(o.evidenceError ? { evidenceError: o.evidenceError } : {}),
|
|
643
|
+
...(o.diagnostic ? { diagnostic: o.diagnostic } : {}),
|
|
644
|
+
},
|
|
645
|
+
};
|
|
254
646
|
}
|
|
255
647
|
export function createDecidioResume(opts) {
|
|
648
|
+
assertStoreV2(opts.store);
|
|
256
649
|
const registry = new Map();
|
|
257
|
-
const inFlight = new Set();
|
|
258
650
|
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
651
|
+
if (opts.reportDir && !reportDirWarned) {
|
|
652
|
+
reportDirWarned = true;
|
|
653
|
+
console.warn(`[decidio] reportDir is ignored since 0.2.0 — execution evidence now lives in the PendingStore's own execution ledger, beside the state that makes a decision non-replayable. Reports written by 0.1.x under <pendingDir>/reports are migrated into that ledger on first use of a FilePendingStore, so nothing is lost; remove the option when convenient.`);
|
|
654
|
+
}
|
|
259
655
|
if (opts.allowUnsigned)
|
|
260
656
|
console.warn("[decidio] WARNING: allowUnsigned is ON — this controller will accept UNSIGNED resume webhooks. Anyone who can reach the endpoint can signal work (parked-payload integrity still gates execution). Use this only behind a trusted network boundary, never on an internet-reachable endpoint.");
|
|
261
|
-
|
|
262
|
-
async function reportAfterExecution(pending, decisionId, result) {
|
|
657
|
+
async function reportAfterExecution(pending, decisionId, result, attemptId) {
|
|
263
658
|
const base = { status: "executed", decisionId, action: pending.action, result };
|
|
264
659
|
let reported;
|
|
265
660
|
let haveReported = false;
|
|
@@ -274,7 +669,32 @@ export function createDecidioResume(opts) {
|
|
|
274
669
|
reported = null;
|
|
275
670
|
haveReported = true;
|
|
276
671
|
}
|
|
277
|
-
|
|
672
|
+
let journalled = false;
|
|
673
|
+
if (haveReported) {
|
|
674
|
+
try {
|
|
675
|
+
await ctrl.store.recordExecuted(decisionId, attemptId, reported, signEvidence(decisionId, attemptId, reported, ctrl.agentKey, ctrl.webhookSecret), buildProblem);
|
|
676
|
+
journalled = true;
|
|
677
|
+
}
|
|
678
|
+
catch (e) {
|
|
679
|
+
if (e instanceof DecidioStaleAttemptError) {
|
|
680
|
+
console.warn(`[decidio] ${decisionId}: this action finished, but the decision is now owned by a newer attempt — it was released for retry while this one was still running. This attempt's report is NOT being sent; the owning attempt reports. Your action DID run: if that means it ran twice, the release was premature.`);
|
|
681
|
+
try {
|
|
682
|
+
await ctrl.store.delete(decisionId);
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
685
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false, diagnostic: `${decisionId} was released to a newer attempt while this one was running; this attempt's report was not sent. If your action is not idempotent, note that it has now run more than once.` };
|
|
686
|
+
}
|
|
687
|
+
if (e instanceof DecidioAlreadySettledError) {
|
|
688
|
+
console.warn(`[decidio] ${decisionId}: this action finished, but an operator had already reconciled the decision as committed and Decidio has that report. This attempt's own report is NOT being sent — one decision, one report. If your result differs from what was reconciled, correct it in Decidio; do NOT re-run the action.`);
|
|
689
|
+
try {
|
|
690
|
+
await ctrl.store.delete(decisionId);
|
|
691
|
+
}
|
|
692
|
+
catch { }
|
|
693
|
+
return { ...base, reportRecorded: true, executionConfirmed: false, reportJournalled: true, diagnostic: `an operator settled ${decisionId} as committed while this attempt was running; their report stands and this one was not sent.` };
|
|
694
|
+
}
|
|
695
|
+
console.error(`[decidio] ${decisionId}: the action RAN but its execution report could NOT be recorded (${e?.message ?? e}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system.`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
278
698
|
let confirmRequest;
|
|
279
699
|
try {
|
|
280
700
|
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
@@ -287,14 +707,16 @@ export function createDecidioResume(opts) {
|
|
|
287
707
|
await ctrl.store.delete(decisionId);
|
|
288
708
|
}
|
|
289
709
|
catch (e) {
|
|
290
|
-
console.
|
|
710
|
+
console.warn(`[decidio] ${decisionId}: the parked payload could not be removed (${e?.message ?? e}). This is cleanup only — the execution ledger already records this decision as executed, so no process can run it again; remove the row at your convenience.`);
|
|
291
711
|
}
|
|
292
712
|
};
|
|
293
713
|
if (!confirmRequest) {
|
|
294
714
|
await deletePending();
|
|
295
|
-
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it
|
|
715
|
+
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it. ` + (journalled
|
|
716
|
+
? `The reported value IS recorded, so fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and re-send it with reconfirm("${decisionId}").`
|
|
717
|
+
: `The reported value could not be recorded either, so nothing here can re-send it — record the outcome from your own system.`);
|
|
296
718
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
297
|
-
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
719
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), diagnostic };
|
|
298
720
|
}
|
|
299
721
|
await deletePending();
|
|
300
722
|
let ev;
|
|
@@ -310,7 +732,7 @@ export function createDecidioResume(opts) {
|
|
|
310
732
|
};
|
|
311
733
|
}
|
|
312
734
|
if (ev.reportRecorded)
|
|
313
|
-
|
|
735
|
+
await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
|
|
314
736
|
const diagnostic = buildProblem
|
|
315
737
|
? `${ev.diagnostic ? ev.diagnostic + " " : ""}Also: the action's return value was not reportable (${buildProblem}), so an empty response was reported - map it with the \`evidence\` option to earn a confirming tier.`
|
|
316
738
|
: ev.diagnostic;
|
|
@@ -318,7 +740,7 @@ export function createDecidioResume(opts) {
|
|
|
318
740
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
319
741
|
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
320
742
|
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
321
|
-
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
743
|
+
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(ev.executionReportReason ? { executionReportReason: ev.executionReportReason } : {}), ...(ev.confirmationRetryable !== undefined ? { confirmationRetryable: ev.confirmationRetryable } : {}), reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
322
744
|
}
|
|
323
745
|
const refusalWarned = new Set();
|
|
324
746
|
const refuseOnce = (decisionId, msg) => {
|
|
@@ -335,13 +757,13 @@ export function createDecidioResume(opts) {
|
|
|
335
757
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
336
758
|
webhookSecret: secret,
|
|
337
759
|
store: opts.store,
|
|
338
|
-
register(action, fn, evidence) {
|
|
760
|
+
register(action, fn, evidence, withContext) {
|
|
339
761
|
const prev = registry.get(action);
|
|
340
762
|
if (prev && prev.fn !== fn)
|
|
341
763
|
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
|
|
342
764
|
if (prev && prev.evidence && evidence && prev.evidence !== evidence)
|
|
343
765
|
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT evidence mapper — the latest one wins, and a resumed decision's record would carry ITS output. Give each protected function its own action name.`);
|
|
344
|
-
registry.set(action, { fn, evidence: evidence ?? prev?.evidence });
|
|
766
|
+
registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
|
|
345
767
|
},
|
|
346
768
|
async park(p) {
|
|
347
769
|
try {
|
|
@@ -401,19 +823,37 @@ export function createDecidioResume(opts) {
|
|
|
401
823
|
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
402
824
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
403
825
|
}
|
|
404
|
-
const
|
|
405
|
-
|
|
826
|
+
const attemptId = randomUUID();
|
|
827
|
+
const claim = await ctrl.store.claim(decisionId, attemptId);
|
|
828
|
+
if (!claim.ok) {
|
|
829
|
+
if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
|
|
830
|
+
refuseOnce(decisionId, `[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}", …).`);
|
|
831
|
+
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
|
|
832
|
+
}
|
|
406
833
|
return { status: "unknown", decisionId };
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
840
|
+
console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
|
|
841
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "could not durably record the invocation boundary" });
|
|
842
|
+
}
|
|
843
|
+
let result;
|
|
407
844
|
try {
|
|
408
|
-
const
|
|
409
|
-
|
|
845
|
+
const entry = registry.get(pending.action);
|
|
846
|
+
result = entry?.withContext
|
|
847
|
+
? await fn({ decisionId, attemptId, idempotencyKey: decisionId }, ...pending.args)
|
|
848
|
+
: await fn(...pending.args);
|
|
410
849
|
}
|
|
411
850
|
catch (e) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
851
|
+
const why = String(e?.message ?? e);
|
|
852
|
+
await ctrl.store.markIndeterminate(decisionId, attemptId, why).catch((se) => console.error(`[decidio] ${decisionId}: could not record INDETERMINATE (${se?.message ?? se}) — treat this decision as possibly executed.`));
|
|
853
|
+
console.error(`[decidio] ${decisionId}: "${pending.action}" was INVOKED and then threw (${why}). Whether the downstream effect committed is not knowable from here, so it will NOT be retried automatically. Check the downstream system, then: guard.resume.reconcile("${decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.`);
|
|
854
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: why });
|
|
416
855
|
}
|
|
856
|
+
return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
|
|
417
857
|
},
|
|
418
858
|
webhookHandler() {
|
|
419
859
|
return (req, res) => {
|
|
@@ -440,7 +880,7 @@ export function createDecidioResume(opts) {
|
|
|
440
880
|
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
|
|
441
881
|
res.statusCode = 200;
|
|
442
882
|
res.setHeader("content-type", "application/json");
|
|
443
|
-
res.end(JSON.stringify(
|
|
883
|
+
res.end(JSON.stringify(webhookBody(outcome)));
|
|
444
884
|
}
|
|
445
885
|
catch (e) {
|
|
446
886
|
res.statusCode = 500;
|
|
@@ -464,52 +904,151 @@ export function createDecidioResume(opts) {
|
|
|
464
904
|
try {
|
|
465
905
|
const body = JSON.parse(raw || "{}");
|
|
466
906
|
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
|
|
467
|
-
return new Response(JSON.stringify(
|
|
907
|
+
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
468
908
|
}
|
|
469
909
|
catch (e) {
|
|
470
910
|
return new Response(JSON.stringify({ error: e?.message ?? "resume failed" }), { status: 500 });
|
|
471
911
|
}
|
|
472
912
|
};
|
|
473
913
|
},
|
|
474
|
-
async
|
|
475
|
-
|
|
914
|
+
async reconcile(decisionId, opts) {
|
|
915
|
+
let entry;
|
|
916
|
+
try {
|
|
917
|
+
entry = await ctrl.store.getExecution(decisionId);
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the durable state for ${decisionId} could not be read (${e?.message ?? e}). Refusing to settle a decision whose state is unknown — inspect the store first.` };
|
|
921
|
+
}
|
|
476
922
|
if (!entry) {
|
|
923
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `nothing is recorded for ${decisionId} in this store, so there is nothing to reconcile. Check the decision id, or the store this worker is configured with.` };
|
|
924
|
+
}
|
|
925
|
+
let settleEvidence;
|
|
926
|
+
try {
|
|
927
|
+
settleEvidence = opts.outcome === "committed" ? toCanonical(opts.evidence ?? null, "reconcile(evidence)") : null;
|
|
928
|
+
}
|
|
929
|
+
catch (e) {
|
|
930
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the evidence you passed to reconcile("${decisionId}") is not reportable as-is (${e?.message ?? e}). Nothing was settled. Pass plain JSON — the same rule your action's return value follows.` };
|
|
931
|
+
}
|
|
932
|
+
const settleSig = opts.outcome === "committed"
|
|
933
|
+
? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, ctrl.webhookSecret)
|
|
934
|
+
: undefined;
|
|
935
|
+
try {
|
|
936
|
+
await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig });
|
|
937
|
+
}
|
|
938
|
+
catch (e) {
|
|
939
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${e?.message ?? e}` };
|
|
940
|
+
}
|
|
941
|
+
if (opts.outcome !== "committed") {
|
|
942
|
+
console.warn(`[decidio] ${decisionId}: released for one more attempt on your assertion that nothing committed${opts.reason ? ` (${opts.reason})` : ""}. The next resume signal will run it.`);
|
|
943
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${decisionId} is parked again and may run once more. Nothing was reported, because on your account nothing executed.` };
|
|
944
|
+
}
|
|
945
|
+
let request;
|
|
946
|
+
try {
|
|
947
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, settleEvidence);
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the reconciled report for ${decisionId} could not be signed (${e?.message ?? e}). The evidence IS recorded locally — fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and re-send it with reconfirm("${decisionId}").` };
|
|
951
|
+
}
|
|
952
|
+
let ev;
|
|
953
|
+
try {
|
|
954
|
+
ev = await postConfirm(ctrl, decisionId, request);
|
|
955
|
+
}
|
|
956
|
+
catch (e) {
|
|
957
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the reconciled report for ${decisionId} did not reach Decidio (${e?.message ?? e}). The evidence IS recorded locally — call reconfirm("${decisionId}") when the service is healthy. Do NOT re-run the action.` };
|
|
958
|
+
}
|
|
959
|
+
if (ev.reportRecorded)
|
|
960
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev.evidenceTier ?? "recorded").catch(() => { });
|
|
961
|
+
return {
|
|
962
|
+
...ev,
|
|
963
|
+
diagnostic: `${ev.diagnostic ? `${ev.diagnostic} ` : ""}This report was RECONCILED BY AN OPERATOR${opts.reason ? ` (${opts.reason})` : ""}, not observed by the SDK: its tier reflects the evidence you supplied, not a return value this process saw.`,
|
|
964
|
+
};
|
|
965
|
+
},
|
|
966
|
+
async reconfirm(decisionId, opts) {
|
|
967
|
+
let entry;
|
|
968
|
+
try {
|
|
969
|
+
entry = await ctrl.store.getExecution(decisionId);
|
|
970
|
+
}
|
|
971
|
+
catch (e) {
|
|
972
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the durable state for ${decisionId} could not be read (${e?.message ?? e}). Refusing to guess whether it executed — inspect the store; do NOT re-run the action to find out.` };
|
|
973
|
+
}
|
|
974
|
+
if (entry?.state === "reported") {
|
|
975
|
+
return { reportRecorded: true, executionConfirmed: false, evidenceTier: entry.confirmation, diagnostic: `${decisionId} was already reported (${entry.confirmation ?? "recorded"}) — nothing to re-send.` };
|
|
976
|
+
}
|
|
977
|
+
if (entry?.state === "indeterminate") {
|
|
978
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${decisionId} is INDETERMINATE: it was invoked and its outcome is unknown, so there is no captured result to re-send. Settle it with reconcile("${decisionId}", …) once you have checked the downstream system.` };
|
|
979
|
+
}
|
|
980
|
+
if (!entry || !entry.hasEvidence) {
|
|
477
981
|
return {
|
|
478
982
|
reportRecorded: false, executionConfirmed: false,
|
|
479
|
-
diagnostic: `nothing is
|
|
983
|
+
diagnostic: `nothing is recorded for ${decisionId}: either its report was already recorded (nothing to do) or this process never executed it. Check the record in Decidio — do NOT re-run the action to find out.`,
|
|
480
984
|
};
|
|
481
985
|
}
|
|
986
|
+
const evStrict = !!ctrl.agentKey;
|
|
987
|
+
const evStatus = verifyEvidence(entry, ctrl.agentKey, ctrl.webhookSecret);
|
|
988
|
+
if (evStatus === "mismatch") {
|
|
989
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the recorded evidence for ${decisionId} does not match its integrity binding — it was altered after this agent wrote it. REFUSING to attest it. Inspect the pending store; if you know the true outcome, settle it with reconcile("${decisionId}", …).` };
|
|
990
|
+
}
|
|
991
|
+
if (evStatus !== "ok" && evStrict && !opts?.acceptUnbound) {
|
|
992
|
+
const why = entry.migratedFrom
|
|
993
|
+
? `it was migrated from ${entry.migratedFrom}, which predates evidence binding — that is expected, and it is why this call has an override`
|
|
994
|
+
: `this agent signs its evidence, so a record without a usable binding did not come from this agent`;
|
|
995
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the recorded evidence for ${decisionId} carries ${evStatus === "unbound" ? "NO integrity binding" : "a binding this agent's key cannot verify"} (${why}). REFUSING to attest it by default. If you have checked what this row claims and want it sent anyway: reconfirm("${decisionId}", { acceptUnbound: true }). If you do not trust it, settle the decision instead: reconcile("${decisionId}", …).` };
|
|
996
|
+
}
|
|
997
|
+
if (evStatus !== "ok" && evStrict)
|
|
998
|
+
console.warn(`[decidio] ${decisionId}: attesting evidence with ${evStatus === "unbound" ? "NO integrity binding" : "an unverifiable binding"} because acceptUnbound was passed${entry.migratedFrom ? ` (row migrated from ${entry.migratedFrom})` : ""}. You are vouching for this row; the agent's signature will say it came from here.`);
|
|
999
|
+
if (evStatus !== "ok")
|
|
1000
|
+
console.warn(`[decidio] ${decisionId}: re-sending an execution report whose evidence is ${evStatus === "unbound" ? "UNBOUND (written without agent key material, so it cannot be integrity-checked)" : "UNVERIFIABLE with this agent's current key material"}${entry.migratedFrom ? ` — it was migrated from ${entry.migratedFrom}, which predates evidence binding` : ""}. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
482
1001
|
let request;
|
|
483
1002
|
try {
|
|
484
|
-
request = buildResumeConfirmRequest(ctrl, decisionId, entry.
|
|
1003
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, entry.evidence);
|
|
485
1004
|
}
|
|
486
1005
|
catch (e) {
|
|
487
1006
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the journalled report for ${decisionId} could not be re-signed (${e?.message ?? e}). The entry is kept; fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and retry.` };
|
|
488
1007
|
}
|
|
489
|
-
|
|
1008
|
+
let ev;
|
|
1009
|
+
try {
|
|
1010
|
+
ev = await postConfirm(ctrl, decisionId, request);
|
|
1011
|
+
}
|
|
1012
|
+
catch (e) {
|
|
1013
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report for ${decisionId} still did not reach Decidio (${e?.message ?? e}). The recorded evidence is KEPT — call reconfirm("${decisionId}") again when the service is healthy. Your action already ran; do NOT re-run it.` };
|
|
1014
|
+
}
|
|
490
1015
|
if (ev.reportRecorded)
|
|
491
|
-
|
|
1016
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
|
|
492
1017
|
return ev;
|
|
493
1018
|
},
|
|
494
1019
|
async recover() {
|
|
495
1020
|
const items = [];
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1021
|
+
const execs = await ctrl.store.listExecutions().catch((e) => {
|
|
1022
|
+
console.error(`[decidio] recover(): the execution ledger could not be read (${e?.message ?? e}). Nothing is reported rather than reporting a falsely empty recovery list.`);
|
|
1023
|
+
return null;
|
|
1024
|
+
});
|
|
1025
|
+
if (execs === null)
|
|
1026
|
+
return items;
|
|
1027
|
+
for (const r of execs) {
|
|
1028
|
+
const a = r.action ?? "(unknown action)";
|
|
1029
|
+
switch (r.state) {
|
|
1030
|
+
case "executed_unreported":
|
|
1031
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "executed_unreported",
|
|
1032
|
+
guidance: `"${a}" executed at ${r.at} and its execution report has not been recorded. Re-send it safely: guard.resume.reconfirm("${r.decisionId}") — this replays the saved report and cannot re-run the action.` });
|
|
1033
|
+
break;
|
|
1034
|
+
case "indeterminate":
|
|
1035
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "indeterminate",
|
|
1036
|
+
guidance: `"${a}" was INVOKED and its outcome is unknown${r.error ? ` (${r.error})` : ""}. It will NOT be retried automatically. Check the downstream system, then settle it: guard.resume.reconcile("${r.decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.` });
|
|
1037
|
+
break;
|
|
1038
|
+
case "invoking":
|
|
1039
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "indeterminate",
|
|
1040
|
+
guidance: `"${a}" was invoked at ${r.at} and this process died before recording the outcome, so whether it ran is unknown locally. It will NOT be retried automatically — reconcile it as above.` });
|
|
1041
|
+
break;
|
|
1042
|
+
case "claimed_preinvoke":
|
|
1043
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "claimed_stalled",
|
|
1044
|
+
guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
|
|
1045
|
+
break;
|
|
1046
|
+
case "parked":
|
|
1047
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
|
|
1048
|
+
break;
|
|
1049
|
+
case "reported":
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
513
1052
|
}
|
|
514
1053
|
return items;
|
|
515
1054
|
},
|