@decidio/sdk 0.3.0 → 0.3.5
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 +119 -13
- package/dist/adapters/_gate.js +3 -2
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/cli.js +292 -29
- package/dist/errors.js +5 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +5 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +10 -7
- package/dist/inngest.js +2 -0
- package/dist/resume.d.ts +58 -27
- package/dist/resume.js +273 -161
- package/dist/signing.d.ts +4 -0
- package/dist/signing.js +23 -2
- package/package.json +3 -3
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);
|
|
@@ -321,14 +321,40 @@ export class FilePendingStore {
|
|
|
321
321
|
this.migrateLegacy();
|
|
322
322
|
}
|
|
323
323
|
catch (e) {
|
|
324
|
-
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.`);
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
328
328
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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`;
|
|
332
358
|
const fd = openSync(tmp, "w", 0o600);
|
|
333
359
|
try {
|
|
334
360
|
writeFileSync(fd, text);
|
|
@@ -337,14 +363,83 @@ export class FilePendingStore {
|
|
|
337
363
|
finally {
|
|
338
364
|
closeSync(fd);
|
|
339
365
|
}
|
|
366
|
+
return tmp;
|
|
367
|
+
}
|
|
368
|
+
writeAtomic(file, text) {
|
|
369
|
+
const tmp = this.writeTemp(file, text);
|
|
340
370
|
renameSync(tmp, file);
|
|
341
371
|
}
|
|
342
|
-
|
|
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) {
|
|
343
438
|
const corrupt = [];
|
|
344
439
|
let best = null;
|
|
345
440
|
let denial = null;
|
|
346
441
|
for (const s of EXECUTION_STATES) {
|
|
347
|
-
const f = this.
|
|
442
|
+
const f = this.markerPath(key, s);
|
|
348
443
|
if (!existsSync(f))
|
|
349
444
|
continue;
|
|
350
445
|
try {
|
|
@@ -363,39 +458,23 @@ export class FilePendingStore {
|
|
|
363
458
|
const ran = best && rank(best.state) >= rank("invoking") ? best : null;
|
|
364
459
|
return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
|
|
365
460
|
}
|
|
366
|
-
setState(id, attemptId, state, patch = {}) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
const next = { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
|
|
380
|
-
this.writeAtomic(this.statePath(id, state), JSON.stringify(next, null, 2));
|
|
381
|
-
for (const s of EXECUTION_STATES) {
|
|
382
|
-
if (s === state)
|
|
383
|
-
continue;
|
|
384
|
-
if (rank(s) >= rank(state))
|
|
385
|
-
continue;
|
|
386
|
-
const f = this.statePath(id, s);
|
|
387
|
-
try {
|
|
388
|
-
if (existsSync(f))
|
|
389
|
-
rmSync(f);
|
|
390
|
-
}
|
|
391
|
-
catch { }
|
|
392
|
-
}
|
|
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
|
+
});
|
|
393
473
|
}
|
|
394
474
|
async put(p) {
|
|
395
475
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
476
|
+
this.transition(p.decisionId, "put", (cur) => cur ? null
|
|
477
|
+
: { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
399
478
|
}
|
|
400
479
|
async get(id) {
|
|
401
480
|
const f = this.path(id);
|
|
@@ -403,7 +482,7 @@ export class FilePendingStore {
|
|
|
403
482
|
}
|
|
404
483
|
async delete(id, attemptId) {
|
|
405
484
|
if (attemptId) {
|
|
406
|
-
const owner = this.
|
|
485
|
+
const owner = this.readHead(id).rec?.attemptId;
|
|
407
486
|
if (owner && owner !== attemptId)
|
|
408
487
|
return;
|
|
409
488
|
}
|
|
@@ -417,7 +496,8 @@ export class FilePendingStore {
|
|
|
417
496
|
if (!existsSync(this.dir))
|
|
418
497
|
return [];
|
|
419
498
|
const out = [];
|
|
420
|
-
|
|
499
|
+
const names = readdirSync(this.dir);
|
|
500
|
+
for (const f of names.filter((f) => f.endsWith(".json"))) {
|
|
421
501
|
let p;
|
|
422
502
|
try {
|
|
423
503
|
p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
@@ -426,7 +506,7 @@ export class FilePendingStore {
|
|
|
426
506
|
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
427
507
|
continue;
|
|
428
508
|
}
|
|
429
|
-
const { rec } = this.
|
|
509
|
+
const { rec } = this.readHead(p.decisionId, names);
|
|
430
510
|
if (!rec || rec.state === "parked")
|
|
431
511
|
out.push(p);
|
|
432
512
|
}
|
|
@@ -435,62 +515,41 @@ export class FilePendingStore {
|
|
|
435
515
|
async claim(id, attemptId) {
|
|
436
516
|
if (!existsSync(this.path(id)))
|
|
437
517
|
return { ok: false, reason: "no parked entry" };
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (state === "claimed_preinvoke")
|
|
445
|
-
return { ok: false, state, reason: "claimed by another attempt" };
|
|
446
|
-
const target = this.statePath(id, "claimed_preinvoke");
|
|
447
|
-
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
448
|
-
const payload = JSON.stringify({ ...(rec ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
|
|
449
|
-
const fd = openSync(tmp, "w", 0o600);
|
|
450
|
-
try {
|
|
451
|
-
writeFileSync(fd, payload);
|
|
452
|
-
fsyncSync(fd);
|
|
453
|
-
}
|
|
454
|
-
finally {
|
|
455
|
-
closeSync(fd);
|
|
456
|
-
}
|
|
457
|
-
try {
|
|
458
|
-
linkSync(tmp, target);
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
try {
|
|
462
|
-
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;
|
|
463
524
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
const p = this.statePath(id, "parked");
|
|
473
|
-
if (existsSync(p))
|
|
474
|
-
rmSync(p);
|
|
475
|
-
}
|
|
476
|
-
catch { }
|
|
477
|
-
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 };
|
|
478
532
|
}
|
|
479
533
|
async markInvoking(id, attemptId) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
assertNotDenied(id, cur, "invoking");
|
|
485
|
-
assertClaimedBy(id, cur, attemptId);
|
|
486
|
-
this.setState(id, attemptId, "invoking");
|
|
534
|
+
this.setState(id, attemptId, "invoking", {}, (cur) => {
|
|
535
|
+
assertNotDenied(id, cur, "invoking");
|
|
536
|
+
assertClaimedBy(id, cur, attemptId);
|
|
537
|
+
});
|
|
487
538
|
}
|
|
488
539
|
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
489
540
|
async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
|
|
490
541
|
async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
|
|
491
|
-
async markDiscarded(id, verdict, reason) {
|
|
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
|
+
}
|
|
492
551
|
async getExecution(id) {
|
|
493
|
-
const { rec, corrupt } = this.
|
|
552
|
+
const { rec, corrupt } = this.readHead(id);
|
|
494
553
|
if (corrupt.length)
|
|
495
554
|
throw new DecidioStateCorruptError(id, corrupt);
|
|
496
555
|
return rec;
|
|
@@ -498,21 +557,26 @@ export class FilePendingStore {
|
|
|
498
557
|
async listExecutions() {
|
|
499
558
|
if (!existsSync(this.dir))
|
|
500
559
|
return [];
|
|
501
|
-
const
|
|
502
|
-
|
|
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
|
+
}
|
|
503
568
|
const dot = name.lastIndexOf(".");
|
|
504
569
|
if (dot < 0)
|
|
505
570
|
continue;
|
|
506
571
|
const suffix = name.slice(dot + 1);
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
ids.add(name.slice(0, dot));
|
|
572
|
+
if (EXECUTION_STATES.includes(suffix))
|
|
573
|
+
keys.add(name.slice(0, dot));
|
|
510
574
|
}
|
|
511
575
|
const out = [];
|
|
512
|
-
for (const key of
|
|
513
|
-
const { rec, corrupt } = this.
|
|
576
|
+
for (const key of keys) {
|
|
577
|
+
const { rec, corrupt } = this.readHead(key, names);
|
|
514
578
|
if (corrupt.length) {
|
|
515
|
-
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() });
|
|
516
580
|
continue;
|
|
517
581
|
}
|
|
518
582
|
if (rec)
|
|
@@ -521,60 +585,92 @@ export class FilePendingStore {
|
|
|
521
585
|
return out;
|
|
522
586
|
}
|
|
523
587
|
async settle(id, outcome, opts = {}) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
|
|
534
|
-
this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
|
|
535
|
-
for (const st of EXECUTION_STATES) {
|
|
536
|
-
if (st === next.state)
|
|
537
|
-
continue;
|
|
538
|
-
try {
|
|
539
|
-
const f = this.statePath(id, st);
|
|
540
|
-
if (existsSync(f))
|
|
541
|
-
rmSync(f);
|
|
542
|
-
}
|
|
543
|
-
catch { }
|
|
544
|
-
}
|
|
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
|
+
});
|
|
545
597
|
}
|
|
546
598
|
async releasePreinvoke(id, attemptId) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
rmSync(c);
|
|
558
|
-
}
|
|
559
|
-
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
|
+
});
|
|
560
609
|
}
|
|
561
610
|
migrateLegacy() {
|
|
562
611
|
if (!existsSync(this.dir))
|
|
563
|
-
return { migrated: 0, indeterminate: 0, reports: 0 };
|
|
564
|
-
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.`);
|
|
565
659
|
for (const name of readdirSync(this.dir)) {
|
|
566
660
|
if (name.endsWith(".claimed")) {
|
|
567
661
|
const key = name.slice(0, -".claimed".length);
|
|
568
662
|
try {
|
|
569
663
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
570
|
-
const
|
|
571
|
-
|
|
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) {
|
|
572
669
|
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
573
670
|
continue;
|
|
574
671
|
}
|
|
575
|
-
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));
|
|
576
672
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
577
|
-
|
|
673
|
+
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
578
674
|
indeterminate++;
|
|
579
675
|
}
|
|
580
676
|
catch {
|
|
@@ -588,9 +684,10 @@ export class FilePendingStore {
|
|
|
588
684
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
589
685
|
if (!p?.decisionId)
|
|
590
686
|
continue;
|
|
591
|
-
if (this.
|
|
687
|
+
if (this.readHead(p.decisionId).rec)
|
|
592
688
|
continue;
|
|
593
|
-
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() });
|
|
594
691
|
migrated++;
|
|
595
692
|
}
|
|
596
693
|
catch { }
|
|
@@ -598,7 +695,7 @@ export class FilePendingStore {
|
|
|
598
695
|
if (indeterminate)
|
|
599
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().`);
|
|
600
697
|
const reports = this.migrateLegacyReports();
|
|
601
|
-
return { migrated, indeterminate, reports };
|
|
698
|
+
return { migrated, indeterminate, reports, chained };
|
|
602
699
|
}
|
|
603
700
|
migrateLegacyReports() {
|
|
604
701
|
const reportsDir = join(this.dir, "reports");
|
|
@@ -613,22 +710,18 @@ export class FilePendingStore {
|
|
|
613
710
|
const e = JSON.parse(readFileSync(file, "utf8"));
|
|
614
711
|
if (!e?.decisionId)
|
|
615
712
|
continue;
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
renameSync(file, `${file}.migrated`);
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
if (existing?.state === "discarded") {
|
|
622
|
-
renameSync(file, `${file}.migrated`);
|
|
623
|
-
console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${existing.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.`);
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
|
|
627
|
-
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,
|
|
628
715
|
evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
|
|
629
716
|
at: e.at ?? new Date().toISOString(),
|
|
630
|
-
}
|
|
717
|
+
}));
|
|
631
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
|
+
}
|
|
632
725
|
moved++;
|
|
633
726
|
}
|
|
634
727
|
catch {
|
|
@@ -738,6 +831,7 @@ function webhookBody(o) {
|
|
|
738
831
|
ok: true,
|
|
739
832
|
outcome: {
|
|
740
833
|
status: o.status, decisionId: o.decisionId,
|
|
834
|
+
...(o.reason ? { reason: o.reason } : {}),
|
|
741
835
|
...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
|
|
742
836
|
...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
|
|
743
837
|
...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
|
|
@@ -842,7 +936,7 @@ export function createDecidioResume(opts) {
|
|
|
842
936
|
};
|
|
843
937
|
}
|
|
844
938
|
if (ev.reportRecorded)
|
|
845
|
-
await ctrl.store.markReported(decisionId, attemptId, ev
|
|
939
|
+
await ctrl.store.markReported(decisionId, attemptId, tombstoneTier(ev)).catch(() => { });
|
|
846
940
|
const diagnostic = buildProblem
|
|
847
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.`
|
|
848
942
|
: ev.diagnostic;
|
|
@@ -876,6 +970,9 @@ export function createDecidioResume(opts) {
|
|
|
876
970
|
registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
|
|
877
971
|
},
|
|
878
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 : "");
|
|
879
976
|
try {
|
|
880
977
|
toCanonical(p.args, "args");
|
|
881
978
|
}
|
|
@@ -890,6 +987,9 @@ export function createDecidioResume(opts) {
|
|
|
890
987
|
async resolve({ decisionId, verdict, reason }) {
|
|
891
988
|
const finish = (o) => { if (o.status !== "unknown")
|
|
892
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})` };
|
|
893
993
|
const pending = await ctrl.store.get(decisionId);
|
|
894
994
|
if (!pending)
|
|
895
995
|
return { status: "unknown", decisionId };
|
|
@@ -937,7 +1037,7 @@ export function createDecidioResume(opts) {
|
|
|
937
1037
|
refuseOnce(decisionId, claim.state === "discarded"
|
|
938
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.`
|
|
939
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);
|
|
940
|
-
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "
|
|
1040
|
+
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
941
1041
|
}
|
|
942
1042
|
return { status: "unknown", decisionId };
|
|
943
1043
|
}
|
|
@@ -986,7 +1086,7 @@ export function createDecidioResume(opts) {
|
|
|
986
1086
|
}
|
|
987
1087
|
const body = JSON.parse(raw || "{}");
|
|
988
1088
|
const verdict = body.verdict;
|
|
989
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1089
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict, reason: body.reason });
|
|
990
1090
|
res.statusCode = 200;
|
|
991
1091
|
res.setHeader("content-type", "application/json");
|
|
992
1092
|
res.end(JSON.stringify(webhookBody(outcome)));
|
|
@@ -1012,7 +1112,7 @@ export function createDecidioResume(opts) {
|
|
|
1012
1112
|
}
|
|
1013
1113
|
try {
|
|
1014
1114
|
const body = JSON.parse(raw || "{}");
|
|
1015
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1115
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict: body.verdict, reason: body.reason });
|
|
1016
1116
|
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
1017
1117
|
}
|
|
1018
1118
|
catch (e) {
|
|
@@ -1021,6 +1121,9 @@ export function createDecidioResume(opts) {
|
|
|
1021
1121
|
};
|
|
1022
1122
|
},
|
|
1023
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.` };
|
|
1024
1127
|
let entry;
|
|
1025
1128
|
try {
|
|
1026
1129
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -1066,13 +1169,16 @@ export function createDecidioResume(opts) {
|
|
|
1066
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.` };
|
|
1067
1170
|
}
|
|
1068
1171
|
if (ev.reportRecorded)
|
|
1069
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev
|
|
1172
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", tombstoneTier(ev)).catch(() => { });
|
|
1070
1173
|
return {
|
|
1071
1174
|
...ev,
|
|
1072
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.`,
|
|
1073
1176
|
};
|
|
1074
1177
|
},
|
|
1075
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.` };
|
|
1076
1182
|
let entry;
|
|
1077
1183
|
try {
|
|
1078
1184
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -1081,7 +1187,8 @@ export function createDecidioResume(opts) {
|
|
|
1081
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.` };
|
|
1082
1188
|
}
|
|
1083
1189
|
if (entry?.state === "reported") {
|
|
1084
|
-
|
|
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.` };
|
|
1085
1192
|
}
|
|
1086
1193
|
if (entry?.state === "indeterminate") {
|
|
1087
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.` };
|
|
@@ -1130,7 +1237,7 @@ export function createDecidioResume(opts) {
|
|
|
1130
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.` };
|
|
1131
1238
|
}
|
|
1132
1239
|
if (ev.reportRecorded)
|
|
1133
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev
|
|
1240
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", tombstoneTier(ev)).catch(() => { });
|
|
1134
1241
|
return ev;
|
|
1135
1242
|
},
|
|
1136
1243
|
async recover() {
|
|
@@ -1162,7 +1269,12 @@ export function createDecidioResume(opts) {
|
|
|
1162
1269
|
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" }).` });
|
|
1163
1270
|
break;
|
|
1164
1271
|
case "parked":
|
|
1165
|
-
|
|
1272
|
+
if (!(await ctrl.store.get(r.decisionId))) {
|
|
1273
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
|
|
1277
|
+
}
|
|
1166
1278
|
break;
|
|
1167
1279
|
case "discarded":
|
|
1168
1280
|
if (r.executionDespiteDenial) {
|