@verax-ai/proxy 0.1.2 → 0.1.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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Called before `inner` runs. A failure here must not block the call.
3
+ *
4
+ * The stamp is wall-clock time and not the proxy's injected `now`: a mark is
5
+ * not a ledger row, and taking a tick from a deterministic clock would shift
6
+ * every timestamp recorded after it — the golden ledger caught exactly that.
7
+ */
8
+ export declare function markStarted(stateDir: string, ref: string, subject: string): void;
9
+ /** Called once the effect row is written, on success and on failure alike. */
10
+ export declare function markEnded(stateDir: string, ref: string): void;
11
+ /** True when a run started and no end was recorded: the outcome is unknown. */
12
+ export declare function startedWithoutEnd(stateDir: string, ref: string): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Durable in-flight marks: "this ref started running".
3
+ *
4
+ * The in-memory registry that stops a second run dies with the process. On the
5
+ * approved path an `allow` does not mean the tool ran — it means the operator
6
+ * said yes — so a restarted body cannot tell "approved, never started" from
7
+ * "approved, started, crashed before the effect". Without that distinction it
8
+ * has to either run everything twice or run nothing at all.
9
+ *
10
+ * A mark is one empty file per ref, created before `inner` and removed after
11
+ * the effect is written. One file per ref rather than a shared log because two
12
+ * refs may start and finish at the same moment: file create and unlink need no
13
+ * lock, a shared append-only log would need a compaction pass and would race.
14
+ *
15
+ * A mark left behind by a crash is exactly the signal wanted: it outlives the
16
+ * process, and the retry that finds it answers `outcome-unknown` instead of
17
+ * running the tool a second time.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ const KLASOR = "in-flight";
23
+ /** Ref may carry a tenant prefix and any path character; the name never does. */
24
+ function markPath(stateDir, ref) {
25
+ const ad = createHash("sha256").update(ref, "utf8").digest("hex").slice(0, 32);
26
+ return join(stateDir, KLASOR, `${ad}.start`);
27
+ }
28
+ /**
29
+ * Called before `inner` runs. A failure here must not block the call.
30
+ *
31
+ * The stamp is wall-clock time and not the proxy's injected `now`: a mark is
32
+ * not a ledger row, and taking a tick from a deterministic clock would shift
33
+ * every timestamp recorded after it — the golden ledger caught exactly that.
34
+ */
35
+ export function markStarted(stateDir, ref, subject) {
36
+ try {
37
+ mkdirSync(join(stateDir, KLASOR), { recursive: true, mode: 0o700 });
38
+ // The body is for a human reading the directory after a crash; nothing parses it.
39
+ writeFileSync(markPath(stateDir, ref), `${JSON.stringify({ ref, subject, atMs: Date.now() })}\n`, {
40
+ encoding: "utf8",
41
+ mode: 0o600,
42
+ });
43
+ }
44
+ catch {
45
+ // A missing mark degrades to today's behaviour, it does not deny the call.
46
+ }
47
+ }
48
+ /** Called once the effect row is written, on success and on failure alike. */
49
+ export function markEnded(stateDir, ref) {
50
+ try {
51
+ rmSync(markPath(stateDir, ref), { force: true });
52
+ }
53
+ catch {
54
+ // Leaving a stale mark makes the next retry cautious, never wrong.
55
+ }
56
+ }
57
+ /** True when a run started and no end was recorded: the outcome is unknown. */
58
+ export function startedWithoutEnd(stateDir, ref) {
59
+ try {
60
+ return existsSync(markPath(stateDir, ref));
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
package/dist/proxy.js CHANGED
@@ -4,6 +4,7 @@ import { signDecisionRecord } from "@cedulon/core";
4
4
  import { approvePending, approvalsLogFor, createApprovalBudgetGuard, drainApprovalCommands, spentTodayMinorOf, } from "./approvals.js";
5
5
  import { SerialQueue } from "./serial-queue.js";
6
6
  import { diskProbe } from "./disk.js";
7
+ import { markEnded, markStarted, startedWithoutEnd } from "./in-flight-log.js";
7
8
  import { effectDescriptor, sha256Canonical } from "./hash.js";
8
9
  import { inputsLogFor } from "./inputs.js";
9
10
  import { countedWork, hasPrimaryEffect, lookupDecisionByRef, lookupReauthByHash, lookupResolvedBy, noteResolution, noteTenantRef, } from "./ledger.js";
@@ -294,6 +295,11 @@ export function createProxy(deps) {
294
295
  };
295
296
  }
296
297
  function launchInner(dispatchedCall, principal, allowRef, flightKey) {
298
+ // Written before the tool runs, removed after the effect is recorded. A
299
+ // mark that survives the process is what tells a restarted body that this
300
+ // ref was already started (in-flight-log.ts).
301
+ if (stateDir !== null)
302
+ markStarted(stateDir, flightKey, dispatchedCall.name);
297
303
  const work = runInner(dispatchedCall, principal, allowRef);
298
304
  inFlight.set(flightKey, work);
299
305
  return { kind: "run", work, key: flightKey, replayRef: allowRef };
@@ -469,6 +475,27 @@ export function createProxy(deps) {
469
475
  const flying = inFlight.get(scopedRef);
470
476
  if (flying)
471
477
  return { kind: "wait", work: flying, replayRef: allow.ref };
478
+ // An approved allow means "the operator said yes", not "it ran",
479
+ // so the first retry after an approval is the first run. Only a
480
+ // mark left on disk says a run was already started and never
481
+ // finished — that one cannot be run again.
482
+ if (stateDir !== null && startedWithoutEnd(stateDir, scopedRef)) {
483
+ const unknownRef = deps.nonce();
484
+ await writeRecord({
485
+ decision: "deny",
486
+ reasonCode: "outcome-unknown",
487
+ ref: unknownRef,
488
+ requestHash,
489
+ inputs: {
490
+ ...resolved.inputs,
491
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
492
+ },
493
+ effectHash: null,
494
+ subject: call.name,
495
+ timestampMs,
496
+ });
497
+ return { kind: "done", result: denied("outcome-unknown", unknownRef) };
498
+ }
472
499
  return launchInner(dispatched, principal, allow.ref, scopedRef);
473
500
  }
474
501
  return { kind: "done", result: deferred(given) };
@@ -596,6 +623,10 @@ export function createProxy(deps) {
596
623
  }
597
624
  finally {
598
625
  inFlight.delete(plan.key);
626
+ // runInner has written its effect row by now — on the throw path too,
627
+ // as `<name>:threw`. The outcome is on the ledger, so the mark goes.
628
+ if (stateDir !== null)
629
+ markEnded(stateDir, plan.key);
599
630
  }
600
631
  },
601
632
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/proxy",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {