@decidio/sdk 0.2.2 → 0.3.3
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 +189 -12
- package/dist/adapters/_gate.js +1 -1
- package/dist/adapters/langgraph.d.ts +2 -2
- package/dist/adapters/langgraph.js +2 -2
- package/dist/adapters/openai.d.ts +14 -4
- package/dist/adapters/openai.js +3 -2
- package/dist/adapters/temporal.d.ts +2 -2
- package/dist/adapters/temporal.js +7 -2
- package/dist/cli.js +97 -24
- package/dist/errors.js +12 -4
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +13 -7
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -2
- package/dist/inngest.d.ts +10 -2
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +164 -27
- package/dist/resume.js +432 -201
- package/dist/signing.d.ts +8 -1
- package/dist/signing.js +18 -23
- package/openapi.yaml +38 -0
- package/package.json +3 -8
package/dist/resume.js
CHANGED
|
@@ -5,8 +5,8 @@ import { userInfo } from "node:os";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
|
-
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
|
-
import { DecidioHttpError } from "./errors.js";
|
|
8
|
+
import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
|
|
9
|
+
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
10
10
|
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
11
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
12
12
|
const bytes = argsBindingBytes(p);
|
|
@@ -78,9 +78,9 @@ export function verifyEvidence(rec, agentKey, webhookSecret) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
export const EXECUTION_STATES = [
|
|
81
|
-
"parked", "claimed_preinvoke", "invoking", "executed_unreported", "indeterminate", "reported",
|
|
81
|
+
"parked", "claimed_preinvoke", "discarded", "invoking", "executed_unreported", "indeterminate", "reported",
|
|
82
82
|
];
|
|
83
|
-
export const NON_REPLAYABLE = ["invoking", "executed_unreported", "indeterminate", "reported"];
|
|
83
|
+
export const NON_REPLAYABLE = ["discarded", "invoking", "executed_unreported", "indeterminate", "reported"];
|
|
84
84
|
export class DecidioStaleAttemptError extends Error {
|
|
85
85
|
decisionId;
|
|
86
86
|
owner;
|
|
@@ -101,16 +101,47 @@ export class DecidioAlreadySettledError extends Error {
|
|
|
101
101
|
this.name = "DecidioAlreadySettledError";
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
export class DecidioRecoveryUnavailableError extends Error {
|
|
105
|
+
cause;
|
|
106
|
+
constructor(cause) {
|
|
107
|
+
super(`[decidio] recover(): the execution ledger could not be read (${cause?.message ?? cause}). Refusing to return an empty recovery list, which would read as "nothing is outstanding" — inspect the store; some actions may be awaiting a report.`);
|
|
108
|
+
this.cause = cause;
|
|
109
|
+
this.name = "DecidioRecoveryUnavailableError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export class DecidioDeniedError extends Error {
|
|
113
|
+
decisionId;
|
|
114
|
+
verdict;
|
|
115
|
+
constructor(decisionId, verdict) {
|
|
116
|
+
super(`[decidio] ${decisionId}: this decision was DENIED (${verdict}) and a denial is terminal — refusing a state write that would move it back toward execution. Nothing may advance out of a denial; if this action should now proceed it needs a NEW request and a new approval.`);
|
|
117
|
+
this.decisionId = decisionId;
|
|
118
|
+
this.verdict = verdict;
|
|
119
|
+
this.name = "DecidioDeniedError";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function assertNotDenied(id, cur, next) {
|
|
123
|
+
if (cur.state === "discarded" && next !== "discarded")
|
|
124
|
+
throw new DecidioDeniedError(id, cur.verdict ?? "rejected");
|
|
125
|
+
}
|
|
126
|
+
function assertClaimedBy(id, cur, attemptId) {
|
|
127
|
+
if (cur.state !== "claimed_preinvoke" || cur.attemptId !== attemptId) {
|
|
128
|
+
throw new Error(`[decidio] ${id}: refusing to cross the invocation boundary — attempt ${attemptId} does not hold a claim on this decision (state ${cur.state}, held by ${cur.attemptId || "nobody"}). A claim is the only way to acquire ownership; call claim() and act on its result.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
104
131
|
function assertNotSettled(id, cur, next) {
|
|
105
132
|
if (cur.reconciled?.outcome === "committed" && (next === "executed_unreported" || next === "indeterminate")) {
|
|
106
133
|
throw new DecidioAlreadySettledError(id);
|
|
107
134
|
}
|
|
108
135
|
}
|
|
109
136
|
export const SETTLE_OUTCOMES = ["committed", "not_committed"];
|
|
110
|
-
export function assertSettlable(id, state, outcome,
|
|
137
|
+
export function assertSettlable(id, state, outcome, opts = {}) {
|
|
138
|
+
const hasObservedEvidence = opts.hasObservedEvidence ?? true;
|
|
111
139
|
if (!SETTLE_OUTCOMES.includes(outcome)) {
|
|
112
140
|
throw new Error(`[decidio] ${id}: outcome must be "committed" or "not_committed", not ${JSON.stringify(outcome)}.`);
|
|
113
141
|
}
|
|
142
|
+
if (state === "discarded") {
|
|
143
|
+
throw new Error(`[decidio] ${id}: this decision was DENIED by a human or a policy, and a denial is final — it cannot be reconciled, released or retried. Nothing ran, so there is no outcome to record. If this action should now proceed it needs a NEW request and a new approval.`);
|
|
144
|
+
}
|
|
114
145
|
if (outcome === "committed") {
|
|
115
146
|
if (state === "reported")
|
|
116
147
|
throw new Error(`[decidio] ${id}: already reported — there is nothing left to reconcile.`);
|
|
@@ -126,6 +157,9 @@ export function assertSettlable(id, state, outcome, hasObservedEvidence = true)
|
|
|
126
157
|
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
158
|
if (state === "parked")
|
|
128
159
|
throw new Error(`[decidio] ${id}: already parked — nothing to release.`);
|
|
160
|
+
if (state === "invoking" && opts.workerStopped !== true) {
|
|
161
|
+
throw new Error(`[decidio] ${id}: this decision is still INVOKING — the worker that claimed it may be running the action right now, and releasing it would let a second worker run the same approved action. Stop or confirm the death of that worker first, then say so explicitly: reconcile("${id}", { outcome: "not_committed", reason: …, workerStopped: true }). If you cannot confirm it stopped, treat the decision as possibly executed and reconcile it as "committed" once you have checked the downstream system.`);
|
|
162
|
+
}
|
|
129
163
|
}
|
|
130
164
|
}
|
|
131
165
|
const rank = (s) => EXECUTION_STATES.indexOf(s);
|
|
@@ -135,11 +169,12 @@ export class MemoryPendingStore {
|
|
|
135
169
|
rec(id) {
|
|
136
170
|
return this.x.get(id) ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
137
171
|
}
|
|
138
|
-
advance(id, attemptId, state, patch = {}) {
|
|
172
|
+
advance(id, attemptId, state, patch = {}, takeover = false) {
|
|
139
173
|
const cur = this.rec(id);
|
|
140
|
-
if (cur.attemptId && cur.attemptId !== attemptId
|
|
174
|
+
if (!takeover && cur.attemptId && cur.attemptId !== attemptId) {
|
|
141
175
|
throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
|
|
142
176
|
}
|
|
177
|
+
assertNotDenied(id, cur, state);
|
|
143
178
|
assertNotSettled(id, cur, state);
|
|
144
179
|
if (rank(state) < rank(cur.state)) {
|
|
145
180
|
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
|
|
@@ -152,7 +187,15 @@ export class MemoryPendingStore {
|
|
|
152
187
|
this.x.set(p.decisionId, { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
153
188
|
}
|
|
154
189
|
async get(id) { return this.m.get(id) ?? null; }
|
|
155
|
-
async delete(id) {
|
|
190
|
+
async delete(id, attemptId) {
|
|
191
|
+
if (attemptId && !this.ownsPayload(id, attemptId))
|
|
192
|
+
return;
|
|
193
|
+
this.m.delete(id);
|
|
194
|
+
}
|
|
195
|
+
ownsPayload(id, attemptId) {
|
|
196
|
+
const owner = this.x.get(id)?.attemptId;
|
|
197
|
+
return !owner || owner === attemptId;
|
|
198
|
+
}
|
|
156
199
|
async list() { return [...this.m.values()].filter((p) => this.rec(p.decisionId).state === "parked"); }
|
|
157
200
|
async claim(id, attemptId) {
|
|
158
201
|
if (!this.m.has(id))
|
|
@@ -162,59 +205,87 @@ export class MemoryPendingStore {
|
|
|
162
205
|
return { ok: false, state: cur.state, reason: `already ${cur.state}` };
|
|
163
206
|
if (cur.state === "claimed_preinvoke")
|
|
164
207
|
return { ok: false, state: cur.state, reason: "claimed by another attempt" };
|
|
165
|
-
this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action });
|
|
208
|
+
this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action }, true);
|
|
166
209
|
return { ok: true };
|
|
167
210
|
}
|
|
168
|
-
async markInvoking(id, attemptId) {
|
|
211
|
+
async markInvoking(id, attemptId) {
|
|
212
|
+
const cur = this.rec(id);
|
|
213
|
+
assertNotDenied(id, cur, "invoking");
|
|
214
|
+
assertClaimedBy(id, cur, attemptId);
|
|
215
|
+
this.advance(id, attemptId, "invoking");
|
|
216
|
+
}
|
|
169
217
|
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.advance(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
170
218
|
async markIndeterminate(id, attemptId, error) { this.advance(id, attemptId, "indeterminate", { error }); }
|
|
171
219
|
async markReported(id, attemptId, confirmation) { this.advance(id, attemptId, "reported", { confirmation }); }
|
|
220
|
+
async markDiscarded(id, verdict, reason) { this.advance(id, this.x.get(id)?.attemptId ?? "", "discarded", { verdict, ...(reason ? { discardReason: reason } : {}) }); }
|
|
172
221
|
async getExecution(id) { return this.x.get(id) ?? null; }
|
|
173
222
|
async listExecutions() { return [...this.x.values()]; }
|
|
174
223
|
async settle(id, outcome, opts = {}) {
|
|
175
224
|
const cur = this.rec(id);
|
|
176
|
-
assertSettlable(id, cur.state, outcome, !cur.evidenceError);
|
|
225
|
+
assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
|
|
177
226
|
const at = new Date().toISOString();
|
|
178
|
-
const reconciled = { outcome, reason: opts.reason, at };
|
|
227
|
+
const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
|
|
179
228
|
this.x.set(id, outcome === "committed"
|
|
180
229
|
? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
|
|
181
230
|
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at });
|
|
182
231
|
}
|
|
183
232
|
async releasePreinvoke(id, attemptId) {
|
|
184
233
|
const cur = this.rec(id);
|
|
185
|
-
if (
|
|
186
|
-
throw new Error(`[decidio] ${id}: refusing to release
|
|
234
|
+
if (NON_REPLAYABLE.includes(cur.state))
|
|
235
|
+
throw new Error(`[decidio] ${id}: refusing to release this decision — its durable state is ${cur.state}, which is terminal or past the invocation boundary. releasePreinvoke only ever releases a claim nothing has acted on.`);
|
|
187
236
|
if (cur.attemptId && cur.attemptId !== attemptId)
|
|
188
237
|
return;
|
|
189
238
|
this.x.set(id, { ...cur, state: "parked", attemptId: null, at: new Date().toISOString() });
|
|
190
239
|
}
|
|
191
240
|
}
|
|
241
|
+
export function windowsPrincipal() {
|
|
242
|
+
const sysRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
|
|
243
|
+
for (const exe of [join(sysRoot, "System32", "whoami.exe"), "whoami"]) {
|
|
244
|
+
try {
|
|
245
|
+
const out = execFileSync(exe, ["/user", "/fo", "csv", "/nh"], { encoding: "utf8" });
|
|
246
|
+
const m = out.match(/S-1-[0-9-]+/);
|
|
247
|
+
if (m)
|
|
248
|
+
return `*${m[0]}`;
|
|
249
|
+
}
|
|
250
|
+
catch { }
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
return userInfo().username;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export function hardenFile(file) {
|
|
260
|
+
if (process.platform !== "win32")
|
|
261
|
+
return null;
|
|
262
|
+
try {
|
|
263
|
+
const principal = windowsPrincipal();
|
|
264
|
+
if (!principal)
|
|
265
|
+
return "could not determine the current user";
|
|
266
|
+
execFileSync("icacls", [file, "/inheritance:r", "/grant:r", `${principal}:F`, "/grant:r", "*S-1-5-18:F"], { stdio: "ignore" });
|
|
267
|
+
try {
|
|
268
|
+
readFileSync(file, "utf8");
|
|
269
|
+
}
|
|
270
|
+
catch (verifyError) {
|
|
271
|
+
try {
|
|
272
|
+
execFileSync("icacls", [file, "/inheritance:e"], { stdio: "ignore" });
|
|
273
|
+
}
|
|
274
|
+
catch { }
|
|
275
|
+
return `the restriction applied but this process could no longer read the file (${verifyError?.message ?? verifyError}), so it was rolled back`;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
return String(e?.message ?? e);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
192
283
|
export function hardenDir(dir) {
|
|
193
284
|
if (process.platform !== "win32")
|
|
194
285
|
return;
|
|
195
286
|
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.`);
|
|
196
287
|
try {
|
|
197
|
-
const
|
|
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
|
-
})();
|
|
288
|
+
const principal = windowsPrincipal();
|
|
218
289
|
if (!principal)
|
|
219
290
|
throw new Error("could not determine the current user");
|
|
220
291
|
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${principal}:(OI)(CI)F`, "/grant:r", "*S-1-5-18:(OI)(CI)F"], { stdio: "ignore" });
|
|
@@ -250,14 +321,40 @@ export class FilePendingStore {
|
|
|
250
321
|
this.migrateLegacy();
|
|
251
322
|
}
|
|
252
323
|
catch (e) {
|
|
253
|
-
console.error(`[decidio] could not upgrade the
|
|
324
|
+
console.error(`[decidio] could not upgrade the older pending layout in ${dir} (${e?.message ?? e}). Entries written by an older SDK may not appear in recover() — inspect the directory before assuming it is empty.`);
|
|
254
325
|
}
|
|
255
326
|
}
|
|
256
327
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
257
328
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
329
|
+
revPath(key, n) { return join(this.dir, `${key}.rev.${n}`); }
|
|
330
|
+
markerPath(key, s) { return join(this.dir, `${key}.${s}`); }
|
|
331
|
+
listRevs(key, names) {
|
|
332
|
+
const prefix = `${key}.rev.`;
|
|
333
|
+
const out = [];
|
|
334
|
+
if (!names) {
|
|
335
|
+
try {
|
|
336
|
+
names = readdirSync(this.dir);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const name of names) {
|
|
343
|
+
if (!name.startsWith(prefix))
|
|
344
|
+
continue;
|
|
345
|
+
const tail = name.slice(prefix.length);
|
|
346
|
+
if (tail.length && /^\d+$/.test(tail))
|
|
347
|
+
out.push(Number(tail));
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
legacyMarkers(key, names) {
|
|
352
|
+
if (names)
|
|
353
|
+
return EXECUTION_STATES.filter((s) => names.includes(`${key}.${s}`));
|
|
354
|
+
return EXECUTION_STATES.filter((s) => existsSync(this.markerPath(key, s)));
|
|
355
|
+
}
|
|
356
|
+
writeTemp(target, text) {
|
|
357
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
261
358
|
const fd = openSync(tmp, "w", 0o600);
|
|
262
359
|
try {
|
|
263
360
|
writeFileSync(fd, text);
|
|
@@ -266,73 +363,141 @@ export class FilePendingStore {
|
|
|
266
363
|
finally {
|
|
267
364
|
closeSync(fd);
|
|
268
365
|
}
|
|
366
|
+
return tmp;
|
|
367
|
+
}
|
|
368
|
+
writeAtomic(file, text) {
|
|
369
|
+
const tmp = this.writeTemp(file, text);
|
|
269
370
|
renameSync(tmp, file);
|
|
270
371
|
}
|
|
271
|
-
|
|
372
|
+
readHead(id, names) {
|
|
373
|
+
const key = this.key(id);
|
|
374
|
+
for (let tries = 0; tries < 4; tries++) {
|
|
375
|
+
const revs = this.listRevs(key, names);
|
|
376
|
+
const markers = this.legacyMarkers(key, names);
|
|
377
|
+
names = undefined;
|
|
378
|
+
if (revs.length === 0) {
|
|
379
|
+
if (markers.length === 0)
|
|
380
|
+
return { rec: null, rev: 0, corrupt: [] };
|
|
381
|
+
const legacy = this.resolveLegacyMarkers(key);
|
|
382
|
+
return { rec: legacy.rec, rev: 0, corrupt: legacy.corrupt };
|
|
383
|
+
}
|
|
384
|
+
if (markers.length) {
|
|
385
|
+
return { rec: null, rev: Math.max(...revs), corrupt: markers.map((s) => `${this.markerPath(key, s)}: a legacy state marker beside the revision chain — written by an older SDK after this directory was upgraded, or left by an interrupted upgrade; the two cannot be reconciled automatically`) };
|
|
386
|
+
}
|
|
387
|
+
const top = Math.max(...revs);
|
|
388
|
+
const f = this.revPath(key, top);
|
|
389
|
+
try {
|
|
390
|
+
const r = JSON.parse(readFileSync(f, "utf8"));
|
|
391
|
+
if (!r || typeof r !== "object" || !EXECUTION_STATES.includes(r.state)) {
|
|
392
|
+
return { rec: null, rev: top, corrupt: [`${f}: the record carries no recognised state (${JSON.stringify(r?.state ?? null)})`] };
|
|
393
|
+
}
|
|
394
|
+
return { rec: r, rev: top, corrupt: [] };
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
if (e?.code === "ENOENT")
|
|
398
|
+
continue;
|
|
399
|
+
return { rec: null, rev: top, corrupt: [`${f}: ${e?.message ?? e}`] };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { rec: null, rev: 0, corrupt: [`${key}: the revision chain changed under every read attempt`] };
|
|
403
|
+
}
|
|
404
|
+
_barrier(_op, _id, _next) { }
|
|
405
|
+
transition(id, op, compute) {
|
|
406
|
+
const key = this.key(id);
|
|
407
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
408
|
+
const { rec, rev, corrupt } = this.readHead(id);
|
|
409
|
+
if (corrupt.length)
|
|
410
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
411
|
+
const next = compute(rec, rev);
|
|
412
|
+
if (next === null)
|
|
413
|
+
return null;
|
|
414
|
+
this._barrier(op, id, next.state);
|
|
415
|
+
const target = this.revPath(key, rev + 1);
|
|
416
|
+
const tmp = this.writeTemp(target, JSON.stringify(next, null, 2));
|
|
417
|
+
try {
|
|
418
|
+
linkSync(tmp, target);
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
try {
|
|
422
|
+
rmSync(tmp);
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
if (e?.code === "EEXIST")
|
|
426
|
+
continue;
|
|
427
|
+
throw e;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
rmSync(tmp);
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
return next;
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`[decidio] ${id}: could not publish a state transition after 16 attempts — another writer kept winning the next revision. Retry, or look for a process writing this decision in a loop.`);
|
|
436
|
+
}
|
|
437
|
+
resolveLegacyMarkers(key) {
|
|
272
438
|
const corrupt = [];
|
|
273
439
|
let best = null;
|
|
440
|
+
let denial = null;
|
|
274
441
|
for (const s of EXECUTION_STATES) {
|
|
275
|
-
const f = this.
|
|
442
|
+
const f = this.markerPath(key, s);
|
|
276
443
|
if (!existsSync(f))
|
|
277
444
|
continue;
|
|
278
445
|
try {
|
|
279
446
|
const r = JSON.parse(readFileSync(f, "utf8"));
|
|
280
447
|
if (!best || rank(s) >= rank(best.state))
|
|
281
448
|
best = { ...r, state: s };
|
|
449
|
+
if (s === "discarded")
|
|
450
|
+
denial = { ...r, state: "discarded" };
|
|
282
451
|
}
|
|
283
452
|
catch (e) {
|
|
284
453
|
corrupt.push(`${f}: ${e.message}`);
|
|
285
454
|
}
|
|
286
455
|
}
|
|
287
|
-
|
|
456
|
+
if (!denial)
|
|
457
|
+
return { rec: best, corrupt };
|
|
458
|
+
const ran = best && rank(best.state) >= rank("invoking") ? best : null;
|
|
459
|
+
return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
|
|
288
460
|
}
|
|
289
|
-
setState(id, attemptId, state, patch = {}) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
}
|
|
461
|
+
setState(id, attemptId, state, patch = {}, pre) {
|
|
462
|
+
this.transition(id, "advance", (rec) => {
|
|
463
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
464
|
+
pre?.(cur);
|
|
465
|
+
if (cur.attemptId && cur.attemptId !== attemptId)
|
|
466
|
+
throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
|
|
467
|
+
assertNotDenied(id, cur, state);
|
|
468
|
+
assertNotSettled(id, cur, state);
|
|
469
|
+
if (rank(state) < rank(cur.state))
|
|
470
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
|
|
471
|
+
return { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
|
|
472
|
+
});
|
|
315
473
|
}
|
|
316
474
|
async put(p) {
|
|
317
475
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
476
|
+
this.transition(p.decisionId, "put", (cur) => cur ? null
|
|
477
|
+
: { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
321
478
|
}
|
|
322
479
|
async get(id) {
|
|
323
480
|
const f = this.path(id);
|
|
324
481
|
return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
|
|
325
482
|
}
|
|
326
|
-
async delete(id) {
|
|
327
|
-
if (
|
|
328
|
-
|
|
483
|
+
async delete(id, attemptId) {
|
|
484
|
+
if (attemptId) {
|
|
485
|
+
const owner = this.readHead(id).rec?.attemptId;
|
|
486
|
+
if (owner && owner !== attemptId)
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
if (existsSync(this.path(id)))
|
|
491
|
+
rmSync(this.path(id));
|
|
492
|
+
}
|
|
493
|
+
catch { }
|
|
329
494
|
}
|
|
330
|
-
catch { } }
|
|
331
495
|
async list() {
|
|
332
496
|
if (!existsSync(this.dir))
|
|
333
497
|
return [];
|
|
334
498
|
const out = [];
|
|
335
|
-
|
|
499
|
+
const names = readdirSync(this.dir);
|
|
500
|
+
for (const f of names.filter((f) => f.endsWith(".json"))) {
|
|
336
501
|
let p;
|
|
337
502
|
try {
|
|
338
503
|
p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
@@ -341,7 +506,7 @@ export class FilePendingStore {
|
|
|
341
506
|
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
342
507
|
continue;
|
|
343
508
|
}
|
|
344
|
-
const { rec } = this.
|
|
509
|
+
const { rec } = this.readHead(p.decisionId, names);
|
|
345
510
|
if (!rec || rec.state === "parked")
|
|
346
511
|
out.push(p);
|
|
347
512
|
}
|
|
@@ -350,53 +515,41 @@ export class FilePendingStore {
|
|
|
350
515
|
async claim(id, attemptId) {
|
|
351
516
|
if (!existsSync(this.path(id)))
|
|
352
517
|
return { ok: false, reason: "no parked entry" };
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
}
|
|
372
|
-
try {
|
|
373
|
-
linkSync(tmp, target);
|
|
374
|
-
}
|
|
375
|
-
catch {
|
|
376
|
-
try {
|
|
377
|
-
rmSync(tmp);
|
|
518
|
+
let refusal = null;
|
|
519
|
+
this.transition(id, "claim", (cur) => {
|
|
520
|
+
const state = cur?.state ?? "parked";
|
|
521
|
+
if (NON_REPLAYABLE.includes(state)) {
|
|
522
|
+
refusal = { ok: false, state, reason: `already ${state}` };
|
|
523
|
+
return null;
|
|
378
524
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return { ok: true };
|
|
525
|
+
if (state === "claimed_preinvoke") {
|
|
526
|
+
refusal = { ok: false, state, reason: "claimed by another attempt" };
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
return { ...(cur ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: cur?.action, at: new Date().toISOString() };
|
|
530
|
+
});
|
|
531
|
+
return refusal ?? { ok: true };
|
|
532
|
+
}
|
|
533
|
+
async markInvoking(id, attemptId) {
|
|
534
|
+
this.setState(id, attemptId, "invoking", {}, (cur) => {
|
|
535
|
+
assertNotDenied(id, cur, "invoking");
|
|
536
|
+
assertClaimedBy(id, cur, attemptId);
|
|
537
|
+
});
|
|
393
538
|
}
|
|
394
|
-
async markInvoking(id, attemptId) { this.setState(id, attemptId, "invoking"); }
|
|
395
539
|
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
396
540
|
async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
|
|
397
541
|
async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
|
|
542
|
+
async markDiscarded(id, verdict, reason) {
|
|
543
|
+
this.transition(id, "discard", (rec) => {
|
|
544
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
545
|
+
assertNotSettled(id, cur, "discarded");
|
|
546
|
+
if (rank("discarded") < rank(cur.state))
|
|
547
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → discarded; execution state only ever advances.`);
|
|
548
|
+
return { ...cur, decisionId: id, attemptId: cur.attemptId ?? "", state: "discarded", verdict, ...(reason ? { discardReason: reason } : {}), at: new Date().toISOString() };
|
|
549
|
+
});
|
|
550
|
+
}
|
|
398
551
|
async getExecution(id) {
|
|
399
|
-
const { rec, corrupt } = this.
|
|
552
|
+
const { rec, corrupt } = this.readHead(id);
|
|
400
553
|
if (corrupt.length)
|
|
401
554
|
throw new DecidioStateCorruptError(id, corrupt);
|
|
402
555
|
return rec;
|
|
@@ -404,21 +557,26 @@ export class FilePendingStore {
|
|
|
404
557
|
async listExecutions() {
|
|
405
558
|
if (!existsSync(this.dir))
|
|
406
559
|
return [];
|
|
407
|
-
const
|
|
408
|
-
|
|
560
|
+
const keys = new Set();
|
|
561
|
+
const names = readdirSync(this.dir);
|
|
562
|
+
for (const name of names) {
|
|
563
|
+
const m = /^(.*)\.rev\.\d+$/.exec(name);
|
|
564
|
+
if (m) {
|
|
565
|
+
keys.add(m[1]);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
409
568
|
const dot = name.lastIndexOf(".");
|
|
410
569
|
if (dot < 0)
|
|
411
570
|
continue;
|
|
412
571
|
const suffix = name.slice(dot + 1);
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
ids.add(name.slice(0, dot));
|
|
572
|
+
if (EXECUTION_STATES.includes(suffix))
|
|
573
|
+
keys.add(name.slice(0, dot));
|
|
416
574
|
}
|
|
417
575
|
const out = [];
|
|
418
|
-
for (const key of
|
|
419
|
-
const { rec, corrupt } = this.
|
|
576
|
+
for (const key of keys) {
|
|
577
|
+
const { rec, corrupt } = this.readHead(key, names);
|
|
420
578
|
if (corrupt.length) {
|
|
421
|
-
out.push({ decisionId: key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
|
|
579
|
+
out.push({ decisionId: rec?.decisionId ?? key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
|
|
422
580
|
continue;
|
|
423
581
|
}
|
|
424
582
|
if (rec)
|
|
@@ -427,54 +585,92 @@ export class FilePendingStore {
|
|
|
427
585
|
return out;
|
|
428
586
|
}
|
|
429
587
|
async settle(id, outcome, opts = {}) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
}
|
|
588
|
+
this.transition(id, "settle", (rec) => {
|
|
589
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
590
|
+
assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
|
|
591
|
+
const at = new Date().toISOString();
|
|
592
|
+
const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
|
|
593
|
+
return outcome === "committed"
|
|
594
|
+
? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
|
|
595
|
+
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
|
|
596
|
+
});
|
|
450
597
|
}
|
|
451
598
|
async releasePreinvoke(id, attemptId) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
rmSync(c);
|
|
463
|
-
}
|
|
464
|
-
catch { }
|
|
599
|
+
this.transition(id, "release", (rec) => {
|
|
600
|
+
const state = rec?.state ?? "parked";
|
|
601
|
+
if (NON_REPLAYABLE.includes(state))
|
|
602
|
+
throw new Error(`[decidio] ${id}: refusing to release this decision — its durable state is ${state}, which is terminal or past the invocation boundary. releasePreinvoke only ever releases a claim nothing has acted on.`);
|
|
603
|
+
if (rec?.attemptId && rec.attemptId !== attemptId)
|
|
604
|
+
return null;
|
|
605
|
+
if (state === "parked")
|
|
606
|
+
return null;
|
|
607
|
+
return { ...(rec ?? {}), decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() };
|
|
608
|
+
});
|
|
465
609
|
}
|
|
466
610
|
migrateLegacy() {
|
|
467
611
|
if (!existsSync(this.dir))
|
|
468
|
-
return { migrated: 0, indeterminate: 0, reports: 0 };
|
|
469
|
-
let migrated = 0, indeterminate = 0;
|
|
612
|
+
return { migrated: 0, indeterminate: 0, reports: 0, chained: 0 };
|
|
613
|
+
let migrated = 0, indeterminate = 0, chained = 0;
|
|
614
|
+
const markerKeys = new Set();
|
|
615
|
+
for (const name of readdirSync(this.dir)) {
|
|
616
|
+
const dot = name.lastIndexOf(".");
|
|
617
|
+
if (dot < 0)
|
|
618
|
+
continue;
|
|
619
|
+
if (EXECUTION_STATES.includes(name.slice(dot + 1)))
|
|
620
|
+
markerKeys.add(name.slice(0, dot));
|
|
621
|
+
}
|
|
622
|
+
for (const key of markerKeys) {
|
|
623
|
+
if (this.listRevs(key).length)
|
|
624
|
+
continue;
|
|
625
|
+
const present = this.legacyMarkers(key);
|
|
626
|
+
const { rec, corrupt } = this.resolveLegacyMarkers(key);
|
|
627
|
+
if (corrupt.length || !rec) {
|
|
628
|
+
console.error(`[decidio] ${key}: the older state marker(s) for this decision could not be read (${corrupt.join("; ") || "no readable marker"}) — left in place; the decision reads as unreadable until a person inspects it.`);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const target = this.revPath(key, 1);
|
|
632
|
+
const tmp = this.writeTemp(target, JSON.stringify(rec, null, 2));
|
|
633
|
+
try {
|
|
634
|
+
linkSync(tmp, target);
|
|
635
|
+
chained++;
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
if (e?.code !== "EEXIST") {
|
|
639
|
+
try {
|
|
640
|
+
rmSync(tmp);
|
|
641
|
+
}
|
|
642
|
+
catch { }
|
|
643
|
+
throw e;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
rmSync(tmp);
|
|
648
|
+
}
|
|
649
|
+
catch { }
|
|
650
|
+
for (const s of present) {
|
|
651
|
+
try {
|
|
652
|
+
rmSync(this.markerPath(key, s));
|
|
653
|
+
}
|
|
654
|
+
catch { }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (chained)
|
|
658
|
+
console.error(`[decidio] upgraded ${chained} decision${chained === 1 ? "" : "s"} from the one-marker-per-state layout to the revision chain. Do not run an older SDK against this directory again: it would write markers this layout refuses to read.`);
|
|
470
659
|
for (const name of readdirSync(this.dir)) {
|
|
471
660
|
if (name.endsWith(".claimed")) {
|
|
472
661
|
const key = name.slice(0, -".claimed".length);
|
|
473
662
|
try {
|
|
474
663
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
475
|
-
this.
|
|
664
|
+
const published = this.transition(p.decisionId, "migrate", (cur) => cur && NON_REPLAYABLE.includes(cur.state) ? null : ({
|
|
665
|
+
...(cur ?? {}), decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action,
|
|
666
|
+
error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString(),
|
|
667
|
+
}));
|
|
668
|
+
if (published === null) {
|
|
669
|
+
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
476
672
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
477
|
-
|
|
673
|
+
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
478
674
|
indeterminate++;
|
|
479
675
|
}
|
|
480
676
|
catch {
|
|
@@ -488,9 +684,10 @@ export class FilePendingStore {
|
|
|
488
684
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
489
685
|
if (!p?.decisionId)
|
|
490
686
|
continue;
|
|
491
|
-
if (this.
|
|
687
|
+
if (this.readHead(p.decisionId).rec)
|
|
492
688
|
continue;
|
|
493
|
-
this.
|
|
689
|
+
this.transition(p.decisionId, "migrate", (cur) => cur ? null
|
|
690
|
+
: { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
494
691
|
migrated++;
|
|
495
692
|
}
|
|
496
693
|
catch { }
|
|
@@ -498,7 +695,7 @@ export class FilePendingStore {
|
|
|
498
695
|
if (indeterminate)
|
|
499
696
|
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
697
|
const reports = this.migrateLegacyReports();
|
|
501
|
-
return { migrated, indeterminate, reports };
|
|
698
|
+
return { migrated, indeterminate, reports, chained };
|
|
502
699
|
}
|
|
503
700
|
migrateLegacyReports() {
|
|
504
701
|
const reportsDir = join(this.dir, "reports");
|
|
@@ -513,17 +710,18 @@ export class FilePendingStore {
|
|
|
513
710
|
const e = JSON.parse(readFileSync(file, "utf8"));
|
|
514
711
|
if (!e?.decisionId)
|
|
515
712
|
continue;
|
|
516
|
-
const
|
|
517
|
-
|
|
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,
|
|
713
|
+
const published = this.transition(e.decisionId, "migrate", (cur) => cur && (cur.state === "discarded" || rank(cur.state) >= rank("executed_unreported")) ? null : ({
|
|
714
|
+
...(cur ?? {}), decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
|
|
523
715
|
evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
|
|
524
716
|
at: e.at ?? new Date().toISOString(),
|
|
525
|
-
}
|
|
717
|
+
}));
|
|
526
718
|
renameSync(file, `${file}.migrated`);
|
|
719
|
+
if (published === null) {
|
|
720
|
+
const head = this.readHead(e.decisionId).rec;
|
|
721
|
+
if (head?.state === "discarded")
|
|
722
|
+
console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${head.verdict ?? "rejected"}). It has NOT been added to the ledger — a denial is terminal and this report will not be sent. The file is kept as ${file}.migrated: if that action really ran, check the downstream system and correct it there.`);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
527
725
|
moved++;
|
|
528
726
|
}
|
|
529
727
|
catch {
|
|
@@ -615,6 +813,7 @@ async function statusOf(cfg, decisionId) {
|
|
|
615
813
|
const V2_STORE_METHODS = [
|
|
616
814
|
"put", "get", "delete", "list",
|
|
617
815
|
"claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
|
|
816
|
+
"markDiscarded",
|
|
618
817
|
"getExecution", "listExecutions", "releasePreinvoke", "settle",
|
|
619
818
|
];
|
|
620
819
|
function assertStoreV2(store) {
|
|
@@ -622,7 +821,7 @@ function assertStoreV2(store) {
|
|
|
622
821
|
if (!missing.length)
|
|
623
822
|
return;
|
|
624
823
|
throw new Error(`[decidio] this PendingStore is missing ${missing.length} required method${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.\n` +
|
|
625
|
-
`
|
|
824
|
+
` The durable execution state machine is MANDATORY (parked → claimed_preinvoke → invoking → executed_unreported | indeterminate → reported, plus the terminal "discarded" that a denial writes).\n` +
|
|
626
825
|
` 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
826
|
` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
|
|
628
827
|
}
|
|
@@ -632,6 +831,7 @@ function webhookBody(o) {
|
|
|
632
831
|
ok: true,
|
|
633
832
|
outcome: {
|
|
634
833
|
status: o.status, decisionId: o.decisionId,
|
|
834
|
+
...(o.reason ? { reason: o.reason } : {}),
|
|
635
835
|
...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
|
|
636
836
|
...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
|
|
637
837
|
...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
|
|
@@ -679,7 +879,7 @@ export function createDecidioResume(opts) {
|
|
|
679
879
|
if (e instanceof DecidioStaleAttemptError) {
|
|
680
880
|
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
881
|
try {
|
|
682
|
-
await ctrl.store.delete(decisionId);
|
|
882
|
+
await ctrl.store.delete(decisionId, attemptId);
|
|
683
883
|
}
|
|
684
884
|
catch { }
|
|
685
885
|
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.` };
|
|
@@ -687,11 +887,15 @@ export function createDecidioResume(opts) {
|
|
|
687
887
|
if (e instanceof DecidioAlreadySettledError) {
|
|
688
888
|
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
889
|
try {
|
|
690
|
-
await ctrl.store.delete(decisionId);
|
|
890
|
+
await ctrl.store.delete(decisionId, attemptId);
|
|
691
891
|
}
|
|
692
892
|
catch { }
|
|
693
893
|
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
894
|
}
|
|
895
|
+
if (e instanceof DecidioDeniedError) {
|
|
896
|
+
console.error(`[decidio] ${decisionId}: this decision is DENIED, so no execution report will be sent. If your action nevertheless ran, your store let a refused decision reach the handler — treat that as a defect in the store, and correct the downstream effect by hand.`);
|
|
897
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false, diagnostic: `${decisionId} is denied; no report was sent.` };
|
|
898
|
+
}
|
|
695
899
|
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
900
|
}
|
|
697
901
|
}
|
|
@@ -704,7 +908,7 @@ export function createDecidioResume(opts) {
|
|
|
704
908
|
}
|
|
705
909
|
const deletePending = async () => {
|
|
706
910
|
try {
|
|
707
|
-
await ctrl.store.delete(decisionId);
|
|
911
|
+
await ctrl.store.delete(decisionId, attemptId);
|
|
708
912
|
}
|
|
709
913
|
catch (e) {
|
|
710
914
|
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.`);
|
|
@@ -732,7 +936,7 @@ export function createDecidioResume(opts) {
|
|
|
732
936
|
};
|
|
733
937
|
}
|
|
734
938
|
if (ev.reportRecorded)
|
|
735
|
-
await ctrl.store.markReported(decisionId, attemptId, ev
|
|
939
|
+
await ctrl.store.markReported(decisionId, attemptId, tombstoneTier(ev)).catch(() => { });
|
|
736
940
|
const diagnostic = buildProblem
|
|
737
941
|
? `${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.`
|
|
738
942
|
: ev.diagnostic;
|
|
@@ -743,11 +947,11 @@ export function createDecidioResume(opts) {
|
|
|
743
947
|
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 } : {}) };
|
|
744
948
|
}
|
|
745
949
|
const refusalWarned = new Set();
|
|
746
|
-
const refuseOnce = (decisionId, msg) => {
|
|
950
|
+
const refuseOnce = (decisionId, msg, tail) => {
|
|
747
951
|
if (refusalWarned.has(decisionId))
|
|
748
952
|
return;
|
|
749
953
|
refusalWarned.add(decisionId);
|
|
750
|
-
console.error(msg + " (This prints once — the entry stays parked until you act.)");
|
|
954
|
+
console.error(msg + (tail ?? " (This prints once — the entry stays parked until you act.)"));
|
|
751
955
|
};
|
|
752
956
|
const ctrl = {
|
|
753
957
|
...opts,
|
|
@@ -766,6 +970,9 @@ export function createDecidioResume(opts) {
|
|
|
766
970
|
registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
|
|
767
971
|
},
|
|
768
972
|
async park(p) {
|
|
973
|
+
const idProblem = decisionIdProblem(p.decisionId);
|
|
974
|
+
if (idProblem)
|
|
975
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] refusing to park ${JSON.stringify(p.decisionId ?? null)}: it is not a decision id this SDK will act on (${idProblem}). Nothing was parked and nothing will execute - a decision id comes from the gate's answer, verbatim.`, typeof p.decisionId === "string" ? p.decisionId : "");
|
|
769
976
|
try {
|
|
770
977
|
toCanonical(p.args, "args");
|
|
771
978
|
}
|
|
@@ -780,16 +987,16 @@ export function createDecidioResume(opts) {
|
|
|
780
987
|
async resolve({ decisionId, verdict, reason }) {
|
|
781
988
|
const finish = (o) => { if (o.status !== "unknown")
|
|
782
989
|
opts.onResolved?.(o); return o; };
|
|
990
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
991
|
+
if (idProblem)
|
|
992
|
+
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
783
993
|
const pending = await ctrl.store.get(decisionId);
|
|
784
994
|
if (!pending)
|
|
785
995
|
return { status: "unknown", decisionId };
|
|
786
|
-
if (verdict === "rejected") {
|
|
787
|
-
await ctrl.store.
|
|
788
|
-
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
789
|
-
}
|
|
790
|
-
if (verdict === "blocked") {
|
|
996
|
+
if (verdict === "rejected" || verdict === "blocked") {
|
|
997
|
+
await ctrl.store.markDiscarded(decisionId, verdict, reason);
|
|
791
998
|
await ctrl.store.delete(decisionId);
|
|
792
|
-
return finish({ status:
|
|
999
|
+
return finish({ status: verdict, decisionId, action: pending.action, reason });
|
|
793
1000
|
}
|
|
794
1001
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
795
1002
|
return { status: "unknown", decisionId };
|
|
@@ -827,7 +1034,9 @@ export function createDecidioResume(opts) {
|
|
|
827
1034
|
const claim = await ctrl.store.claim(decisionId, attemptId);
|
|
828
1035
|
if (!claim.ok) {
|
|
829
1036
|
if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
|
|
830
|
-
refuseOnce(decisionId,
|
|
1037
|
+
refuseOnce(decisionId, claim.state === "discarded"
|
|
1038
|
+
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1039
|
+
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
|
|
831
1040
|
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
|
|
832
1041
|
}
|
|
833
1042
|
return { status: "unknown", decisionId };
|
|
@@ -877,7 +1086,7 @@ export function createDecidioResume(opts) {
|
|
|
877
1086
|
}
|
|
878
1087
|
const body = JSON.parse(raw || "{}");
|
|
879
1088
|
const verdict = body.verdict;
|
|
880
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1089
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict, reason: body.reason });
|
|
881
1090
|
res.statusCode = 200;
|
|
882
1091
|
res.setHeader("content-type", "application/json");
|
|
883
1092
|
res.end(JSON.stringify(webhookBody(outcome)));
|
|
@@ -903,7 +1112,7 @@ export function createDecidioResume(opts) {
|
|
|
903
1112
|
}
|
|
904
1113
|
try {
|
|
905
1114
|
const body = JSON.parse(raw || "{}");
|
|
906
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1115
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict: body.verdict, reason: body.reason });
|
|
907
1116
|
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
908
1117
|
}
|
|
909
1118
|
catch (e) {
|
|
@@ -912,6 +1121,9 @@ export function createDecidioResume(opts) {
|
|
|
912
1121
|
};
|
|
913
1122
|
},
|
|
914
1123
|
async reconcile(decisionId, opts) {
|
|
1124
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1125
|
+
if (idProblem)
|
|
1126
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was settled.` };
|
|
915
1127
|
let entry;
|
|
916
1128
|
try {
|
|
917
1129
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -933,7 +1145,7 @@ export function createDecidioResume(opts) {
|
|
|
933
1145
|
? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, ctrl.webhookSecret)
|
|
934
1146
|
: undefined;
|
|
935
1147
|
try {
|
|
936
|
-
await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig });
|
|
1148
|
+
await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig, workerStopped: opts.workerStopped });
|
|
937
1149
|
}
|
|
938
1150
|
catch (e) {
|
|
939
1151
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${e?.message ?? e}` };
|
|
@@ -957,13 +1169,16 @@ export function createDecidioResume(opts) {
|
|
|
957
1169
|
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
1170
|
}
|
|
959
1171
|
if (ev.reportRecorded)
|
|
960
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev
|
|
1172
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", tombstoneTier(ev)).catch(() => { });
|
|
961
1173
|
return {
|
|
962
1174
|
...ev,
|
|
963
1175
|
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
1176
|
};
|
|
965
1177
|
},
|
|
966
1178
|
async reconfirm(decisionId, opts) {
|
|
1179
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1180
|
+
if (idProblem)
|
|
1181
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was sent.` };
|
|
967
1182
|
let entry;
|
|
968
1183
|
try {
|
|
969
1184
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -972,11 +1187,20 @@ export function createDecidioResume(opts) {
|
|
|
972
1187
|
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
1188
|
}
|
|
974
1189
|
if (entry?.state === "reported") {
|
|
975
|
-
|
|
1190
|
+
const confirmed = tierConfirms(entry.confirmation);
|
|
1191
|
+
return { reportRecorded: true, executionConfirmed: confirmed, evidenceTier: entry.confirmation, diagnostic: `${decisionId} was already reported (${entry.confirmation ?? "recorded"}${confirmed ? ", a confirming tier" : ", not a confirming tier"}) — nothing to re-send.` };
|
|
976
1192
|
}
|
|
977
1193
|
if (entry?.state === "indeterminate") {
|
|
978
1194
|
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
1195
|
}
|
|
1196
|
+
if (entry?.state === "discarded") {
|
|
1197
|
+
return {
|
|
1198
|
+
reportRecorded: false, executionConfirmed: false,
|
|
1199
|
+
diagnostic: entry.executionDespiteDenial
|
|
1200
|
+
? `${decisionId} was DENIED (${entry.verdict ?? "rejected"}), and this store ALSO holds an execution marker for it (${entry.executionDespiteDenial.state} at ${entry.executionDespiteDenial.at}). A denial is terminal, so no report will be sent for it — but something may have run. Check the downstream system and correct it there; do NOT re-run the action.`
|
|
1201
|
+
: `${decisionId} was DENIED (${entry.verdict ?? "rejected"}) and nothing ran, so there is no execution report to send. A denial cannot be reconciled or retried.`,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
980
1204
|
if (!entry || !entry.hasEvidence) {
|
|
981
1205
|
return {
|
|
982
1206
|
reportRecorded: false, executionConfirmed: false,
|
|
@@ -1013,17 +1237,18 @@ export function createDecidioResume(opts) {
|
|
|
1013
1237
|
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
1238
|
}
|
|
1015
1239
|
if (ev.reportRecorded)
|
|
1016
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev
|
|
1240
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", tombstoneTier(ev)).catch(() => { });
|
|
1017
1241
|
return ev;
|
|
1018
1242
|
},
|
|
1019
1243
|
async recover() {
|
|
1020
1244
|
const items = [];
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1245
|
+
let execs;
|
|
1246
|
+
try {
|
|
1247
|
+
execs = await ctrl.store.listExecutions();
|
|
1248
|
+
}
|
|
1249
|
+
catch (e) {
|
|
1250
|
+
throw new DecidioRecoveryUnavailableError(e);
|
|
1251
|
+
}
|
|
1027
1252
|
for (const r of execs) {
|
|
1028
1253
|
const a = r.action ?? "(unknown action)";
|
|
1029
1254
|
switch (r.state) {
|
|
@@ -1037,7 +1262,7 @@ export function createDecidioResume(opts) {
|
|
|
1037
1262
|
break;
|
|
1038
1263
|
case "invoking":
|
|
1039
1264
|
items.push({ decisionId: r.decisionId, action: r.action, state: "indeterminate",
|
|
1040
|
-
guidance: `"${a}" was invoked at ${r.at} and
|
|
1265
|
+
guidance: `"${a}" was invoked at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} and no outcome has been recorded since. That does NOT mean the worker died — it may still be running this action. It will not be retried automatically. Check that worker first: if it is gone, settle it with { outcome: "committed", evidence: … } once you have checked the downstream system, or release it with { outcome: "not_committed", reason: …, workerStopped: true }.` });
|
|
1041
1266
|
break;
|
|
1042
1267
|
case "claimed_preinvoke":
|
|
1043
1268
|
items.push({ decisionId: r.decisionId, action: r.action, state: "claimed_stalled",
|
|
@@ -1046,6 +1271,12 @@ export function createDecidioResume(opts) {
|
|
|
1046
1271
|
case "parked":
|
|
1047
1272
|
items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
|
|
1048
1273
|
break;
|
|
1274
|
+
case "discarded":
|
|
1275
|
+
if (r.executionDespiteDenial) {
|
|
1276
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "denied_with_evidence",
|
|
1277
|
+
guidance: `"${a}" was DENIED (${r.verdict ?? "rejected"})${r.discardReason ? ` — ${r.discardReason}` : ""}, but this store also holds an execution marker for it (${r.executionDespiteDenial.state} at ${r.executionDespiteDenial.at}${r.executionDespiteDenial.hasEvidence ? ", with a captured result" : ""}). Nothing will run it and nothing will report it — a denial is terminal and cannot be reconciled. CHECK THE DOWNSTREAM SYSTEM: if the action really ran, correct it there.` });
|
|
1278
|
+
}
|
|
1279
|
+
break;
|
|
1049
1280
|
case "reported":
|
|
1050
1281
|
break;
|
|
1051
1282
|
}
|