@smartmemory/stratum 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/guard.js +63 -6
- package/dist/cli/guard.js.map +1 -1
- package/dist/cli/learn.js +226 -0
- package/dist/cli/learn.js.map +1 -0
- package/dist/cli/query_gate.js +2 -1
- package/dist/cli/query_gate.js.map +1 -1
- package/dist/cli/stratum.js +5 -2
- package/dist/cli/stratum.js.map +1 -1
- package/dist/connectors/background.js +58 -10
- package/dist/connectors/background.js.map +1 -1
- package/dist/connectors/base.js +16 -0
- package/dist/connectors/base.js.map +1 -1
- package/dist/connectors/cancellation.js +119 -0
- package/dist/connectors/cancellation.js.map +1 -0
- package/dist/connectors/claude-bg-worker.js +9 -1
- package/dist/connectors/claude-bg-worker.js.map +1 -1
- package/dist/connectors/claude.js +179 -86
- package/dist/connectors/claude.js.map +1 -1
- package/dist/connectors/codex.js +289 -61
- package/dist/connectors/codex.js.map +1 -1
- package/dist/connectors/runner.js +44 -3
- package/dist/connectors/runner.js.map +1 -1
- package/dist/contracts/events.json +34 -1
- package/dist/contracts/guard-signers.allowed +36 -0
- package/dist/contracts/mcp-surface.json +1097 -104
- package/dist/engine/checkpoint.js +8 -1
- package/dist/engine/checkpoint.js.map +1 -1
- package/dist/engine/engine.js +508 -42
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/evaluate.js +61 -0
- package/dist/engine/evaluate.js.map +1 -0
- package/dist/engine/ledger.js +12 -0
- package/dist/engine/ledger.js.map +1 -1
- package/dist/engine/receipts.js +91 -0
- package/dist/engine/receipts.js.map +1 -0
- package/dist/engine/state.js +51 -1
- package/dist/engine/state.js.map +1 -1
- package/dist/guard/authorization.js +71 -0
- package/dist/guard/authorization.js.map +1 -0
- package/dist/guard/descriptors.js +248 -0
- package/dist/guard/descriptors.js.map +1 -0
- package/dist/guard/errors.js +12 -0
- package/dist/guard/errors.js.map +1 -1
- package/dist/guard/evidence.js +24 -3
- package/dist/guard/evidence.js.map +1 -1
- package/dist/guard/sshsig.js +264 -0
- package/dist/guard/sshsig.js.map +1 -0
- package/dist/guard/store.js +13 -0
- package/dist/guard/store.js.map +1 -1
- package/dist/guard/transition.js +484 -33
- package/dist/guard/transition.js.map +1 -1
- package/dist/guard/trust.js +78 -0
- package/dist/guard/trust.js.map +1 -0
- package/dist/ir/schema.js +8 -1
- package/dist/ir/schema.js.map +1 -1
- package/dist/ir/validate.js +3 -1
- package/dist/ir/validate.js.map +1 -1
- package/dist/judge/judged.js +1 -1
- package/dist/judge/pricing.js +1 -0
- package/dist/judge/pricing.js.map +1 -1
- package/dist/learn/apply.js +600 -0
- package/dist/learn/apply.js.map +1 -0
- package/dist/learn/candidate.js +186 -0
- package/dist/learn/candidate.js.map +1 -0
- package/dist/learn/classify.js +181 -0
- package/dist/learn/classify.js.map +1 -0
- package/dist/learn/harvest.js +138 -0
- package/dist/learn/harvest.js.map +1 -0
- package/dist/learn/smartmemory_egress.js +330 -0
- package/dist/learn/smartmemory_egress.js.map +1 -0
- package/dist/mcp/server.js +155 -24
- package/dist/mcp/server.js.map +1 -1
- package/dist/policy/bundle.js +195 -0
- package/dist/policy/bundle.js.map +1 -0
- package/dist/policy/events.js +51 -0
- package/dist/policy/events.js.map +1 -0
- package/dist/policy/smartmemory_client.js +332 -0
- package/dist/policy/smartmemory_client.js.map +1 -0
- package/dist/policy/types.js +2 -0
- package/dist/policy/types.js.map +1 -0
- package/package.json +3 -2
package/dist/engine/engine.js
CHANGED
|
@@ -4,13 +4,50 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
7
8
|
import { runAgent } from "../connectors/runner.js";
|
|
8
9
|
import { extractReferences } from "../ir/refs.js";
|
|
9
10
|
import { validateSpec } from "../ir/validate.js";
|
|
10
|
-
import {
|
|
11
|
+
import { LearnEgress } from "../learn/smartmemory_egress.js";
|
|
12
|
+
import { mergeBundleIntoSpec, policyRuleKey, predicateType, validateBundle } from "../policy/bundle.js";
|
|
13
|
+
import { buildFlowTerminalEvent, buildGateResolutionEvent } from "../policy/events.js";
|
|
14
|
+
import { emitPolicyEvent as postPolicyEvent } from "../policy/smartmemory_client.js";
|
|
15
|
+
import { BUDGET_KEYS, BudgetLedger, validConnectorTelemetry, validUsage } from "./ledger.js";
|
|
11
16
|
import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
|
|
17
|
+
import { buildReceipt, findReceipt, ReceiptValidationError, spineSpent } from "./receipts.js";
|
|
12
18
|
import { StateStore } from "./state.js";
|
|
13
19
|
const execFileAsync = promisify(execFile);
|
|
20
|
+
/**
|
|
21
|
+
* The fixed shape every S1 `evaluate:` step must return. Engine-owned and
|
|
22
|
+
* strict — an author's `out` contract governs what is *referenceable*, this
|
|
23
|
+
* schema governs what the data must *be*. It is the trust anchor: a transport
|
|
24
|
+
* failure can never be laundered into a `closed` verdict, and the cross-field
|
|
25
|
+
* invariants (`closed` ⇒ no children, `open` ⇒ ≥1 child) are enforced here.
|
|
26
|
+
*/
|
|
27
|
+
export const evaluatorResultSchema = z.object({
|
|
28
|
+
status: z.enum(["closed", "open", "failed"]),
|
|
29
|
+
children: z.array(z.unknown()),
|
|
30
|
+
reason: z.string(),
|
|
31
|
+
score: z.number().optional(),
|
|
32
|
+
route: z.enum(["claude", "codex"]).optional(),
|
|
33
|
+
}).strict().superRefine((result, ctx) => {
|
|
34
|
+
if (result.status === "closed" && result.children.length > 0) {
|
|
35
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "a closed verdict must carry no children" });
|
|
36
|
+
}
|
|
37
|
+
if (result.status === "open" && result.children.length === 0) {
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "an open verdict must carry at least one child" });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* The engine validates the runner's ENVELOPE at runtime, not just the verdict
|
|
43
|
+
* inside it — the `ok` discriminant is the runner's word for whether it even
|
|
44
|
+
* succeeded, and a malformed envelope must never let a `{status:"closed"}`
|
|
45
|
+
* payload reach the success path. Same trust posture as the judge verdict.
|
|
46
|
+
*/
|
|
47
|
+
const evaluateRunResultSchema = z.discriminatedUnion("ok", [
|
|
48
|
+
z.object({ ok: z.literal(true), result: z.unknown() }),
|
|
49
|
+
z.object({ ok: z.literal(false), kind: z.enum(["exit", "timeout", "parse"]), reason: z.string() }),
|
|
50
|
+
]);
|
|
14
51
|
export class CheckpointOperationError extends Error {
|
|
15
52
|
errorType;
|
|
16
53
|
available;
|
|
@@ -29,11 +66,17 @@ export class SpecValidationError extends Error {
|
|
|
29
66
|
this.errors = errors;
|
|
30
67
|
}
|
|
31
68
|
}
|
|
69
|
+
export class InputValidationError extends SpecValidationError {
|
|
70
|
+
constructor(errors) { super(errors); this.message = "entry input validation failed"; }
|
|
71
|
+
}
|
|
32
72
|
export class StratumEngine {
|
|
33
73
|
store;
|
|
34
74
|
evaluator;
|
|
35
75
|
judge;
|
|
76
|
+
evaluateRunner;
|
|
36
77
|
connector;
|
|
78
|
+
learnEgress;
|
|
79
|
+
learnEgressStartup;
|
|
37
80
|
// Serializes load-modify-save per run: plan may hand out several ready steps, so
|
|
38
81
|
// stepDone/resume can race in-process. The state root is owned by one engine process in v1.
|
|
39
82
|
runLocks = new Map();
|
|
@@ -51,7 +94,19 @@ export class StratumEngine {
|
|
|
51
94
|
this.evaluator = options.evaluator;
|
|
52
95
|
if (options.judge)
|
|
53
96
|
this.judge = options.judge;
|
|
97
|
+
if (options.evaluateRunner)
|
|
98
|
+
this.evaluateRunner = options.evaluateRunner;
|
|
54
99
|
this.connector = options.connector ?? defaultConnector;
|
|
100
|
+
this.learnEgress = options.learnEgress ?? new LearnEgress({
|
|
101
|
+
...options.learnEgressOptions,
|
|
102
|
+
store: this.store,
|
|
103
|
+
withReceiptUpdate: (runId, update) => this.withReceiptUpdate(runId, update),
|
|
104
|
+
});
|
|
105
|
+
this.learnEgressStartup = this.learnEgress.enabled()
|
|
106
|
+
? this.learnEgress.drainAll().catch((error) => {
|
|
107
|
+
console.warn(`SmartMemory egress startup reconciliation failed: ${message(error)}`);
|
|
108
|
+
})
|
|
109
|
+
: Promise.resolve();
|
|
55
110
|
}
|
|
56
111
|
async loadRun(runId) {
|
|
57
112
|
const active = this.activeRuns.get(runId);
|
|
@@ -85,27 +140,70 @@ export class StratumEngine {
|
|
|
85
140
|
});
|
|
86
141
|
return result;
|
|
87
142
|
}
|
|
143
|
+
async withReceiptUpdate(runId, update) {
|
|
144
|
+
return this.withRunLock(runId, async () => {
|
|
145
|
+
const run = await this.loadRun(runId);
|
|
146
|
+
const result = await update(run);
|
|
147
|
+
await this.persist(run);
|
|
148
|
+
return result;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async closeLearnEgress() {
|
|
152
|
+
await this.learnEgressStartup;
|
|
153
|
+
await this.learnEgress.close();
|
|
154
|
+
}
|
|
88
155
|
async plan(specInput, input, options = {}) {
|
|
89
156
|
const validation = validateSpec(specInput);
|
|
90
157
|
if (!validation.ok)
|
|
91
158
|
throw new SpecValidationError(validation.errors);
|
|
92
|
-
|
|
93
|
-
|
|
159
|
+
let effectiveSpec = validation.value;
|
|
160
|
+
let policyFields = {};
|
|
161
|
+
if (options.policyBundle !== undefined) {
|
|
162
|
+
const bundle = validateBundle(options.policyBundle);
|
|
163
|
+
const merged = mergeBundleIntoSpec(effectiveSpec, bundle, options.policyStepSelector);
|
|
164
|
+
effectiveSpec = merged.spec;
|
|
165
|
+
policyFields = { bundle_id: merged.bundle_id, policy_rules: merged.policy_rules, policy_rules_version: 2, policy_verdicts: [] };
|
|
166
|
+
const policyBindings = Object.values(merged.policy_rules).flat();
|
|
167
|
+
const ruleCount = new Set(policyBindings.map((binding) => binding.rule_id)).size;
|
|
168
|
+
console.warn(`policy bundle ${merged.bundle_id}: ${ruleCount} rules bound to ${policyBindings.length} step-predicate pairs`);
|
|
169
|
+
if (bundle.rules.some((rule) => rule.bind.kind === "ensure" && rule.on_fail === "gate")) {
|
|
170
|
+
console.warn("ensure policy rule on_fail=gate is enforced as refuse in P1; gate routing is deferred to P3");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Validate the policy-merged specification and entry input before allocating
|
|
174
|
+
// or persisting a run. A rejected request must never dispatch work.
|
|
175
|
+
const effectiveValidation = validateSpec(effectiveSpec);
|
|
176
|
+
if (!effectiveValidation.ok)
|
|
177
|
+
throw new SpecValidationError(effectiveValidation.errors);
|
|
178
|
+
effectiveSpec = effectiveValidation.value;
|
|
179
|
+
const flowName = effectiveSpec.flows.entry;
|
|
180
|
+
const parsedInput = effectiveValidation.inputs[flowName]?.safeParse(input);
|
|
181
|
+
if (!parsedInput)
|
|
182
|
+
throw new Error("entry flow input contract missing after validation");
|
|
183
|
+
if (!parsedInput.success) {
|
|
184
|
+
throw new InputValidationError(parsedInput.error.issues.map((issue) => ({
|
|
185
|
+
code: "INPUT_CONTRACT_INVALID",
|
|
186
|
+
path: ["input", ...issue.path].map((part, index) => typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`).join(""),
|
|
187
|
+
message: issue.message,
|
|
188
|
+
})));
|
|
189
|
+
}
|
|
190
|
+
const flow = effectiveSpec.flows[flowName];
|
|
94
191
|
if (!flow)
|
|
95
192
|
throw new Error("entry flow missing after validation");
|
|
96
193
|
const steps = Object.create(null);
|
|
97
194
|
for (const step of flow.steps)
|
|
98
195
|
steps[step.id] = { status: "pending", attempts: [], spent: {} };
|
|
99
196
|
const run = {
|
|
100
|
-
id: randomUUID(), spec:
|
|
101
|
-
input, flowName, status: "running", flowSpent: {}, steps,
|
|
197
|
+
id: randomUUID(), spec: effectiveSpec, revisionDigest: digest(effectiveSpec), generationCounter: 0,
|
|
198
|
+
input: parsedInput.data, flowName, status: "running", flowSpent: {}, steps,
|
|
102
199
|
events: [{ at: now(), type: "planned" }],
|
|
200
|
+
...policyFields,
|
|
103
201
|
// Canonicalize at plan time: a relative root must never re-resolve against a
|
|
104
202
|
// different process cwd after restart.
|
|
105
203
|
...(options.workspaceRoot !== undefined ? { workspaceRoot: resolve(options.workspaceRoot) } : {}),
|
|
106
204
|
};
|
|
107
205
|
await this.persist(run);
|
|
108
|
-
return this.withRevisionDigest(await this.advance(run,
|
|
206
|
+
return this.withRevisionDigest(await this.advance(run, effectiveValidation.value, effectiveValidation.contracts), run);
|
|
109
207
|
}
|
|
110
208
|
async flowRunBg(specInput, input, options = {}) {
|
|
111
209
|
const validation = validateSpec(specInput);
|
|
@@ -253,7 +351,9 @@ export class StratumEngine {
|
|
|
253
351
|
const usage = { ...reported };
|
|
254
352
|
delete usage.dispatches;
|
|
255
353
|
// "settle": the agent already ran, so over-limit usage is still recorded in both ledgers.
|
|
256
|
-
const budgetFailure =
|
|
354
|
+
const budgetFailure = hasBudget(usage)
|
|
355
|
+
? this.settleLegacyReceipt(run, usage, "step_done", telemetry, { scope, step, state }, result.usdSource, result.split)
|
|
356
|
+
: undefined;
|
|
257
357
|
if (budgetFailure === "flow") {
|
|
258
358
|
const failure = { attempt, reason: "flow budget exhausted" };
|
|
259
359
|
state.attempts.push({ attempt, at: now(), failure, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
|
|
@@ -329,6 +429,70 @@ export class StratumEngine {
|
|
|
329
429
|
await this.persist(run);
|
|
330
430
|
return this.advance(run, validated.value, validated.contracts, scope);
|
|
331
431
|
}
|
|
432
|
+
async usageReport(runId, input) {
|
|
433
|
+
return this.withRunLock(runId, async () => {
|
|
434
|
+
if (typeof input === "object" && input !== null && input.usdSource === "legacy") {
|
|
435
|
+
throw new ReceiptValidationError('usdSource "legacy" is reserved for engine-synthesized receipts');
|
|
436
|
+
}
|
|
437
|
+
const run = await this.loadRun(runId);
|
|
438
|
+
const candidate = input;
|
|
439
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)
|
|
440
|
+
|| typeof candidate?.dispatchId !== "string" || candidate.dispatchId.length === 0) {
|
|
441
|
+
throw new ReceiptValidationError("dispatchId must be a non-empty string");
|
|
442
|
+
}
|
|
443
|
+
if (candidate.dispatchId.startsWith("legacy:")) {
|
|
444
|
+
throw new ReceiptValidationError('dispatchId prefix "legacy:" is reserved for engine-synthesized receipts');
|
|
445
|
+
}
|
|
446
|
+
if (candidate.dispatchId.startsWith("engine:")) {
|
|
447
|
+
throw new ReceiptValidationError('dispatchId prefix "engine:" is reserved for engine-synthesized receipts');
|
|
448
|
+
}
|
|
449
|
+
const duplicate = findReceipt(run, candidate.dispatchId);
|
|
450
|
+
if (duplicate !== undefined) {
|
|
451
|
+
return { status: "duplicate", runId: run.id, seq: duplicate.seq, ledger: this.ledgerInfo(run) };
|
|
452
|
+
}
|
|
453
|
+
// Validate and allocate against a staging copy so a rejected receipt cannot
|
|
454
|
+
// advance the live counter (important while an active fanout pins the run object).
|
|
455
|
+
const staged = { ...run };
|
|
456
|
+
const receipt = buildReceipt(staged, input);
|
|
457
|
+
const located = receipt.stepId === undefined
|
|
458
|
+
? undefined
|
|
459
|
+
: this.locateReceiptStep(run, this.validationFor(run).value, receipt.stepId);
|
|
460
|
+
if (receipt.stepId !== undefined && located === undefined) {
|
|
461
|
+
throw new ReceiptValidationError(`receipt step ${JSON.stringify(receipt.stepId)} does not exist`, "invalid_step");
|
|
462
|
+
}
|
|
463
|
+
run.receiptCounter = receipt.seq;
|
|
464
|
+
const wasRunning = run.status === "running";
|
|
465
|
+
const settled = this.settleReceipt(run, receipt, located);
|
|
466
|
+
if (settled.status === "duplicate") {
|
|
467
|
+
return { status: "duplicate", runId: run.id, seq: settled.receipt.seq, ledger: this.ledgerInfo(run) };
|
|
468
|
+
}
|
|
469
|
+
let budget;
|
|
470
|
+
if (settled.budget === "flow") {
|
|
471
|
+
budget = wasRunning ? "flow_exhausted" : "flow_exhausted_after_terminal";
|
|
472
|
+
if (wasRunning) {
|
|
473
|
+
const attempt = located?.item?.attempts.length ?? located?.state.attempts.length ?? 0;
|
|
474
|
+
await this.terminalBudget(run, { attempt: attempt + 1, reason: "flow budget exhausted" });
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
await this.persist(run);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
if (settled.budget === "subflow")
|
|
482
|
+
budget = "subflow_exhausted";
|
|
483
|
+
if (settled.budget === "task")
|
|
484
|
+
budget = "task_exhausted";
|
|
485
|
+
await this.persist(run);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
status: "ok",
|
|
489
|
+
runId: run.id,
|
|
490
|
+
seq: receipt.seq,
|
|
491
|
+
...(budget !== undefined ? { budget } : {}),
|
|
492
|
+
ledger: this.ledgerInfo(run),
|
|
493
|
+
};
|
|
494
|
+
});
|
|
495
|
+
}
|
|
332
496
|
async commit(runId, label) {
|
|
333
497
|
this.assertExternalMutationAllowed(runId, "commit");
|
|
334
498
|
return await this.withRunLock(runId, async () => {
|
|
@@ -357,12 +521,39 @@ export class StratumEngine {
|
|
|
357
521
|
const run = await this.loadCheckpointRun(runId);
|
|
358
522
|
this.assertNoForegroundFanout(run, "revert");
|
|
359
523
|
const normalized = label.trim();
|
|
524
|
+
// Money spent is spent: a revert restores state, never spend. Capture the live
|
|
525
|
+
// cumulative total before the snapshot overwrites it.
|
|
526
|
+
const liveSpentBeforeRevert = { ...run.flowSpent };
|
|
360
527
|
if (!revertCheckpoint(run, normalized)) {
|
|
361
528
|
// Insertion order, matching Python (list(state.checkpoints.keys())) and the commit
|
|
362
529
|
// envelope's `checkpoints` — not sorted, and robust to numeric labels (array, not object).
|
|
363
530
|
const available = (run.checkpoints ?? []).map((entry) => entry.label);
|
|
364
531
|
throw new CheckpointOperationError("checkpoint_not_found", `No checkpoint '${normalized}' on flow '${runId}'`, available);
|
|
365
532
|
}
|
|
533
|
+
const receiptsAtRevert = run.receiptCounter ?? 0;
|
|
534
|
+
const stepsRestored = Object.keys(run.steps);
|
|
535
|
+
// flowSpent is monotonic across reverts: the live pre-revert total already
|
|
536
|
+
// includes every receipt (spine) plus any pre-receipt legacy spend, so it is
|
|
537
|
+
// the correct value in both the receipt-era and the upgraded-mid-run case.
|
|
538
|
+
// The spine is kept as a floor (defense against a corrupted live total); the
|
|
539
|
+
// restored snapshot is never consulted for spend.
|
|
540
|
+
const spine = spineSpent(run);
|
|
541
|
+
const reconciled = {};
|
|
542
|
+
for (const key of BUDGET_KEYS) {
|
|
543
|
+
const value = Math.max(liveSpentBeforeRevert[key] ?? 0, spine[key] ?? 0);
|
|
544
|
+
if (value !== 0)
|
|
545
|
+
reconciled[key] = value;
|
|
546
|
+
}
|
|
547
|
+
run.flowSpent = reconciled;
|
|
548
|
+
const detail = { label: normalized, receiptsAtRevert, stepsRestored };
|
|
549
|
+
this.event(run, "checkpoint_reverted", undefined, detail);
|
|
550
|
+
const checkpointReceiptSeq = receiptsAtRevert + 1;
|
|
551
|
+
(run.receipts ??= []).push(buildReceipt(run, {
|
|
552
|
+
dispatchId: `engine:checkpoint_reverted:${checkpointReceiptSeq}`,
|
|
553
|
+
source: "engine",
|
|
554
|
+
usage: {},
|
|
555
|
+
detail,
|
|
556
|
+
}));
|
|
366
557
|
this.rotateRestoredIssuances(run);
|
|
367
558
|
await this.persist(run);
|
|
368
559
|
return { ...await this.reAdvanceLocked(runId), reverted_to: normalized };
|
|
@@ -411,7 +602,10 @@ export class StratumEngine {
|
|
|
411
602
|
async flowPoll(runId, cursor = 0) {
|
|
412
603
|
if (!Number.isInteger(cursor) || cursor < 0)
|
|
413
604
|
throw new Error("invalid event cursor");
|
|
414
|
-
|
|
605
|
+
// Active fanouts pin a mutable run and set terminal status before save()
|
|
606
|
+
// finishes. Observers must see only committed state; otherwise "completed"
|
|
607
|
+
// can race the final write/rename and disagree with a restarted engine.
|
|
608
|
+
const run = await this.store.load(runId);
|
|
415
609
|
return {
|
|
416
610
|
runId,
|
|
417
611
|
status: run.status,
|
|
@@ -423,18 +617,19 @@ export class StratumEngine {
|
|
|
423
617
|
};
|
|
424
618
|
}
|
|
425
619
|
async flowBgPoll(runId, cursor = 0) {
|
|
426
|
-
const flow = await this.flowPoll(runId, cursor);
|
|
427
620
|
const bg = this.bgFlows.get(runId);
|
|
428
621
|
if (!bg)
|
|
429
622
|
throw new Error(`background flow ${runId} not found`);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
623
|
+
// Capture driver state before reading disk. A terminal driver status is set
|
|
624
|
+
// only after persistence, so this ordering cannot pair bg.completed with an
|
|
625
|
+
// older running snapshot when the driver finishes during the asynchronous read.
|
|
626
|
+
const driver = {
|
|
627
|
+
status: bg.status,
|
|
628
|
+
cancelRequested: bg.cancelRequested,
|
|
629
|
+
pendingGates: [...bg.pendingGates],
|
|
437
630
|
};
|
|
631
|
+
const flow = await this.flowPoll(runId, cursor);
|
|
632
|
+
return { ...flow, bg: driver };
|
|
438
633
|
}
|
|
439
634
|
async flowCancelBg(runId) {
|
|
440
635
|
const bg = this.bgFlows.get(runId);
|
|
@@ -459,8 +654,20 @@ export class StratumEngine {
|
|
|
459
654
|
}
|
|
460
655
|
return { status: bg.status };
|
|
461
656
|
}
|
|
462
|
-
async gateResolve(runId, stepId, decision, gateToken) {
|
|
657
|
+
async gateResolve(runId, stepId, decision, gateToken, userId) {
|
|
463
658
|
const response = await this.withRunLock(runId, () => this.gateResolveLocked(runId, stepId, decision, gateToken));
|
|
659
|
+
const resolvedRun = await this.loadRun(runId);
|
|
660
|
+
if (resolvedRun.bundle_id !== undefined) {
|
|
661
|
+
const round = resolvedRun.events.filter((event) => event.type === "gate_resolved" && event.stepId === stepId).length;
|
|
662
|
+
this.firePolicyEvent(buildGateResolutionEvent({
|
|
663
|
+
runId,
|
|
664
|
+
bundleId: resolvedRun.bundle_id,
|
|
665
|
+
stepId,
|
|
666
|
+
round,
|
|
667
|
+
outcome: decision,
|
|
668
|
+
...(userId !== undefined ? { resolvedByUserId: userId } : {}),
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
464
671
|
const bg = this.bgFlows.get(runId);
|
|
465
672
|
if (bg?.status === "paused_gate" && response.status !== "ready" && response.status !== "running") {
|
|
466
673
|
bg.status = response.status;
|
|
@@ -532,7 +739,7 @@ export class StratumEngine {
|
|
|
532
739
|
scope.parent.state.sub.rounds = total;
|
|
533
740
|
else
|
|
534
741
|
run.rounds = total;
|
|
535
|
-
this.resetFrom(
|
|
742
|
+
this.resetFrom(run, scope, target);
|
|
536
743
|
// The target's descendants include this gate; retain its local revision counter.
|
|
537
744
|
scope.steps[step.id].iterations = gateRounds + 1;
|
|
538
745
|
await this.persist(run);
|
|
@@ -560,7 +767,7 @@ export class StratumEngine {
|
|
|
560
767
|
return this.advance(run, validated.value, validated.contracts, scope);
|
|
561
768
|
}
|
|
562
769
|
async completeTerminalGate(run, flow, contracts) {
|
|
563
|
-
const output = this.resolveFlowOutput({ input: run.input, steps: run.steps, flow });
|
|
770
|
+
const output = this.resolveFlowOutput({ input: run.input, steps: run.steps, flow, flowName: run.flowName });
|
|
564
771
|
const parsed = contracts[flow.output.contract]?.safeParse(output);
|
|
565
772
|
if (!parsed?.success)
|
|
566
773
|
return this.terminalFailure(run, { attempt: 0, reason: parsed?.error.message ?? "flow output contract missing" });
|
|
@@ -568,6 +775,7 @@ export class StratumEngine {
|
|
|
568
775
|
run.status = "completed";
|
|
569
776
|
this.event(run, "completed", undefined, { output });
|
|
570
777
|
await this.persist(run);
|
|
778
|
+
this.emitFlowTerminal(run);
|
|
571
779
|
return this.response(run);
|
|
572
780
|
}
|
|
573
781
|
/** Re-derive a run's response after async fanout/subflow progress without
|
|
@@ -737,6 +945,7 @@ export class StratumEngine {
|
|
|
737
945
|
run.status = "completed";
|
|
738
946
|
this.event(run, "completed", undefined, { output });
|
|
739
947
|
await this.persist(run);
|
|
948
|
+
this.emitFlowTerminal(run);
|
|
740
949
|
return this.response(run);
|
|
741
950
|
}
|
|
742
951
|
return this.failScope(run, spec, contracts, scope, "no runnable steps remain");
|
|
@@ -895,6 +1104,74 @@ export class StratumEngine {
|
|
|
895
1104
|
break;
|
|
896
1105
|
}
|
|
897
1106
|
}
|
|
1107
|
+
if (step.evaluate !== undefined) {
|
|
1108
|
+
const attempt = state.attempts.length + 1;
|
|
1109
|
+
const evaluate = step.evaluate;
|
|
1110
|
+
// Deterministic, single-shot, and atomic: no intermediate `running`
|
|
1111
|
+
// is persisted, so a crash mid-evaluate leaves the step `pending` and
|
|
1112
|
+
// it re-runs on resume. `forceExhausted` terminalizes every failure —
|
|
1113
|
+
// retrying a deterministic evaluator is pointless (backtrack is S3).
|
|
1114
|
+
const evalFail = (reason) => this.failAttempt(run, spec, contracts, scope, step, state, attempt, reason, {}, undefined, undefined, true);
|
|
1115
|
+
if (!this.evaluateRunner) {
|
|
1116
|
+
await evalFail("evaluate: no evaluate runner configured");
|
|
1117
|
+
changed = true;
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
let input;
|
|
1121
|
+
try {
|
|
1122
|
+
input = evaluate.in === undefined ? undefined : this.renderValue(evaluate.in, scope);
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
await evalFail(`evaluate: input render failed: ${message(error)}`);
|
|
1126
|
+
changed = true;
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
// Sandboxing (workspaceRoot jail) is deferred — see design open question 3.
|
|
1130
|
+
let rawOutcome;
|
|
1131
|
+
try {
|
|
1132
|
+
rawOutcome = await this.evaluateRunner({ command: evaluate.command, input, timeoutMs: evaluate.timeout_ms }, {});
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
await evalFail(`evaluate: runner threw: ${message(error)}`);
|
|
1136
|
+
changed = true;
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
const envelope = evaluateRunResultSchema.safeParse(rawOutcome);
|
|
1140
|
+
if (!envelope.success) {
|
|
1141
|
+
await evalFail(`evaluate: runner returned a malformed result envelope: ${envelope.error.message}`);
|
|
1142
|
+
changed = true;
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
const outcome = envelope.data;
|
|
1146
|
+
if (!outcome.ok) {
|
|
1147
|
+
const detail = outcome.reason;
|
|
1148
|
+
const reason = outcome.kind === "exit" ? `evaluate: command exited with a non-zero status (${detail})`
|
|
1149
|
+
: outcome.kind === "timeout" ? `evaluate: command timed out (${detail})`
|
|
1150
|
+
: `evaluate: output was not valid JSON (${detail})`;
|
|
1151
|
+
await evalFail(reason);
|
|
1152
|
+
changed = true;
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
const parsed = evaluatorResultSchema.safeParse(outcome.result);
|
|
1156
|
+
if (!parsed.success) {
|
|
1157
|
+
await evalFail(`evaluate: output failed the evaluator-result contract: ${parsed.error.message}`);
|
|
1158
|
+
changed = true;
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
const outError = this.contractError(step, parsed.data, contracts);
|
|
1162
|
+
if (outError) {
|
|
1163
|
+
await evalFail(`evaluate: output failed the ${step.out} contract: ${outError}`);
|
|
1164
|
+
changed = true;
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1167
|
+
state.status = "succeeded";
|
|
1168
|
+
state.output = parsed.data;
|
|
1169
|
+
state.attempts.push({ attempt, at: now(), result: parsed.data });
|
|
1170
|
+
this.event(run, "result", this.scopedId(scope, step.id), { attempt, result: parsed.data });
|
|
1171
|
+
await this.persist(run);
|
|
1172
|
+
changed = true;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
898
1175
|
if (step.do === undefined) {
|
|
899
1176
|
await this.failScope(run, spec, contracts, scope, "construct is outside P1 engine scope");
|
|
900
1177
|
break;
|
|
@@ -1362,7 +1639,11 @@ export class StratumEngine {
|
|
|
1362
1639
|
const usage = { ...(result.usage ?? {}) };
|
|
1363
1640
|
const reportedDispatches = usage.dispatches !== undefined;
|
|
1364
1641
|
delete usage.dispatches;
|
|
1365
|
-
const settled =
|
|
1642
|
+
const settled = hasBudget(usage)
|
|
1643
|
+
? this.settleLegacyReceipt(run, usage, "fanout", result.telemetry, {
|
|
1644
|
+
scope: this.rootScope(run, spec), step, state, item,
|
|
1645
|
+
}, result.usdSource, result.split)
|
|
1646
|
+
: undefined;
|
|
1366
1647
|
if (hasBudget(usage))
|
|
1367
1648
|
this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: usage });
|
|
1368
1649
|
const stageStep = { ...step, do: stage.do, out: stage.out, ensure: stage.ensure, budget: step.budget };
|
|
@@ -1484,7 +1765,7 @@ export class StratumEngine {
|
|
|
1484
1765
|
}
|
|
1485
1766
|
/** Evaluates a step's ensure list in order; the first failing predicate wins. */
|
|
1486
1767
|
async runEnsures(run, step, state, output, scope = this.rootScope(run, this.validationFor(run).value), fanoutItem) {
|
|
1487
|
-
for (const predicate of step.ensure ?? []) {
|
|
1768
|
+
for (const [ensureIndex, predicate] of (step.ensure ?? []).entries()) {
|
|
1488
1769
|
if ("judged" in predicate) {
|
|
1489
1770
|
const { statement, stakes } = predicate.judged;
|
|
1490
1771
|
// The runner is an injected seam — validate its outcome; a malformed shape
|
|
@@ -1523,12 +1804,24 @@ export class StratumEngine {
|
|
|
1523
1804
|
failureReason = `judged predicate failed: ${message(error)}`;
|
|
1524
1805
|
}
|
|
1525
1806
|
}
|
|
1526
|
-
const usage = outcome?.usage ?? {};
|
|
1527
|
-
const
|
|
1807
|
+
const usage = { ...(outcome?.usage ?? {}) };
|
|
1808
|
+
const judgedUsageAsReported = { ...usage };
|
|
1809
|
+
// A judge may report `dispatches`; that key is ledger-only (never a receipt)
|
|
1810
|
+
// and keeps its pre-receipt settle semantics.
|
|
1811
|
+
const judgedDispatches = usage.dispatches !== undefined ? { dispatches: usage.dispatches } : undefined;
|
|
1812
|
+
delete usage.dispatches;
|
|
1813
|
+
const receiptItem = fanoutItem === undefined ? undefined : state.fanout?.items[fanoutItem.itemIndex];
|
|
1814
|
+
const dispatchFailure = judgedDispatches !== undefined ? this.debit(run, step, state, judgedDispatches, "settle", scope) : undefined;
|
|
1815
|
+
const costFailure = hasBudget(usage)
|
|
1816
|
+
? this.settleLegacyReceipt(run, usage, "judged", outcome?.model === undefined ? undefined : {
|
|
1817
|
+
model: outcome.model.length > 0 ? outcome.model : "unknown", durationMs: 0,
|
|
1818
|
+
}, { scope, step, state, ...(receiptItem !== undefined ? { item: receiptItem } : {}) })
|
|
1819
|
+
: undefined;
|
|
1820
|
+
const budgetFailure = worstBudget(dispatchFailure, costFailure);
|
|
1528
1821
|
// A judged debit inside a fanout item must stay visible per item — the
|
|
1529
1822
|
// observability contract forbids anonymous ledger movement.
|
|
1530
|
-
if (fanoutItem && hasBudget(
|
|
1531
|
-
this.event(run, "fanout_ledger_debit", step.id, { itemIndex: fanoutItem.itemIndex, amount:
|
|
1823
|
+
if (fanoutItem && hasBudget(judgedUsageAsReported)) {
|
|
1824
|
+
this.event(run, "fanout_ledger_debit", step.id, { itemIndex: fanoutItem.itemIndex, amount: judgedUsageAsReported, source: "judged" });
|
|
1532
1825
|
}
|
|
1533
1826
|
// Fixed audit payload — every judged evaluation events, failures included.
|
|
1534
1827
|
this.event(run, "judged", this.scopedId(scope, step.id), {
|
|
@@ -1541,6 +1834,7 @@ export class StratumEngine {
|
|
|
1541
1834
|
usage: { tokens: usage.tokens ?? 0, usd: usage.usd ?? 0 },
|
|
1542
1835
|
...(fanoutItem ? { itemIndex: fanoutItem.itemIndex, stage: fanoutItem.stage } : {}),
|
|
1543
1836
|
});
|
|
1837
|
+
this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, failureReason === undefined && outcome?.holds === true, "judged");
|
|
1544
1838
|
if (budgetFailure === "flow")
|
|
1545
1839
|
return { kind: "flow_budget" };
|
|
1546
1840
|
if (budgetFailure === "subflow")
|
|
@@ -1560,6 +1854,7 @@ export class StratumEngine {
|
|
|
1560
1854
|
? `file_exists(${JSON.stringify(predicate.file_exists)})`
|
|
1561
1855
|
: `file_contains(${JSON.stringify(predicate.file_contains.path)}, ${JSON.stringify(predicate.file_contains.text)})`;
|
|
1562
1856
|
const verdict = this.ensurePredicate(expression, run, output, scope, fanoutItem);
|
|
1857
|
+
this.recordPolicyVerdict(run, scope.flowName, step.id, ensureIndex, verdict.holds, predicateType(predicate));
|
|
1563
1858
|
if (!verdict.holds)
|
|
1564
1859
|
return { kind: "fail", reason: `ensure ${JSON.stringify(expression)} failed: ${verdict.reason}` };
|
|
1565
1860
|
}
|
|
@@ -1624,6 +1919,90 @@ export class StratumEngine {
|
|
|
1624
1919
|
return "task";
|
|
1625
1920
|
return undefined;
|
|
1626
1921
|
}
|
|
1922
|
+
settleReceipt(run, receipt, located, explicitAttempt) {
|
|
1923
|
+
const duplicate = findReceipt(run, receipt.dispatchId);
|
|
1924
|
+
if (duplicate !== undefined)
|
|
1925
|
+
return { status: "duplicate", receipt: duplicate };
|
|
1926
|
+
const canonicalStepId = located === undefined ? undefined : this.scopedId(located.scope, located.step.id);
|
|
1927
|
+
if (canonicalStepId !== undefined)
|
|
1928
|
+
receipt.stepId = canonicalStepId;
|
|
1929
|
+
let budget;
|
|
1930
|
+
const executable = located !== undefined && (located.step.do !== undefined || located.step.fanout !== undefined);
|
|
1931
|
+
if (executable) {
|
|
1932
|
+
budget = this.debit(run, located.step, located.state, receipt.amount, "settle", located.scope);
|
|
1933
|
+
}
|
|
1934
|
+
else {
|
|
1935
|
+
const flowLedger = new BudgetLedger(this.flowFor(run, this.validationFor(run).value).budget, run.flowSpent);
|
|
1936
|
+
const subflowLedger = located?.scope.parent
|
|
1937
|
+
? new BudgetLedger(located.scope.parent.step.budget, located.scope.parent.state.spent)
|
|
1938
|
+
: undefined;
|
|
1939
|
+
const flowOk = flowLedger.canDebit(receipt.amount);
|
|
1940
|
+
const subflowOk = subflowLedger?.canDebit(receipt.amount) ?? true;
|
|
1941
|
+
flowLedger.debit(receipt.amount);
|
|
1942
|
+
subflowLedger?.debit(receipt.amount);
|
|
1943
|
+
Object.assign(run.flowSpent, flowLedger.spent);
|
|
1944
|
+
if (subflowLedger && located?.scope.parent)
|
|
1945
|
+
Object.assign(located.scope.parent.state.spent, subflowLedger.spent);
|
|
1946
|
+
if (!flowOk)
|
|
1947
|
+
budget = "flow";
|
|
1948
|
+
else if (!subflowOk)
|
|
1949
|
+
budget = "subflow";
|
|
1950
|
+
}
|
|
1951
|
+
(run.receipts ??= []).push(receipt);
|
|
1952
|
+
// `attempt` names the attempt the call belongs to. Legacy settles pass it (the
|
|
1953
|
+
// attempt record is pushed after settlement, so length+1 is that attempt);
|
|
1954
|
+
// external receipts get it only while the step is awaiting a result. Gates
|
|
1955
|
+
// and finished steps have no attempt to name.
|
|
1956
|
+
const attempt = explicitAttempt
|
|
1957
|
+
?? (executable && (located?.item !== undefined
|
|
1958
|
+
? (located.item.status === "ready" || located.item.status === "running")
|
|
1959
|
+
: located?.state.status === "ready")
|
|
1960
|
+
? (located?.item !== undefined ? located.item.attempts.length + 1 : located.state.attempts.length + 1)
|
|
1961
|
+
: undefined);
|
|
1962
|
+
const item = located?.item;
|
|
1963
|
+
const detail = {
|
|
1964
|
+
...(located?.state.epoch !== undefined ? { epoch: located.state.epoch } : {}),
|
|
1965
|
+
...(attempt !== undefined ? { attempt } : {}),
|
|
1966
|
+
...(item?.stage !== undefined ? { item: { itemIndex: item.index, stage: item.stage, generation: item.generation } } : {}),
|
|
1967
|
+
};
|
|
1968
|
+
// One detail object serves both the audit event and the receipt row, so the
|
|
1969
|
+
// SmartMemory mirror carries exactly what the event stream carries.
|
|
1970
|
+
const eventDetail = {
|
|
1971
|
+
seq: receipt.seq,
|
|
1972
|
+
dispatchId: receipt.dispatchId,
|
|
1973
|
+
source: receipt.source,
|
|
1974
|
+
amount: { ...receipt.amount },
|
|
1975
|
+
model: receipt.telemetry.model,
|
|
1976
|
+
...(receipt.telemetry.effort !== undefined ? { effort: receipt.telemetry.effort } : {}),
|
|
1977
|
+
durationMs: receipt.telemetry.durationMs,
|
|
1978
|
+
...detail,
|
|
1979
|
+
...(receipt.split !== undefined ? { split: { ...receipt.split } } : {}),
|
|
1980
|
+
...(receipt.usdSource !== undefined ? { usdSource: receipt.usdSource } : {}),
|
|
1981
|
+
...(receipt.reportedAt !== undefined ? { reportedAt: receipt.reportedAt } : {}),
|
|
1982
|
+
};
|
|
1983
|
+
receipt.detail = { ...(receipt.detail ?? {}), ...eventDetail };
|
|
1984
|
+
this.event(run, "usage_debit", canonicalStepId, structuredClone(eventDetail));
|
|
1985
|
+
return { status: "ok", ...(budget !== undefined ? { budget } : {}) };
|
|
1986
|
+
}
|
|
1987
|
+
settleLegacyReceipt(run, usage, source, telemetry, located, usdSource, split) {
|
|
1988
|
+
const seq = (run.receiptCounter ?? 0) + 1;
|
|
1989
|
+
const receipt = buildReceipt(run, {
|
|
1990
|
+
dispatchId: `legacy:${seq}`,
|
|
1991
|
+
stepId: this.scopedId(located.scope, located.step.id),
|
|
1992
|
+
source,
|
|
1993
|
+
usage,
|
|
1994
|
+
...(telemetry !== undefined ? { telemetry } : {}),
|
|
1995
|
+
...(split !== undefined ? { split } : {}),
|
|
1996
|
+
// A connector that priced the call itself keeps "reported"; only an
|
|
1997
|
+
// unlabelled usd is engine-synthesized "legacy".
|
|
1998
|
+
...(usage.usd !== undefined ? { usdSource: usdSource ?? "legacy" } : {}),
|
|
1999
|
+
});
|
|
2000
|
+
const attempt = (located.item?.attempts.length ?? located.state.attempts.length) + 1;
|
|
2001
|
+
const settled = this.settleReceipt(run, receipt, located, attempt);
|
|
2002
|
+
if (settled.status === "duplicate")
|
|
2003
|
+
throw new Error(`legacy receipt sequence ${seq} collided`);
|
|
2004
|
+
return settled.budget;
|
|
2005
|
+
}
|
|
1627
2006
|
unreachableOnFailTarget(step, scope) {
|
|
1628
2007
|
const routers = scope.flow.steps.filter((candidate) => candidate.on_fail === step.id);
|
|
1629
2008
|
return routers.length > 0
|
|
@@ -1636,7 +2015,8 @@ export class StratumEngine {
|
|
|
1636
2015
|
return !routesHere || scope.steps[step.id].routed !== undefined;
|
|
1637
2016
|
}
|
|
1638
2017
|
/** Reset a revise target and its ordinary descendants; static validation proved target ancestry. */
|
|
1639
|
-
resetFrom(
|
|
2018
|
+
resetFrom(run, scope, target) {
|
|
2019
|
+
const { flow, steps } = scope;
|
|
1640
2020
|
const descendants = new Set([target]);
|
|
1641
2021
|
let changed = true;
|
|
1642
2022
|
while (changed) {
|
|
@@ -1657,6 +2037,20 @@ export class StratumEngine {
|
|
|
1657
2037
|
}
|
|
1658
2038
|
}
|
|
1659
2039
|
}
|
|
2040
|
+
const reset = [...descendants].map((id) => {
|
|
2041
|
+
const fromEpoch = steps[id].epoch ?? 0;
|
|
2042
|
+
return { stepId: this.scopedId(scope, id), fromEpoch, toEpoch: fromEpoch + 1 };
|
|
2043
|
+
});
|
|
2044
|
+
const subflowsDropped = [...descendants].flatMap((id) => steps[id].sub === undefined ? [] : [this.scopedId(scope, id)]);
|
|
2045
|
+
const detail = { reason: "revise", reset, subflowsDropped };
|
|
2046
|
+
this.event(run, "step_reset", this.scopedId(scope, target), detail);
|
|
2047
|
+
const resetReceiptSeq = (run.receiptCounter ?? 0) + 1;
|
|
2048
|
+
(run.receipts ??= []).push(buildReceipt(run, {
|
|
2049
|
+
dispatchId: `engine:step_reset:${resetReceiptSeq}`,
|
|
2050
|
+
source: "engine",
|
|
2051
|
+
usage: {},
|
|
2052
|
+
detail,
|
|
2053
|
+
}));
|
|
1660
2054
|
const gateIds = new Set(flow.steps.flatMap((step) => step.gate !== undefined ? [step.id] : []));
|
|
1661
2055
|
for (const id of descendants) {
|
|
1662
2056
|
const state = steps[id];
|
|
@@ -1859,7 +2253,7 @@ export class StratumEngine {
|
|
|
1859
2253
|
return parse && !parse.success ? parse.error.message : parse ? undefined : "output contract missing";
|
|
1860
2254
|
}
|
|
1861
2255
|
rootScope(run, spec) {
|
|
1862
|
-
return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec) };
|
|
2256
|
+
return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec), flowName: run.flowName };
|
|
1863
2257
|
}
|
|
1864
2258
|
childScope(spec, parentStep, parentState) {
|
|
1865
2259
|
if (parentStep.run === undefined || parentState.sub === undefined)
|
|
@@ -1871,6 +2265,7 @@ export class StratumEngine {
|
|
|
1871
2265
|
input: parentState.sub.input,
|
|
1872
2266
|
steps: parentState.sub.steps,
|
|
1873
2267
|
flow,
|
|
2268
|
+
flowName: parentStep.run,
|
|
1874
2269
|
prefix: parentStep.id,
|
|
1875
2270
|
parent: { step: parentStep, state: parentState },
|
|
1876
2271
|
};
|
|
@@ -1901,6 +2296,32 @@ export class StratumEngine {
|
|
|
1901
2296
|
const state = scope.steps[childId];
|
|
1902
2297
|
return step && state ? { scope, step, state } : undefined;
|
|
1903
2298
|
}
|
|
2299
|
+
/** Receipt attribution may arrive after a step or run terminalizes, when the
|
|
2300
|
+
* execution lookup intentionally hides inactive subflow/fanout state. */
|
|
2301
|
+
locateReceiptStep(run, spec, id) {
|
|
2302
|
+
const active = this.locateStep(run, spec, id);
|
|
2303
|
+
if (active !== undefined || !id.includes("/"))
|
|
2304
|
+
return active;
|
|
2305
|
+
const parts = id.split("/");
|
|
2306
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
2307
|
+
return undefined;
|
|
2308
|
+
const [parentId, childId] = parts;
|
|
2309
|
+
const root = this.rootScope(run, spec);
|
|
2310
|
+
const parentStep = root.flow.steps.find((candidate) => candidate.id === parentId);
|
|
2311
|
+
const parentState = root.steps[parentId];
|
|
2312
|
+
if (!parentStep || !parentState)
|
|
2313
|
+
return undefined;
|
|
2314
|
+
if (parentStep.fanout?.dispatch === "consumer" && parentState.fanout && /^(0|[1-9][0-9]*)$/.test(childId)) {
|
|
2315
|
+
const item = parentState.fanout.items[Number(childId)];
|
|
2316
|
+
return item && item.index === Number(childId) ? { scope: root, step: parentStep, state: parentState, item } : undefined;
|
|
2317
|
+
}
|
|
2318
|
+
if (parentStep.run === undefined || parentState.sub === undefined)
|
|
2319
|
+
return undefined;
|
|
2320
|
+
const scope = this.childScope(spec, parentStep, parentState);
|
|
2321
|
+
const step = scope.flow.steps.find((candidate) => candidate.id === childId);
|
|
2322
|
+
const state = scope.steps[childId];
|
|
2323
|
+
return step && state ? { scope, step, state } : undefined;
|
|
2324
|
+
}
|
|
1904
2325
|
collectReady(run, spec) {
|
|
1905
2326
|
const root = this.rootScope(run, spec);
|
|
1906
2327
|
const ready = root.flow.steps.flatMap((step) => {
|
|
@@ -2045,7 +2466,9 @@ export class StratumEngine {
|
|
|
2045
2466
|
try {
|
|
2046
2467
|
return await this.loadRun(runId);
|
|
2047
2468
|
}
|
|
2048
|
-
catch {
|
|
2469
|
+
catch (error) {
|
|
2470
|
+
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"))
|
|
2471
|
+
throw error;
|
|
2049
2472
|
throw new CheckpointOperationError("flow_not_found", `No active flow with id '${runId}'`);
|
|
2050
2473
|
}
|
|
2051
2474
|
}
|
|
@@ -2155,6 +2578,7 @@ export class StratumEngine {
|
|
|
2155
2578
|
run.failure = failure;
|
|
2156
2579
|
this.event(run, "budget_exhausted", undefined, failure);
|
|
2157
2580
|
await this.persist(run);
|
|
2581
|
+
this.emitFlowTerminal(run);
|
|
2158
2582
|
return this.response(run);
|
|
2159
2583
|
}
|
|
2160
2584
|
async terminalFailure(run, failure) {
|
|
@@ -2162,8 +2586,42 @@ export class StratumEngine {
|
|
|
2162
2586
|
run.failure = failure;
|
|
2163
2587
|
this.event(run, "failed", undefined, failure);
|
|
2164
2588
|
await this.persist(run);
|
|
2589
|
+
this.emitFlowTerminal(run);
|
|
2165
2590
|
return this.response(run);
|
|
2166
2591
|
}
|
|
2592
|
+
recordPolicyVerdict(run, flowName, stepId, ensureIndex, met, predicateTypeValue) {
|
|
2593
|
+
const binding = run.policy_rules?.[policyRuleKey(flowName, stepId)]?.find((candidate) => candidate.ensure_index === ensureIndex);
|
|
2594
|
+
if (binding === undefined)
|
|
2595
|
+
return;
|
|
2596
|
+
(run.policy_verdicts ??= []).push({
|
|
2597
|
+
rule_id: binding.rule_id,
|
|
2598
|
+
source: structuredClone(binding.source),
|
|
2599
|
+
met,
|
|
2600
|
+
predicate_type: predicateTypeValue,
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
emitFlowTerminal(run) {
|
|
2604
|
+
if (run.bundle_id === undefined)
|
|
2605
|
+
return;
|
|
2606
|
+
this.firePolicyEvent(buildFlowTerminalEvent({
|
|
2607
|
+
runId: run.id,
|
|
2608
|
+
bundleId: run.bundle_id,
|
|
2609
|
+
outcome: run.status,
|
|
2610
|
+
rulesEvaluated: run.policy_verdicts ?? [],
|
|
2611
|
+
}));
|
|
2612
|
+
}
|
|
2613
|
+
firePolicyEvent(event) {
|
|
2614
|
+
void postPolicyEvent(event).catch((error) => {
|
|
2615
|
+
console.warn(`policy event ${event.event_id} delivery failed: ${message(error)}`);
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
triggerLearnEgress(runId) {
|
|
2619
|
+
if (!this.learnEgress.enabled())
|
|
2620
|
+
return;
|
|
2621
|
+
void this.learnEgress.drainRun(runId).catch((error) => {
|
|
2622
|
+
console.warn(`SmartMemory egress drain failed for run ${runId}: ${message(error)}`);
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2167
2625
|
response(run) {
|
|
2168
2626
|
const ledger = this.ledgerInfo(run);
|
|
2169
2627
|
if (run.status === "completed")
|
|
@@ -2186,7 +2644,13 @@ export class StratumEngine {
|
|
|
2186
2644
|
}
|
|
2187
2645
|
persist(run) {
|
|
2188
2646
|
const previous = this.persistLocks.get(run.id) ?? Promise.resolve();
|
|
2189
|
-
const result = previous
|
|
2647
|
+
const result = previous
|
|
2648
|
+
.then(() => this.store.save(run))
|
|
2649
|
+
.then(() => {
|
|
2650
|
+
if (this.learnEgress.enabled() && run.receipts?.some((receipt) => receipt.egress === "pending") === true) {
|
|
2651
|
+
this.triggerLearnEgress(run.id);
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2190
2654
|
const tail = result.catch(() => undefined);
|
|
2191
2655
|
this.persistLocks.set(run.id, tail);
|
|
2192
2656
|
void tail.then(() => { if (this.persistLocks.get(run.id) === tail)
|
|
@@ -2231,6 +2695,8 @@ function stringLeaves(step) {
|
|
|
2231
2695
|
// over "${prep.output.items}" must wait for prep, not fail at resolve time.
|
|
2232
2696
|
if (step.with !== undefined)
|
|
2233
2697
|
collect(step.with);
|
|
2698
|
+
if (step.evaluate?.in !== undefined)
|
|
2699
|
+
collect(step.evaluate.in);
|
|
2234
2700
|
if (step.fanout !== undefined) {
|
|
2235
2701
|
collect(step.fanout.over);
|
|
2236
2702
|
for (const stage of step.fanout.steps) {
|
|
@@ -2298,16 +2764,12 @@ function message(error) {
|
|
|
2298
2764
|
}
|
|
2299
2765
|
}
|
|
2300
2766
|
function hasBudget(usage) { return Object.keys(usage).length > 0; }
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
return false;
|
|
2308
|
-
return typeof value.durationMs === "number" && Number.isFinite(value.durationMs) && value.durationMs >= 0
|
|
2309
|
-
&& typeof value.model === "string" && value.model.length > 0
|
|
2310
|
-
&& (value.effort === undefined || (typeof value.effort === "string" && value.effort.length > 0));
|
|
2767
|
+
/** Two settle results from one attempt collapse to the most severe ledger breach. */
|
|
2768
|
+
function worstBudget(...results) {
|
|
2769
|
+
for (const level of ["flow", "subflow", "task"])
|
|
2770
|
+
if (results.includes(level))
|
|
2771
|
+
return level;
|
|
2772
|
+
return undefined;
|
|
2311
2773
|
}
|
|
2312
2774
|
function telemetryFields(value) {
|
|
2313
2775
|
return value === undefined ? {} : { durationMs: value.durationMs, model: value.model, ...(value.effort !== undefined ? { effort: value.effort } : {}) };
|
|
@@ -2339,13 +2801,17 @@ export const defaultConnector = async ({ agent, prompt, cwd, previousFailure, ou
|
|
|
2339
2801
|
});
|
|
2340
2802
|
if ("status" in result)
|
|
2341
2803
|
return { failure: "background connector response is not valid for synchronous fanout" };
|
|
2804
|
+
const provenance = {
|
|
2805
|
+
...(result.usdSource !== undefined ? { usdSource: result.usdSource } : {}),
|
|
2806
|
+
...(result.split !== undefined ? { split: result.split } : {}),
|
|
2807
|
+
};
|
|
2342
2808
|
if (outSchema === undefined)
|
|
2343
|
-
return { output: result.text, usage: result.usage, telemetry: result.telemetry };
|
|
2809
|
+
return { output: result.text, usage: result.usage, telemetry: result.telemetry, ...provenance };
|
|
2344
2810
|
try {
|
|
2345
|
-
return { output: JSON.parse(stripJsonFences(result.text)), usage: result.usage, telemetry: result.telemetry };
|
|
2811
|
+
return { output: JSON.parse(stripJsonFences(result.text)), usage: result.usage, telemetry: result.telemetry, ...provenance };
|
|
2346
2812
|
}
|
|
2347
2813
|
catch {
|
|
2348
|
-
return { failure: "connector result must be JSON for a contract-enforced fanout stage", usage: result.usage, telemetry: result.telemetry };
|
|
2814
|
+
return { failure: "connector result must be JSON for a contract-enforced fanout stage", usage: result.usage, telemetry: result.telemetry, ...provenance };
|
|
2349
2815
|
}
|
|
2350
2816
|
};
|
|
2351
2817
|
function stripJsonFences(text) {
|