@decidio/sdk 0.1.11 → 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 +53 -15
- package/dist/adapters/_gate.d.ts +8 -2
- package/dist/adapters/_gate.js +30 -8
- 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 +34 -6
- package/dist/cli.js +141 -52
- package/dist/errors.d.ts +12 -5
- package/dist/errors.js +37 -5
- package/dist/evidence.d.ts +23 -0
- package/dist/evidence.js +15 -4
- package/dist/guard.d.ts +20 -3
- package/dist/guard.js +8 -6
- package/dist/index.d.ts +28 -3
- package/dist/index.js +49 -17
- package/dist/inngest.d.ts +4 -0
- package/dist/inngest.js +1 -1
- package/dist/resume.d.ts +334 -57
- package/dist/resume.js +705 -145
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +36 -2
- package/openapi.yaml +89 -6
- package/package.json +13 -8
package/dist/resume.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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";
|
|
6
|
-
import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
8
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
9
|
import { DecidioHttpError } from "./errors.js";
|
|
@@ -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 };
|
|
123
393
|
}
|
|
124
|
-
async
|
|
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;
|
|
403
|
+
}
|
|
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);
|
|
@@ -172,9 +582,9 @@ function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
|
172
582
|
console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
|
|
173
583
|
return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
|
|
174
584
|
}
|
|
175
|
-
async function postConfirm(cfg, decisionId, body) {
|
|
585
|
+
async function postConfirm(cfg, decisionId, body, journalled = true) {
|
|
176
586
|
const f = cfg.fetchImpl ?? fetch;
|
|
177
|
-
assertSafeTransport(cfg.apiUrl,
|
|
587
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
178
588
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
179
589
|
method: "POST",
|
|
180
590
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
@@ -190,11 +600,11 @@ async function postConfirm(cfg, decisionId, body) {
|
|
|
190
600
|
throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
|
|
191
601
|
}
|
|
192
602
|
const parsed = await r.json().catch(() => null);
|
|
193
|
-
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled
|
|
603
|
+
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled });
|
|
194
604
|
}
|
|
195
605
|
async function statusOf(cfg, decisionId) {
|
|
196
606
|
const f = cfg.fetchImpl ?? fetch;
|
|
197
|
-
assertSafeTransport(cfg.apiUrl,
|
|
607
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
198
608
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
199
609
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
200
610
|
});
|
|
@@ -202,62 +612,56 @@ 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
|
-
return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
return null;
|
|
240
|
-
} }).filter(Boolean);
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
return [];
|
|
244
|
-
}
|
|
245
|
-
}
|
|
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
|
+
};
|
|
246
646
|
}
|
|
247
647
|
export function createDecidioResume(opts) {
|
|
648
|
+
assertStoreV2(opts.store);
|
|
248
649
|
const registry = new Map();
|
|
249
|
-
const inFlight = new Set();
|
|
250
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
|
+
}
|
|
251
655
|
if (opts.allowUnsigned)
|
|
252
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.");
|
|
253
|
-
|
|
254
|
-
async function reportAfterExecution(pending, decisionId, result) {
|
|
657
|
+
async function reportAfterExecution(pending, decisionId, result, attemptId) {
|
|
255
658
|
const base = { status: "executed", decisionId, action: pending.action, result };
|
|
256
659
|
let reported;
|
|
257
660
|
let haveReported = false;
|
|
258
661
|
let buildProblem;
|
|
662
|
+
const evidenceMapper = registry.get(pending.action)?.evidence ?? ctrl.evidence;
|
|
259
663
|
try {
|
|
260
|
-
reported = toCanonical((
|
|
664
|
+
reported = toCanonical((evidenceMapper ? evidenceMapper(result) : result) ?? null, "evidence(result)");
|
|
261
665
|
haveReported = true;
|
|
262
666
|
}
|
|
263
667
|
catch (e) {
|
|
@@ -265,8 +669,32 @@ export function createDecidioResume(opts) {
|
|
|
265
669
|
reported = null;
|
|
266
670
|
haveReported = true;
|
|
267
671
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|
|
270
698
|
let confirmRequest;
|
|
271
699
|
try {
|
|
272
700
|
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
@@ -274,30 +702,45 @@ export function createDecidioResume(opts) {
|
|
|
274
702
|
catch {
|
|
275
703
|
confirmRequest = undefined;
|
|
276
704
|
}
|
|
705
|
+
const deletePending = async () => {
|
|
706
|
+
try {
|
|
707
|
+
await ctrl.store.delete(decisionId);
|
|
708
|
+
}
|
|
709
|
+
catch (e) {
|
|
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.`);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
277
713
|
if (!confirmRequest) {
|
|
278
|
-
await
|
|
279
|
-
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it
|
|
714
|
+
await deletePending();
|
|
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.`);
|
|
280
718
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
281
|
-
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
719
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), diagnostic };
|
|
282
720
|
}
|
|
283
|
-
await
|
|
721
|
+
await deletePending();
|
|
284
722
|
let ev;
|
|
285
723
|
try {
|
|
286
|
-
ev = await postConfirm(ctrl, decisionId, confirmRequest);
|
|
724
|
+
ev = await postConfirm(ctrl, decisionId, confirmRequest, journalled);
|
|
287
725
|
}
|
|
288
726
|
catch (e) {
|
|
289
|
-
ev = {
|
|
727
|
+
ev = {
|
|
728
|
+
reportRecorded: false, executionConfirmed: false,
|
|
729
|
+
diagnostic: journalled
|
|
730
|
+
? `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").`
|
|
731
|
+
: `the execution report did not reach Decidio (${e?.message ?? e}) AND could not be journalled locally, so nothing here can re-send it. Your action DID run - record the outcome from your own system.`,
|
|
732
|
+
};
|
|
290
733
|
}
|
|
291
734
|
if (ev.reportRecorded)
|
|
292
|
-
|
|
735
|
+
await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
|
|
293
736
|
const diagnostic = buildProblem
|
|
294
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.`
|
|
295
738
|
: ev.diagnostic;
|
|
296
739
|
if (!ev.reportRecorded)
|
|
297
740
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
298
|
-
else if (!ev.executionConfirmed)
|
|
741
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
299
742
|
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
300
|
-
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(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 } : {}) };
|
|
301
744
|
}
|
|
302
745
|
const refusalWarned = new Set();
|
|
303
746
|
const refuseOnce = (decisionId, msg) => {
|
|
@@ -314,11 +757,13 @@ export function createDecidioResume(opts) {
|
|
|
314
757
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
315
758
|
webhookSecret: secret,
|
|
316
759
|
store: opts.store,
|
|
317
|
-
register(action, fn) {
|
|
760
|
+
register(action, fn, evidence, withContext) {
|
|
318
761
|
const prev = registry.get(action);
|
|
319
|
-
if (prev && prev !== fn)
|
|
762
|
+
if (prev && prev.fn !== fn)
|
|
320
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.`);
|
|
321
|
-
|
|
764
|
+
if (prev && prev.evidence && evidence && prev.evidence !== evidence)
|
|
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.`);
|
|
766
|
+
registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
|
|
322
767
|
},
|
|
323
768
|
async park(p) {
|
|
324
769
|
try {
|
|
@@ -368,7 +813,7 @@ export function createDecidioResume(opts) {
|
|
|
368
813
|
}
|
|
369
814
|
if (integrity === "unbound")
|
|
370
815
|
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
371
|
-
const fn = registry.get(pending.action);
|
|
816
|
+
const fn = registry.get(pending.action)?.fn;
|
|
372
817
|
if (!fn) {
|
|
373
818
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
374
819
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
@@ -378,21 +823,37 @@ export function createDecidioResume(opts) {
|
|
|
378
823
|
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
379
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` });
|
|
380
825
|
}
|
|
381
|
-
const
|
|
382
|
-
|
|
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
|
+
}
|
|
383
833
|
return { status: "unknown", decisionId };
|
|
834
|
+
}
|
|
384
835
|
try {
|
|
385
|
-
|
|
386
|
-
return finish(await reportAfterExecution(pending, decisionId, result));
|
|
836
|
+
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
387
837
|
}
|
|
388
838
|
catch (e) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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" });
|
|
392
842
|
}
|
|
393
|
-
|
|
394
|
-
|
|
843
|
+
let result;
|
|
844
|
+
try {
|
|
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);
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
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 });
|
|
395
855
|
}
|
|
856
|
+
return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
|
|
396
857
|
},
|
|
397
858
|
webhookHandler() {
|
|
398
859
|
return (req, res) => {
|
|
@@ -419,7 +880,7 @@ export function createDecidioResume(opts) {
|
|
|
419
880
|
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
|
|
420
881
|
res.statusCode = 200;
|
|
421
882
|
res.setHeader("content-type", "application/json");
|
|
422
|
-
res.end(JSON.stringify(
|
|
883
|
+
res.end(JSON.stringify(webhookBody(outcome)));
|
|
423
884
|
}
|
|
424
885
|
catch (e) {
|
|
425
886
|
res.statusCode = 500;
|
|
@@ -443,52 +904,151 @@ export function createDecidioResume(opts) {
|
|
|
443
904
|
try {
|
|
444
905
|
const body = JSON.parse(raw || "{}");
|
|
445
906
|
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
|
|
446
|
-
return new Response(JSON.stringify(
|
|
907
|
+
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
447
908
|
}
|
|
448
909
|
catch (e) {
|
|
449
910
|
return new Response(JSON.stringify({ error: e?.message ?? "resume failed" }), { status: 500 });
|
|
450
911
|
}
|
|
451
912
|
};
|
|
452
913
|
},
|
|
453
|
-
async
|
|
454
|
-
|
|
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
|
+
}
|
|
455
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) {
|
|
456
981
|
return {
|
|
457
982
|
reportRecorded: false, executionConfirmed: false,
|
|
458
|
-
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.`,
|
|
459
984
|
};
|
|
460
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.`);
|
|
461
1001
|
let request;
|
|
462
1002
|
try {
|
|
463
|
-
request = buildResumeConfirmRequest(ctrl, decisionId, entry.
|
|
1003
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, entry.evidence);
|
|
464
1004
|
}
|
|
465
1005
|
catch (e) {
|
|
466
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.` };
|
|
467
1007
|
}
|
|
468
|
-
|
|
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
|
+
}
|
|
469
1015
|
if (ev.reportRecorded)
|
|
470
|
-
|
|
1016
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
|
|
471
1017
|
return ev;
|
|
472
1018
|
},
|
|
473
1019
|
async recover() {
|
|
474
1020
|
const items = [];
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|
|
492
1052
|
}
|
|
493
1053
|
return items;
|
|
494
1054
|
},
|