@relayflows/sdk 2.0.42 → 2.0.43
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/adapters/base.d.ts +12 -1
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/base.js +11 -1
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +8 -4
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +15 -6
- package/dist/adapters/codex.js.map +1 -1
- package/dist/agent-resume.d.ts +8 -0
- package/dist/agent-resume.d.ts.map +1 -0
- package/dist/agent-resume.js +37 -0
- package/dist/agent-resume.js.map +1 -0
- package/dist/agent-transcript.d.ts.map +1 -1
- package/dist/agent-transcript.js +5 -3
- package/dist/agent-transcript.js.map +1 -1
- package/dist/authored-flow-error.d.ts +10 -2
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +16 -2
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +6 -0
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +18 -6
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.js +3 -2
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-human.d.ts +2 -0
- package/dist/authored-human.d.ts.map +1 -1
- package/dist/authored-human.js +4 -0
- package/dist/authored-human.js.map +1 -1
- package/dist/authored-memory.d.ts +47 -2
- package/dist/authored-memory.d.ts.map +1 -1
- package/dist/authored-memory.js +394 -15
- package/dist/authored-memory.js.map +1 -1
- package/dist/authored-node-entry.js +6 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +6 -3
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root-readback.d.ts +21 -0
- package/dist/authored-root-readback.d.ts.map +1 -0
- package/dist/authored-root-readback.js +90 -0
- package/dist/authored-root-readback.js.map +1 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +53 -74
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-output.d.ts +2 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +1 -0
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +26 -4
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-helper-body.d.ts +2 -2
- package/dist/cli/check-helper-body.d.ts.map +1 -1
- package/dist/cli/check-helper-body.js +7 -2
- package/dist/cli/check-helper-body.js.map +1 -1
- package/dist/cli/check-helper-surface.d.ts +8 -0
- package/dist/cli/check-helper-surface.d.ts.map +1 -0
- package/dist/cli/check-helper-surface.js +37 -0
- package/dist/cli/check-helper-surface.js.map +1 -0
- package/dist/cli/check-triggers.d.ts +4 -3
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +7 -3
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +7 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +35 -8
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-answer.d.ts +9 -0
- package/dist/cli/cloud-answer.d.ts.map +1 -0
- package/dist/cli/cloud-answer.js +39 -0
- package/dist/cli/cloud-answer.js.map +1 -0
- package/dist/cli/cloud-connect-cli.d.ts +2 -1
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -1
- package/dist/cli/cloud-connect-cli.js +10 -2
- package/dist/cli/cloud-connect-cli.js.map +1 -1
- package/dist/cli/cloud-human.d.ts +7 -0
- package/dist/cli/cloud-human.d.ts.map +1 -0
- package/dist/cli/cloud-human.js +22 -0
- package/dist/cli/cloud-human.js.map +1 -0
- package/dist/cli/cloud-live.d.ts +2 -2
- package/dist/cli/cloud-live.d.ts.map +1 -1
- package/dist/cli/cloud-live.js +9 -2
- package/dist/cli/cloud-live.js.map +1 -1
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
- package/dist/cli/cloud-mirror-session.js +3 -2
- package/dist/cli/cloud-mirror-session.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +1 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +12 -0
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/detached-record.d.ts +22 -0
- package/dist/cli/detached-record.d.ts.map +1 -0
- package/dist/cli/detached-record.js +57 -0
- package/dist/cli/detached-record.js.map +1 -0
- package/dist/cli/detached-run.d.ts +28 -0
- package/dist/cli/detached-run.d.ts.map +1 -0
- package/dist/cli/detached-run.js +122 -0
- package/dist/cli/detached-run.js.map +1 -0
- package/dist/cli/diagnostic-fold.d.ts +19 -0
- package/dist/cli/diagnostic-fold.d.ts.map +1 -0
- package/dist/cli/diagnostic-fold.js +19 -0
- package/dist/cli/diagnostic-fold.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +16 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/eval.d.ts +22 -0
- package/dist/cli/eval.d.ts.map +1 -0
- package/dist/cli/eval.js +131 -0
- package/dist/cli/eval.js.map +1 -0
- package/dist/cli/journal-timeout.d.ts +12 -0
- package/dist/cli/journal-timeout.d.ts.map +1 -0
- package/dist/cli/journal-timeout.js +42 -0
- package/dist/cli/journal-timeout.js.map +1 -0
- package/dist/cli/local-agent-remedy.d.ts.map +1 -1
- package/dist/cli/local-agent-remedy.js +5 -4
- package/dist/cli/local-agent-remedy.js.map +1 -1
- package/dist/cli/observer-session.d.ts +1 -0
- package/dist/cli/observer-session.d.ts.map +1 -1
- package/dist/cli/observer-session.js +8 -1
- package/dist/cli/observer-session.js.map +1 -1
- package/dist/cli/reuse.d.ts +8 -0
- package/dist/cli/reuse.d.ts.map +1 -1
- package/dist/cli/reuse.js +24 -0
- package/dist/cli/reuse.js.map +1 -1
- package/dist/cli/run.d.ts +8 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +72 -56
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/running-step.d.ts +9 -0
- package/dist/cli/running-step.d.ts.map +1 -0
- package/dist/cli/running-step.js +156 -0
- package/dist/cli/running-step.js.map +1 -0
- package/dist/cli-adapter.d.ts +3 -1
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +6 -2
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli-commands.d.ts +77 -11
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +41 -7
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-watch.d.ts +6 -2
- package/dist/cli-watch.d.ts.map +1 -1
- package/dist/cli-watch.js +7 -6
- package/dist/cli-watch.js.map +1 -1
- package/dist/cli.d.ts +22 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +154 -50
- package/dist/cli.js.map +1 -1
- package/dist/cloud-answer.d.ts +35 -0
- package/dist/cloud-answer.d.ts.map +1 -0
- package/dist/cloud-answer.js +194 -0
- package/dist/cloud-answer.js.map +1 -0
- package/dist/cloud-auth-store.d.ts +46 -0
- package/dist/cloud-auth-store.d.ts.map +1 -0
- package/dist/cloud-auth-store.js +171 -0
- package/dist/cloud-auth-store.js.map +1 -0
- package/dist/cloud-http.d.ts +7 -16
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +59 -40
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-human-wait.d.ts +10 -0
- package/dist/cloud-human-wait.d.ts.map +1 -0
- package/dist/cloud-human-wait.js +17 -0
- package/dist/cloud-human-wait.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -1
- package/dist/cloud-mirror-transport.js +1 -0
- package/dist/cloud-mirror-transport.js.map +1 -1
- package/dist/cloud-run-record.d.ts +7 -2
- package/dist/cloud-run-record.d.ts.map +1 -1
- package/dist/cloud-run-record.js +13 -5
- package/dist/cloud-run-record.js.map +1 -1
- package/dist/cloud-run.d.ts +9 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +36 -2
- package/dist/cloud-run.js.map +1 -1
- package/dist/communication/local.d.ts.map +1 -1
- package/dist/communication/local.js +6 -1
- package/dist/communication/local.js.map +1 -1
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +12 -2
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -3
- package/dist/compile.js.map +1 -1
- package/dist/daemon-spec-validation.d.ts +31 -0
- package/dist/daemon-spec-validation.d.ts.map +1 -0
- package/dist/daemon-spec-validation.js +115 -0
- package/dist/daemon-spec-validation.js.map +1 -0
- package/dist/failure-kinds.d.ts +6 -4
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +17 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-eval-cloud.d.ts +25 -0
- package/dist/flow-eval-cloud.d.ts.map +1 -0
- package/dist/flow-eval-cloud.js +108 -0
- package/dist/flow-eval-cloud.js.map +1 -0
- package/dist/flow-eval-error.d.ts +7 -0
- package/dist/flow-eval-error.d.ts.map +1 -0
- package/dist/flow-eval-error.js +10 -0
- package/dist/flow-eval-error.js.map +1 -0
- package/dist/flow-eval-gate.d.ts +15 -0
- package/dist/flow-eval-gate.d.ts.map +1 -0
- package/dist/flow-eval-gate.js +102 -0
- package/dist/flow-eval-gate.js.map +1 -0
- package/dist/flow-eval-local.d.ts +54 -0
- package/dist/flow-eval-local.d.ts.map +1 -0
- package/dist/flow-eval-local.js +220 -0
- package/dist/flow-eval-local.js.map +1 -0
- package/dist/flow-eval-outcome.d.ts +22 -0
- package/dist/flow-eval-outcome.d.ts.map +1 -0
- package/dist/flow-eval-outcome.js +37 -0
- package/dist/flow-eval-outcome.js.map +1 -0
- package/dist/flow-eval-report.d.ts +106 -0
- package/dist/flow-eval-report.d.ts.map +1 -0
- package/dist/flow-eval-report.js +4 -0
- package/dist/flow-eval-report.js.map +1 -0
- package/dist/flow-eval-suite.d.ts +46 -0
- package/dist/flow-eval-suite.d.ts.map +1 -0
- package/dist/flow-eval-suite.js +144 -0
- package/dist/flow-eval-suite.js.map +1 -0
- package/dist/flow-eval-version.d.ts +59 -0
- package/dist/flow-eval-version.d.ts.map +1 -0
- package/dist/flow-eval-version.js +303 -0
- package/dist/flow-eval-version.js.map +1 -0
- package/dist/flow-eval.d.ts +41 -0
- package/dist/flow-eval.d.ts.map +1 -0
- package/dist/flow-eval.js +201 -0
- package/dist/flow-eval.js.map +1 -0
- package/dist/helper-operation-use.d.ts +18 -0
- package/dist/helper-operation-use.d.ts.map +1 -0
- package/dist/helper-operation-use.js +336 -0
- package/dist/helper-operation-use.js.map +1 -0
- package/dist/helper-preflight.d.ts.map +1 -1
- package/dist/helper-preflight.js +16 -2
- package/dist/helper-preflight.js.map +1 -1
- package/dist/helper-reference.d.ts +33 -0
- package/dist/helper-reference.d.ts.map +1 -1
- package/dist/helper-reference.js +5 -5
- package/dist/helper-reference.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-budgeted-reads.d.ts +41 -0
- package/dist/journal-budgeted-reads.d.ts.map +1 -0
- package/dist/journal-budgeted-reads.js +142 -0
- package/dist/journal-budgeted-reads.js.map +1 -0
- package/dist/journal-client.d.ts +34 -29
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +45 -151
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-connection.d.ts +42 -0
- package/dist/journal-connection.d.ts.map +1 -0
- package/dist/journal-connection.js +209 -0
- package/dist/journal-connection.js.map +1 -0
- package/dist/journal-read-policy.d.ts +38 -0
- package/dist/journal-read-policy.d.ts.map +1 -0
- package/dist/journal-read-policy.js +126 -0
- package/dist/journal-read-policy.js.map +1 -0
- package/dist/named-gate-lowering.d.ts.map +1 -1
- package/dist/named-gate-lowering.js +2 -6
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/re2js-embedded.d.ts +3 -0
- package/dist/re2js-embedded.d.ts.map +1 -0
- package/dist/re2js-embedded.js +4 -0
- package/dist/re2js-embedded.js.map +1 -0
- package/dist/spec.d.ts +10 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +1 -1
- package/dist/step-fields.js +1 -1
- package/dist/step-fields.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +15 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +3 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +15 -5
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-lease.d.ts.map +1 -1
- package/dist/worker-lease.js +11 -3
- package/dist/worker-lease.js.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +5 -3
- package/src/adapters/base.ts +16 -1
- package/src/adapters/claude.ts +8 -4
- package/src/adapters/codex.ts +15 -6
- package/src/agent-resume.ts +36 -0
- package/src/agent-transcript.ts +5 -3
- package/src/authored-flow-error.ts +9 -0
- package/src/authored-flow-executor.ts +18 -1
- package/src/authored-flow-loader.ts +27 -10
- package/src/authored-helper-effect.ts +3 -2
- package/src/authored-human.ts +5 -0
- package/src/authored-memory.ts +445 -15
- package/src/authored-node-entry.ts +6 -1
- package/src/authored-node-runner.ts +6 -3
- package/src/authored-root-readback.ts +100 -0
- package/src/authored-root.ts +58 -85
- package/src/authored-step-output.ts +3 -0
- package/src/authored-worker-step.ts +29 -4
- package/src/cli/check-helper-body.ts +9 -3
- package/src/cli/check-helper-surface.ts +43 -0
- package/src/cli/check-triggers.ts +9 -4
- package/src/cli/check.ts +37 -7
- package/src/cli/cloud-answer.ts +40 -0
- package/src/cli/cloud-connect-cli.ts +9 -2
- package/src/cli/cloud-human.ts +24 -0
- package/src/cli/cloud-live.ts +10 -5
- package/src/cli/cloud-mirror-session.ts +3 -2
- package/src/cli/cloud-run.ts +10 -1
- package/src/cli/detached-record.ts +71 -0
- package/src/cli/detached-run.ts +131 -0
- package/src/cli/diagnostic-fold.ts +28 -0
- package/src/cli/direct-run.ts +14 -1
- package/src/cli/eval.ts +141 -0
- package/src/cli/journal-timeout.ts +49 -0
- package/src/cli/local-agent-remedy.ts +6 -4
- package/src/cli/observer-session.ts +7 -1
- package/src/cli/reuse.ts +25 -0
- package/src/cli/run.ts +75 -67
- package/src/cli/running-step.ts +140 -0
- package/src/cli-adapter.ts +7 -1
- package/src/cli-commands.ts +43 -7
- package/src/cli-watch.ts +8 -5
- package/src/cli.ts +158 -51
- package/src/cloud-answer.ts +202 -0
- package/src/cloud-auth-store.ts +185 -0
- package/src/cloud-http.ts +61 -50
- package/src/cloud-human-wait.ts +23 -0
- package/src/cloud-mirror-transport.ts +1 -0
- package/src/cloud-run-record.ts +14 -5
- package/src/cloud-run.ts +46 -2
- package/src/communication/local.ts +6 -1
- package/src/communication/worker.ts +11 -3
- package/src/compile.ts +5 -3
- package/src/daemon-spec-validation.ts +126 -0
- package/src/failure-kinds.ts +17 -1
- package/src/flow-eval-cloud.ts +114 -0
- package/src/flow-eval-error.ts +10 -0
- package/src/flow-eval-gate.ts +114 -0
- package/src/flow-eval-local.ts +236 -0
- package/src/flow-eval-outcome.ts +43 -0
- package/src/flow-eval-report.ts +102 -0
- package/src/flow-eval-suite.ts +169 -0
- package/src/flow-eval-version.ts +316 -0
- package/src/flow-eval.ts +234 -0
- package/src/helper-operation-use.ts +285 -0
- package/src/helper-preflight.ts +17 -2
- package/src/helper-reference.ts +6 -6
- package/src/index.ts +15 -1
- package/src/journal-budgeted-reads.ts +134 -0
- package/src/journal-client.ts +59 -166
- package/src/journal-connection.ts +222 -0
- package/src/journal-read-policy.ts +123 -0
- package/src/named-gate-lowering.ts +2 -6
- package/src/re2js-embedded.ts +3 -0
- package/src/spec.ts +10 -0
- package/src/step-fields.ts +1 -1
- package/src/validate.ts +14 -0
- package/src/worker-cli.ts +14 -3
- package/src/worker-lease.ts +11 -3
- package/src/worker.ts +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The local executor for `evaluateFlow`: one fresh `flows run` per case
|
|
2
|
+
// against relayflowd, and the case's spend read back from the journals.
|
|
3
|
+
// Memoization (`--reuse-from`) is never used: an evaluation must judge the
|
|
4
|
+
// candidate's own step results, not a prior version's.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { canonicalize } from './canonical.js';
|
|
13
|
+
import { isAuthoredFlowPath } from './direct-input.js';
|
|
14
|
+
import { walkJournal } from './journal-reader.js';
|
|
15
|
+
import type { FlowEvalExecutor, FlowEvalRun, FlowEvalStep } from './flow-eval-report.js';
|
|
16
|
+
import { runReportOutcome } from './flow-eval-outcome.js';
|
|
17
|
+
import type { RunExecution } from './cli/run.js';
|
|
18
|
+
|
|
19
|
+
export interface LocalFlowEvalExecutorOptions {
|
|
20
|
+
/** Daemon data directory. Defaults to `.relayflowd`. */
|
|
21
|
+
dataDir?: string;
|
|
22
|
+
/** Attach a local agent/LLM worker, as `flows run --local-agent`. */
|
|
23
|
+
localAgent?: boolean;
|
|
24
|
+
agentCapacity?: number;
|
|
25
|
+
/** `{ spawn: false }` refuses instead of starting a daemon, as `flows run --no-spawn`. */
|
|
26
|
+
daemon?: { spawn?: boolean };
|
|
27
|
+
/** The `flows` CLI entry point. Defaults to this package's own `dist/cli.js`. */
|
|
28
|
+
cliPath?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Working directory for each run. Defaults to the snapshot directory holding
|
|
31
|
+
* the flow, so a relative read (`readFileSync('./prompt.txt')`) resolves
|
|
32
|
+
* inside the sealed copy — and fails there if the file is not part of the
|
|
33
|
+
* version — instead of reading the caller's mutable working tree. Setting it
|
|
34
|
+
* deliberately reintroduces that dependency.
|
|
35
|
+
*/
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Execute each case as a fresh local run, in its own `flows run` process.
|
|
41
|
+
*
|
|
42
|
+
* A child process per case is deliberate. Authored source is loaded with
|
|
43
|
+
* `import()`, and an ES module is cached by URL for the life of a process: an
|
|
44
|
+
* in-process run would keep executing the FIRST version of a `.flow.ts` it saw,
|
|
45
|
+
* while the report named the edited bytes on disk. A gate must run what it
|
|
46
|
+
* judges, so each case gets a process that has never seen the flow before.
|
|
47
|
+
*
|
|
48
|
+
* Authored `.flow.ts` receives the case input (`{}` when the case has none);
|
|
49
|
+
* declarative YAML/JSON specs take no input, so a case that declares one is
|
|
50
|
+
* refused rather than silently run without it.
|
|
51
|
+
*/
|
|
52
|
+
export function localFlowEvalExecutor(options: LocalFlowEvalExecutorOptions = {}): FlowEvalExecutor {
|
|
53
|
+
const dataDir = resolve(options.dataDir ?? '.relayflowd');
|
|
54
|
+
return async ({ flow, input, signal }) => {
|
|
55
|
+
if (!('path' in flow) || typeof flow.path !== 'string') {
|
|
56
|
+
throw new Error('The local executor runs flow files; write the spec to a file or use a custom executor.');
|
|
57
|
+
}
|
|
58
|
+
const authored = isAuthoredFlowPath(flow.path);
|
|
59
|
+
if (!authored && input !== undefined) throw new Error('Declarative flows take no input; remove `input` from the case.');
|
|
60
|
+
const scratch = authored ? await mkdtemp(join(tmpdir(), 'flows-eval-')) : undefined;
|
|
61
|
+
try {
|
|
62
|
+
const args = [options.cliPath ?? defaultCliPath(), 'run', '--json', '--no-observer-link', '--data-dir', dataDir];
|
|
63
|
+
if (options.daemon?.spawn === false) args.push('--no-spawn');
|
|
64
|
+
if (options.localAgent) args.push('--local-agent');
|
|
65
|
+
if (options.agentCapacity !== undefined) args.push('--agent-capacity', String(options.agentCapacity));
|
|
66
|
+
args.push(resolve(flow.path));
|
|
67
|
+
if (scratch !== undefined) {
|
|
68
|
+
// A file, never inline: an inline argument that happens to name a file
|
|
69
|
+
// in the working directory would be read as that file.
|
|
70
|
+
const inputPath = join(scratch, 'input.json');
|
|
71
|
+
await writeFile(inputPath, canonicalize(input ?? {}));
|
|
72
|
+
args.push('--input', inputPath);
|
|
73
|
+
}
|
|
74
|
+
const execution = await runCliProcess(args, options.cwd ?? dirname(resolve(flow.path)), signal);
|
|
75
|
+
const run = localRunOutcome(execution);
|
|
76
|
+
if (run.runId !== undefined && run.completionReason !== 'refused') {
|
|
77
|
+
Object.assign(run, await localRunSpend(run.runId, dataDir));
|
|
78
|
+
}
|
|
79
|
+
return run;
|
|
80
|
+
} finally {
|
|
81
|
+
if (scratch !== undefined) await rm(scratch, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function defaultCliPath(): string {
|
|
87
|
+
// Built: this file sits beside cli.js in dist/. From source (tests), use the build.
|
|
88
|
+
const sibling = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
89
|
+
return existsSync(sibling) ? sibling : fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runCliProcess(args: string[], cwd: string | undefined, signal: AbortSignal | undefined): Promise<RunExecution> {
|
|
93
|
+
return new Promise((resolvePromise, reject) => {
|
|
94
|
+
const child = spawn(process.execPath, args, {
|
|
95
|
+
...(cwd === undefined ? {} : { cwd }), stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
...(signal === undefined ? {} : { signal }),
|
|
97
|
+
});
|
|
98
|
+
let stdout = '';
|
|
99
|
+
let stderr = '';
|
|
100
|
+
child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; });
|
|
101
|
+
child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-4_000); });
|
|
102
|
+
child.once('error', reject);
|
|
103
|
+
child.once('close', (code) => {
|
|
104
|
+
const line = stdout.trim().split('\n').reverse().find(candidate => candidate.startsWith('{'));
|
|
105
|
+
let report: RunExecution['report'] | undefined;
|
|
106
|
+
try {
|
|
107
|
+
report = line === undefined ? undefined : JSON.parse(line) as RunExecution['report'];
|
|
108
|
+
} catch { /* reported below */ }
|
|
109
|
+
if (report === undefined || typeof report.ok !== 'boolean' || !Array.isArray(report.diagnostics)) {
|
|
110
|
+
reject(new Error(`flows run exited ${code} without a JSON report: ${stderr.trim() || stdout.trim() || '(no output)'}`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolvePromise({ exitCode: (code ?? 1) as RunExecution['exitCode'], report });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Map a local `RunExecution` onto the eval vocabulary. Exported for tests. */
|
|
119
|
+
export function localRunOutcome(execution: RunExecution): FlowEvalRun {
|
|
120
|
+
const { report } = execution;
|
|
121
|
+
// The root, not the failing child: a failed authored run reports the child
|
|
122
|
+
// that failed as `runId`, and spend must be read from the whole run.
|
|
123
|
+
const runId = report.rootRunId ?? report.runId;
|
|
124
|
+
return { ...(runId === undefined ? {} : { runId }), ...runReportOutcome(report, execution.exitCode) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Spend from the local journals.
|
|
129
|
+
*
|
|
130
|
+
* Summed from each step's own `step.completed` budget, never from a run's
|
|
131
|
+
* `budget_total`: an authored child run starts from the flow's `prior_spend`,
|
|
132
|
+
* so its total already includes every earlier child, and adding totals would
|
|
133
|
+
* count the same dollars repeatedly.
|
|
134
|
+
*
|
|
135
|
+
* An authored root runs each step as a child run with its own journal. The
|
|
136
|
+
* children are found in the root's `authored-steps` stream, which is written
|
|
137
|
+
* as each child opens and so survives a failed body, and in the root step's
|
|
138
|
+
* success output (`journalSteps`).
|
|
139
|
+
*/
|
|
140
|
+
export async function localRunSpend(runId: string, dataDir: string): Promise<Pick<FlowEvalRun, 'costUsd' | 'tokensIn' | 'tokensOut' | 'steps'>> {
|
|
141
|
+
try {
|
|
142
|
+
const spend: Spend = { dollars: 0, tokensIn: 0, tokensOut: 0 };
|
|
143
|
+
const steps: FlowEvalStep[] = [];
|
|
144
|
+
const visited = new Set<string>();
|
|
145
|
+
const queue: Array<{ runId: string; depth: number }> = [{ runId, depth: 0 }];
|
|
146
|
+
while (queue.length > 0) {
|
|
147
|
+
const next = queue.shift()!;
|
|
148
|
+
if (visited.has(next.runId)) continue;
|
|
149
|
+
visited.add(next.runId);
|
|
150
|
+
const read = await readJournal(next.runId, dataDir);
|
|
151
|
+
addSpend(spend, read.spend);
|
|
152
|
+
steps.push(...read.steps);
|
|
153
|
+
// Bounded like `f.dispatch` (depth <= 3).
|
|
154
|
+
if (next.depth < 3) for (const child of read.children) queue.push({ runId: child, depth: next.depth + 1 });
|
|
155
|
+
}
|
|
156
|
+
return { costUsd: spend.dollars === null ? null : roundUsd(spend.dollars), tokensIn: spend.tokensIn, tokensOut: spend.tokensOut, steps };
|
|
157
|
+
} catch {
|
|
158
|
+
// Spend is evidence, not the verdict: an unreadable journal leaves cost unknown.
|
|
159
|
+
return { costUsd: null, tokensIn: null, tokensOut: null };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface Spend { dollars: number | null; tokensIn: number; tokensOut: number }
|
|
164
|
+
|
|
165
|
+
const AUTHORED_STEP_RECORD = 'relayflows.authored-step.v1';
|
|
166
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
|
167
|
+
|
|
168
|
+
async function readJournal(runId: string, dataDir: string): Promise<{ spend: Spend; steps: FlowEvalStep[]; children: string[] }> {
|
|
169
|
+
const spend: Spend = { dollars: 0, tokensIn: 0, tokensOut: 0 };
|
|
170
|
+
const steps = new Map<string, FlowEvalStep & { startedAt?: number }>();
|
|
171
|
+
const children = new Set<string>();
|
|
172
|
+
for await (const event of walkJournal(runId, dataDir)) {
|
|
173
|
+
const payload = (event.payload !== null && typeof event.payload === 'object' ? event.payload : {}) as Record<string, unknown>;
|
|
174
|
+
if (event.entry_type === 'step.attempt.started' && event.step_id !== null) {
|
|
175
|
+
const step = steps.get(event.step_id) ?? { id: event.step_id, status: 'running' };
|
|
176
|
+
step.startedAt ??= event.at_ms;
|
|
177
|
+
steps.set(event.step_id, step);
|
|
178
|
+
} else if (event.entry_type === 'step.completed' && event.step_id !== null) {
|
|
179
|
+
const budget = readBudget(payload['budget']);
|
|
180
|
+
const step = steps.get(event.step_id) ?? { id: event.step_id, status: 'done' };
|
|
181
|
+
step.status = typeof payload['completionReason'] === 'string' ? payload['completionReason'] : 'done';
|
|
182
|
+
if (step.startedAt !== undefined) step.durationMs = event.at_ms - step.startedAt;
|
|
183
|
+
if (budget !== undefined) {
|
|
184
|
+
step.costUsd = budget.dollars;
|
|
185
|
+
addSpend(spend, budget);
|
|
186
|
+
}
|
|
187
|
+
steps.set(event.step_id, step);
|
|
188
|
+
for (const child of journalStepRunIds(payload['output'])) children.add(child);
|
|
189
|
+
} else if (event.entry_type === 'stream.appended') {
|
|
190
|
+
for (const child of authoredStepRunIds(payload)) children.add(child);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
children.delete(runId);
|
|
194
|
+
return { spend, steps: [...steps.values()].map(({ startedAt: _startedAt, ...step }) => step), children: [...children] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function journalStepRunIds(output: unknown): string[] {
|
|
198
|
+
if (output === null || typeof output !== 'object') return [];
|
|
199
|
+
const journalSteps = (output as Record<string, unknown>)['journalSteps'];
|
|
200
|
+
if (!Array.isArray(journalSteps)) return [];
|
|
201
|
+
return journalSteps.flatMap(step => {
|
|
202
|
+
const id = step !== null && typeof step === 'object' ? (step as Record<string, unknown>)['runId'] : undefined;
|
|
203
|
+
return typeof id === 'string' && RUN_ID.test(id) ? [id] : [];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Child run ids from an `authored-steps` index record, wherever the stream envelope nests it. */
|
|
208
|
+
function authoredStepRunIds(value: unknown, depth = 0): string[] {
|
|
209
|
+
if (value === null || typeof value !== 'object' || depth > 4) return [];
|
|
210
|
+
const record = value as Record<string, unknown>;
|
|
211
|
+
if (record['index'] === AUTHORED_STEP_RECORD) {
|
|
212
|
+
return typeof record['runId'] === 'string' && RUN_ID.test(record['runId']) ? [record['runId']] : [];
|
|
213
|
+
}
|
|
214
|
+
return Object.values(record).flatMap(nested => authoredStepRunIds(nested, depth + 1));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function addSpend(into: Spend, add: Spend): void {
|
|
218
|
+
into.tokensIn += add.tokensIn;
|
|
219
|
+
into.tokensOut += add.tokensOut;
|
|
220
|
+
into.dollars = into.dollars === null || add.dollars === null ? null : into.dollars + add.dollars;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function roundUsd(value: number): number {
|
|
224
|
+
return Math.round(value * 1e6) / 1e6;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readBudget(value: unknown): { dollars: number | null; tokensIn: number; tokensOut: number } | undefined {
|
|
228
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
229
|
+
const budget = value as Record<string, unknown>;
|
|
230
|
+
const dollars = typeof budget['dollars'] === 'string' ? Number(budget['dollars']) : NaN;
|
|
231
|
+
return {
|
|
232
|
+
dollars: budget['dollars_unmetered'] === true || !Number.isFinite(dollars) || dollars < 0 ? null : dollars,
|
|
233
|
+
tokensIn: typeof budget['tokens_in'] === 'number' ? budget['tokens_in'] : 0,
|
|
234
|
+
tokensOut: typeof budget['tokens_out'] === 'number' ? budget['tokens_out'] : 0,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// One vocabulary for a finished run's verdict, whoever ran it: a local
|
|
2
|
+
// `flows run` report and a hosted run's stored result are the same RunReport
|
|
3
|
+
// shape, and must map to the same completion reason, or a suite would pass
|
|
4
|
+
// locally and fail on Cloud for the same flow behavior.
|
|
5
|
+
|
|
6
|
+
import type { FlowEvalRun } from './flow-eval-report.js';
|
|
7
|
+
|
|
8
|
+
/** The fields of a `RunReport` the verdict reads. */
|
|
9
|
+
export interface RunReportVerdict {
|
|
10
|
+
ok?: unknown;
|
|
11
|
+
status?: unknown;
|
|
12
|
+
completionReason?: unknown;
|
|
13
|
+
completionDetail?: unknown;
|
|
14
|
+
parkCause?: unknown;
|
|
15
|
+
diagnostics?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map a run report onto the eval vocabulary.
|
|
20
|
+
*
|
|
21
|
+
* - `declined`: the report says `success` with a `run_declined` diagnostic —
|
|
22
|
+
* the body chose not to act. Scoring it as `success` would let a flow that
|
|
23
|
+
* did nothing pass a suite expecting it to act.
|
|
24
|
+
* - `worker_unavailable` vs `needs_human`: both park (exit 3); only the second
|
|
25
|
+
* reached a human wait.
|
|
26
|
+
* - `refused`: preflight refused the run (exit 2); nothing executed.
|
|
27
|
+
*/
|
|
28
|
+
export function runReportOutcome(report: RunReportVerdict, exitCode?: number): Pick<FlowEvalRun, 'completionReason' | 'completionDetail'> {
|
|
29
|
+
const diagnostics = Array.isArray(report.diagnostics)
|
|
30
|
+
? report.diagnostics.filter((d): d is Record<string, unknown> => d !== null && typeof d === 'object') : [];
|
|
31
|
+
const message = diagnostics.find(d => ['failure', 'refusal', 'parked'].includes(d['severity'] as string))?.['message'];
|
|
32
|
+
const detail = typeof report.completionDetail === 'string' ? report.completionDetail
|
|
33
|
+
: typeof message === 'string' ? message : undefined;
|
|
34
|
+
let completionReason: string;
|
|
35
|
+
if (exitCode === 2) completionReason = 'refused';
|
|
36
|
+
else if (report.status === 'parked' || exitCode === 3) {
|
|
37
|
+
completionReason = report.parkCause === 'worker_unavailable' ? 'worker_unavailable' : 'needs_human';
|
|
38
|
+
} else if (report.ok === true && diagnostics.some(d => d['kind'] === 'run_declined')) completionReason = 'declined';
|
|
39
|
+
else if (report.ok === true) completionReason = 'success';
|
|
40
|
+
else if (typeof report.completionReason === 'string') completionReason = report.completionReason;
|
|
41
|
+
else completionReason = report.status === 'suspended' ? 'suspended' : 'failed';
|
|
42
|
+
return { completionReason, ...(detail === undefined ? {} : { completionDetail: detail }) };
|
|
43
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The data a flow evaluation exchanges: what an executor returns for one run,
|
|
2
|
+
// and the report the evaluation produces. Types only.
|
|
3
|
+
|
|
4
|
+
import type { FlowEvalCase } from './flow-eval-suite.js';
|
|
5
|
+
import type { JsonValue } from './json-value.js';
|
|
6
|
+
import type { FlowEvalTarget } from './flow-eval-version.js';
|
|
7
|
+
|
|
8
|
+
export const FLOW_EVAL_REPORT_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
/** One step of an executed case, as far as the executor can say. */
|
|
11
|
+
export interface FlowEvalStep {
|
|
12
|
+
id: string;
|
|
13
|
+
status: string;
|
|
14
|
+
durationMs?: number | null;
|
|
15
|
+
costUsd?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What an executor reports for one execution. */
|
|
19
|
+
export interface FlowEvalRun {
|
|
20
|
+
runId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `success`, `declined`, `step_failed`, `budget_exceeded`, `canceled`,
|
|
23
|
+
* `needs_human`, `worker_unavailable` (parked with nothing attached to run
|
|
24
|
+
* it), or `refused` (preflight refused the run).
|
|
25
|
+
*/
|
|
26
|
+
completionReason: string;
|
|
27
|
+
completionDetail?: string;
|
|
28
|
+
/** `null` when the executor cannot attest a dollar cost. */
|
|
29
|
+
costUsd?: number | null;
|
|
30
|
+
tokensIn?: number | null;
|
|
31
|
+
tokensOut?: number | null;
|
|
32
|
+
steps?: FlowEvalStep[];
|
|
33
|
+
/** Executor-supplied numeric metrics, merged into the case's metrics. */
|
|
34
|
+
metrics?: Record<string, number>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FlowEvalExecutionRequest {
|
|
38
|
+
/** The sealed snapshot to execute (a path inside it, or the in-memory spec), never the working tree. */
|
|
39
|
+
flow: FlowEvalTarget;
|
|
40
|
+
caseId: string;
|
|
41
|
+
/** Absent when the case declares no input. */
|
|
42
|
+
input?: JsonValue;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Executes the flow once. Throwing marks the case `error`, never `pass`. */
|
|
47
|
+
export type FlowEvalExecutor = (request: FlowEvalExecutionRequest) => Promise<FlowEvalRun>;
|
|
48
|
+
|
|
49
|
+
export interface FlowEvalScore {
|
|
50
|
+
/** Numeric value recorded under the scorer's name in the case's metrics. */
|
|
51
|
+
value?: number;
|
|
52
|
+
/** False fails the case. Absent means the scorer only measures. */
|
|
53
|
+
pass?: boolean;
|
|
54
|
+
detail?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A caller-defined metric over one executed case. */
|
|
58
|
+
export interface FlowEvalScorer {
|
|
59
|
+
name: string;
|
|
60
|
+
score(run: FlowEvalRun, testCase: FlowEvalCase): FlowEvalScore | Promise<FlowEvalScore>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type FlowEvalOutcome = 'pass' | 'fail' | 'error';
|
|
64
|
+
|
|
65
|
+
export interface FlowEvalCaseResult {
|
|
66
|
+
id: string;
|
|
67
|
+
outcome: FlowEvalOutcome;
|
|
68
|
+
runId?: string;
|
|
69
|
+
completionReason?: string;
|
|
70
|
+
latencyMs: number;
|
|
71
|
+
costUsd: number | null;
|
|
72
|
+
tokensIn: number | null;
|
|
73
|
+
tokensOut: number | null;
|
|
74
|
+
metrics: Record<string, number>;
|
|
75
|
+
steps?: FlowEvalStep[];
|
|
76
|
+
/** Why the case did not pass; empty when it did. */
|
|
77
|
+
failures: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FlowEvalReport {
|
|
81
|
+
schemaVersion: typeof FLOW_EVAL_REPORT_SCHEMA_VERSION;
|
|
82
|
+
kind: 'flows.eval.report';
|
|
83
|
+
/** `files`: the local sources the version covers, relative to the entry's directory. */
|
|
84
|
+
flow: { path?: string; version: string; files: string[] };
|
|
85
|
+
suite: { name: string; sha256: string; cases: number };
|
|
86
|
+
executor: string;
|
|
87
|
+
startedAt: string;
|
|
88
|
+
finishedAt: string;
|
|
89
|
+
cases: FlowEvalCaseResult[];
|
|
90
|
+
summary: {
|
|
91
|
+
total: number;
|
|
92
|
+
passed: number;
|
|
93
|
+
failed: number;
|
|
94
|
+
errored: number;
|
|
95
|
+
passRate: number;
|
|
96
|
+
/** `null` when any case's cost is unknown. */
|
|
97
|
+
totalCostUsd: number | null;
|
|
98
|
+
latencyMs: { p50: number; p95: number; max: number };
|
|
99
|
+
};
|
|
100
|
+
/** The deploy decision: pass only when every threshold and regression check holds. */
|
|
101
|
+
gate: { pass: boolean; reasons: string[]; regressions: string[] };
|
|
102
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The frozen input set of a flow evaluation: its shape, strict parsing of
|
|
2
|
+
// untrusted suite JSON, and its identity hash.
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { canonicalize } from './canonical.js';
|
|
7
|
+
import { FlowEvalError } from './flow-eval-error.js';
|
|
8
|
+
import { snapshotJsonValue, type JsonSnapshotLimits, type JsonValue } from './json-value.js';
|
|
9
|
+
import { MAX_DIRECT_INPUT_BYTES } from './direct-input.js';
|
|
10
|
+
import { deepFreeze } from './flow-eval-version.js';
|
|
11
|
+
|
|
12
|
+
export const MAX_FLOW_EVAL_CASES = 1_000;
|
|
13
|
+
|
|
14
|
+
/** One case's input: no larger than `flows run --input` accepts. */
|
|
15
|
+
const INPUT_LIMITS: JsonSnapshotLimits = Object.freeze({ maxDepth: 64, maxNodes: 262_144, maxBytes: MAX_DIRECT_INPUT_BYTES });
|
|
16
|
+
|
|
17
|
+
/** What a case must observe to pass. Every field present must hold. */
|
|
18
|
+
export interface FlowEvalExpectation {
|
|
19
|
+
/**
|
|
20
|
+
* The run's outcome. Defaults to `success`. `declined` is a successful run
|
|
21
|
+
* whose body chose not to act; `needs_human` is a parked run.
|
|
22
|
+
*/
|
|
23
|
+
completionReason?: string;
|
|
24
|
+
/** Substrings the run's completion detail must contain. */
|
|
25
|
+
detailIncludes?: string[];
|
|
26
|
+
/** A regular expression (JavaScript syntax) the completion detail must match. */
|
|
27
|
+
detailMatches?: string;
|
|
28
|
+
/** Upper bound on this case's wall-clock latency. */
|
|
29
|
+
maxLatencyMs?: number;
|
|
30
|
+
/** Upper bound on this case's reported cost; an unknown cost fails it. */
|
|
31
|
+
maxCostUsd?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FlowEvalCase {
|
|
35
|
+
/** Stable identity across suite revisions; unique within the suite. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** The run's input. Required by authored `.flow.ts`; refused by declarative specs on Cloud. */
|
|
38
|
+
input?: JsonValue;
|
|
39
|
+
expect?: FlowEvalExpectation;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FlowEvalThresholds {
|
|
43
|
+
/** Fraction of cases that must pass, 0..1. Defaults to 1: every case. */
|
|
44
|
+
minPassRate?: number;
|
|
45
|
+
/** Ceiling on the summed cost of every case; an unknown cost fails it. */
|
|
46
|
+
maxTotalCostUsd?: number;
|
|
47
|
+
/** Ceiling on the 95th-percentile case latency. */
|
|
48
|
+
maxP95LatencyMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FlowEvalSuite {
|
|
52
|
+
name: string;
|
|
53
|
+
cases: FlowEvalCase[];
|
|
54
|
+
thresholds?: FlowEvalThresholds;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Validate untrusted suite data (parsed JSON) into a frozen suite. */
|
|
58
|
+
export function parseFlowEvalSuite(value: unknown): FlowEvalSuite {
|
|
59
|
+
const fail = (message: string): never => { throw new FlowEvalError('invalid_suite', message); };
|
|
60
|
+
if (!isRecord(value)) fail('Suite must be a JSON object.');
|
|
61
|
+
const raw = value as Record<string, unknown>;
|
|
62
|
+
allowOnly(raw, ['name', 'cases', 'thresholds'], 'suite', fail);
|
|
63
|
+
if (typeof raw.name !== 'string' || raw.name.trim() === '') fail('Suite needs a nonempty `name`.');
|
|
64
|
+
if (!Array.isArray(raw.cases) || raw.cases.length === 0) fail('Suite needs a nonempty `cases` array.');
|
|
65
|
+
const rawCases = raw.cases as unknown[];
|
|
66
|
+
if (rawCases.length > MAX_FLOW_EVAL_CASES) fail(`Suite has ${rawCases.length} cases; the limit is ${MAX_FLOW_EVAL_CASES}.`);
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
const cases = rawCases.map((entry, index): FlowEvalCase => {
|
|
69
|
+
const at = `cases[${index}]`;
|
|
70
|
+
if (!isRecord(entry)) fail(`${at} must be an object.`);
|
|
71
|
+
const item = entry as Record<string, unknown>;
|
|
72
|
+
allowOnly(item, ['id', 'input', 'expect'], at, fail);
|
|
73
|
+
if (typeof item.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(item.id)) {
|
|
74
|
+
fail(`${at}.id must be 1-128 characters of letters, digits, '.', '_', ':' or '-'.`);
|
|
75
|
+
}
|
|
76
|
+
const id = item.id as string;
|
|
77
|
+
if (seen.has(id)) fail(`${at}.id "${id}" is not unique.`);
|
|
78
|
+
seen.add(id);
|
|
79
|
+
const testCase: FlowEvalCase = { id };
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(item, 'input')) {
|
|
81
|
+
try {
|
|
82
|
+
testCase.input = snapshotJsonValue(item.input, `${at}.input`, INPUT_LIMITS);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
fail(`${at}.input is not JSON data: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (item.expect !== undefined) testCase.expect = parseExpectation(item.expect, `${at}.expect`, fail);
|
|
88
|
+
return testCase;
|
|
89
|
+
});
|
|
90
|
+
const suite: FlowEvalSuite = { name: raw.name as string, cases };
|
|
91
|
+
if (raw.thresholds !== undefined) {
|
|
92
|
+
if (!isRecord(raw.thresholds)) fail('`thresholds` must be an object.');
|
|
93
|
+
const t = raw.thresholds as Record<string, unknown>;
|
|
94
|
+
allowOnly(t, ['minPassRate', 'maxTotalCostUsd', 'maxP95LatencyMs'], 'thresholds', fail);
|
|
95
|
+
const thresholds: FlowEvalThresholds = {};
|
|
96
|
+
if (t.minPassRate !== undefined) {
|
|
97
|
+
if (typeof t.minPassRate !== 'number' || !(t.minPassRate >= 0 && t.minPassRate <= 1)) fail('`thresholds.minPassRate` must be a number from 0 to 1.');
|
|
98
|
+
thresholds.minPassRate = t.minPassRate as number;
|
|
99
|
+
}
|
|
100
|
+
if (t.maxTotalCostUsd !== undefined) thresholds.maxTotalCostUsd = nonNegative(t.maxTotalCostUsd, 'thresholds.maxTotalCostUsd', fail);
|
|
101
|
+
if (t.maxP95LatencyMs !== undefined) thresholds.maxP95LatencyMs = nonNegative(t.maxP95LatencyMs, 'thresholds.maxP95LatencyMs', fail);
|
|
102
|
+
suite.thresholds = thresholds;
|
|
103
|
+
}
|
|
104
|
+
// Frozen in fact, not just by name: a scorer receives these cases, and must
|
|
105
|
+
// not be able to rename one or loosen an expectation for the cases after it.
|
|
106
|
+
return deepFreeze(suite);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read and validate a suite file (JSON). */
|
|
110
|
+
export async function loadFlowEvalSuite(path: string): Promise<FlowEvalSuite> {
|
|
111
|
+
let text: string;
|
|
112
|
+
try {
|
|
113
|
+
text = await readFile(path, 'utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
throw new FlowEvalError('invalid_suite', `Suite file "${path}" is not readable.`);
|
|
116
|
+
}
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(text);
|
|
120
|
+
} catch {
|
|
121
|
+
throw new FlowEvalError('invalid_suite', `Suite file "${path}" is not valid JSON.`);
|
|
122
|
+
}
|
|
123
|
+
return parseFlowEvalSuite(parsed);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The suite's identity: sha256 of its canonical JSON. */
|
|
127
|
+
export function flowEvalSuiteSha256(suite: FlowEvalSuite): string {
|
|
128
|
+
return `sha256:${createHash('sha256').update(canonicalize(suite as unknown as JsonValue)).digest('hex')}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseExpectation(value: unknown, at: string, fail: (message: string) => never): FlowEvalExpectation {
|
|
132
|
+
if (!isRecord(value)) fail(`${at} must be an object.`);
|
|
133
|
+
const raw = value as Record<string, unknown>;
|
|
134
|
+
allowOnly(raw, ['completionReason', 'detailIncludes', 'detailMatches', 'maxLatencyMs', 'maxCostUsd'], at, fail);
|
|
135
|
+
const expect: FlowEvalExpectation = {};
|
|
136
|
+
if (raw.completionReason !== undefined) {
|
|
137
|
+
if (typeof raw.completionReason !== 'string' || raw.completionReason === '') fail(`${at}.completionReason must be a nonempty string.`);
|
|
138
|
+
expect.completionReason = raw.completionReason as string;
|
|
139
|
+
}
|
|
140
|
+
if (raw.detailIncludes !== undefined) {
|
|
141
|
+
if (!Array.isArray(raw.detailIncludes) || raw.detailIncludes.some(item => typeof item !== 'string')) fail(`${at}.detailIncludes must be an array of strings.`);
|
|
142
|
+
expect.detailIncludes = [...(raw.detailIncludes as string[])];
|
|
143
|
+
}
|
|
144
|
+
if (raw.detailMatches !== undefined) {
|
|
145
|
+
if (typeof raw.detailMatches !== 'string') fail(`${at}.detailMatches must be a string.`);
|
|
146
|
+
try {
|
|
147
|
+
new RegExp(raw.detailMatches as string, 'u');
|
|
148
|
+
} catch {
|
|
149
|
+
fail(`${at}.detailMatches is not a valid regular expression.`);
|
|
150
|
+
}
|
|
151
|
+
expect.detailMatches = raw.detailMatches as string;
|
|
152
|
+
}
|
|
153
|
+
if (raw.maxLatencyMs !== undefined) expect.maxLatencyMs = nonNegative(raw.maxLatencyMs, `${at}.maxLatencyMs`, fail);
|
|
154
|
+
if (raw.maxCostUsd !== undefined) expect.maxCostUsd = nonNegative(raw.maxCostUsd, `${at}.maxCostUsd`, fail);
|
|
155
|
+
return expect;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function allowOnly(raw: Record<string, unknown>, keys: readonly string[], at: string, fail: (message: string) => never): void {
|
|
159
|
+
for (const key of Object.keys(raw)) if (!keys.includes(key)) fail(`${at} has unknown field "${key}".`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nonNegative(value: unknown, at: string, fail: (message: string) => never): number {
|
|
163
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) fail(`\`${at}\` must be a non-negative number.`);
|
|
164
|
+
return value as number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
168
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
169
|
+
}
|