@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,316 @@
|
|
|
1
|
+
// What a flow evaluation judges, by content: the flow version.
|
|
2
|
+
//
|
|
3
|
+
// For a flow file the version is the sha256 of a manifest of every local
|
|
4
|
+
// source the run reads, not of the entry file alone. Editing a helper module,
|
|
5
|
+
// a `use`d flow, or the project's extension lock changes what executes, so it
|
|
6
|
+
// must change the version too — otherwise `expectVersion` would accept code it
|
|
7
|
+
// never judged. Packages under node_modules are not part of the manifest; they
|
|
8
|
+
// are pinned by the project's own lockfile, which is. An evaluation executes a
|
|
9
|
+
// sealed copy of exactly these bytes (`sealFlowEvalSnapshot`).
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync, statSync } from 'node:fs';
|
|
13
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { canonicalize, specHash } from './canonical.js';
|
|
17
|
+
import { compileSpec, toKernelSpec } from './compile.js';
|
|
18
|
+
import { FlowEvalError } from './flow-eval-error.js';
|
|
19
|
+
import { parsePluginLock, type PluginLock } from './plugin-lock.js';
|
|
20
|
+
import { pluginStoreDirectory } from './plugin-store.js';
|
|
21
|
+
import type { FlowSpec } from './spec.js';
|
|
22
|
+
|
|
23
|
+
/** A flow source path (`.flow.ts`, `.flow.yaml`, spec JSON) or an in-memory spec. */
|
|
24
|
+
export type FlowEvalTarget = { path: string } | FlowSpec;
|
|
25
|
+
|
|
26
|
+
export interface FlowEvalVersion {
|
|
27
|
+
/** `sha256:<hex>` over the manifest (or the kernel spec hash for an in-memory spec). */
|
|
28
|
+
version: string;
|
|
29
|
+
/** Manifest paths, relative to the entry's directory; empty for an in-memory spec. */
|
|
30
|
+
files: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Most local source files one version may span; past it the evaluation is refused, not truncated. */
|
|
34
|
+
export const MAX_FLOW_EVAL_SOURCES = 2_000;
|
|
35
|
+
|
|
36
|
+
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json'];
|
|
37
|
+
const PROJECT_FILES = ['package.json', 'flows.json', 'flows.lock.json', 'package-lock.json', 'bun.lock', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
38
|
+
|
|
39
|
+
export function isPathTarget(flow: FlowEvalTarget): flow is { path: string } {
|
|
40
|
+
return typeof (flow as { path?: unknown }).path === 'string' && !('steps' in flow);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The version string alone. */
|
|
44
|
+
export async function flowEvalVersion(flow: FlowEvalTarget): Promise<string> {
|
|
45
|
+
return (await flowEvalSources(flow)).version;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The version and the files it covers. */
|
|
49
|
+
export async function flowEvalSources(flow: FlowEvalTarget): Promise<FlowEvalVersion> {
|
|
50
|
+
if (!isPathTarget(flow)) return { version: `sha256:${specHash(toKernelSpec(compileSpec(flow)))}`, files: [] };
|
|
51
|
+
const { version, files } = await collectSources(flow.path);
|
|
52
|
+
return { version, files: files.map(file => file.path) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The sealed bytes a version names, from which every case gets its own copy. */
|
|
56
|
+
export interface FlowEvalSnapshot extends FlowEvalVersion {
|
|
57
|
+
/**
|
|
58
|
+
* A fresh, read-only copy for one case: its own directory, files and
|
|
59
|
+
* directories without write permission, written from the buffers the
|
|
60
|
+
* version hashed. An in-memory spec yields a deep-frozen clone.
|
|
61
|
+
*/
|
|
62
|
+
materialize(): Promise<{ flow: FlowEvalTarget; dispose(): Promise<void> }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Seal the flow before any case runs, and execute only copies of the seal.
|
|
67
|
+
*
|
|
68
|
+
* Hashing the working tree and then running it would let the tree change
|
|
69
|
+
* between the two — an edit made after a check and restored before the next
|
|
70
|
+
* would run bytes the report never named. The sources are read once, hashed,
|
|
71
|
+
* and held in memory; each case then runs a fresh read-only copy written from
|
|
72
|
+
* those buffers, so nothing a case does to its copy reaches the next case. A
|
|
73
|
+
* local module the manifest could not see (a computed
|
|
74
|
+
* `import(\`./rules/${kind}.js\`)`, a file read at runtime) is simply absent
|
|
75
|
+
* from the copy, so such a run fails instead of executing unjudged code.
|
|
76
|
+
*
|
|
77
|
+
* Trust boundary: evaluated code runs as the caller, so it can still reach
|
|
78
|
+
* anything the caller can — including `node_modules`, which is linked rather
|
|
79
|
+
* than copied and pinned by the lockfile in the manifest. Containing hostile
|
|
80
|
+
* code needs a sandbox; this seal makes sure the code that runs is the code
|
|
81
|
+
* the version names.
|
|
82
|
+
*/
|
|
83
|
+
export async function sealFlowEvalSnapshot(flow: FlowEvalTarget): Promise<FlowEvalSnapshot> {
|
|
84
|
+
if (!isPathTarget(flow)) {
|
|
85
|
+
// Hash and execute the same detached copy: a caller mutating its object
|
|
86
|
+
// later cannot change what the remaining cases run.
|
|
87
|
+
const frozen = deepFreeze(structuredClone(flow));
|
|
88
|
+
return { ...(await flowEvalSources(frozen)), materialize: async () => ({ flow: frozen, dispose: async () => {} }) };
|
|
89
|
+
}
|
|
90
|
+
const sources = await collectSources(flow.path);
|
|
91
|
+
// Every node_modules Node would consult from the entry, at the same relative
|
|
92
|
+
// place: a nested package resolves some dependencies from higher levels.
|
|
93
|
+
const modules = ancestorNodeModules(dirname(sources.entry));
|
|
94
|
+
const root = commonDirectory([...sources.absolute.map(file => file.path), ...modules.map(dir => join(dir, '.'))]);
|
|
95
|
+
return {
|
|
96
|
+
version: sources.version,
|
|
97
|
+
files: sources.files.map(file => file.path),
|
|
98
|
+
materialize: async () => {
|
|
99
|
+
const directory = await mkdtemp(join(tmpdir(), 'flows-eval-snapshot-'));
|
|
100
|
+
const dispose = async (): Promise<void> => {
|
|
101
|
+
await makeWritable(directory);
|
|
102
|
+
await rm(directory, { recursive: true, force: true });
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
for (const file of sources.absolute) {
|
|
106
|
+
const target = join(directory, relative(root, file.path));
|
|
107
|
+
await mkdir(dirname(target), { recursive: true });
|
|
108
|
+
await writeFile(target, file.bytes, { mode: 0o444 });
|
|
109
|
+
}
|
|
110
|
+
for (const dir of modules) {
|
|
111
|
+
const link = join(directory, relative(root, dir));
|
|
112
|
+
await mkdir(dirname(link), { recursive: true });
|
|
113
|
+
await symlink(dir, link, 'dir');
|
|
114
|
+
}
|
|
115
|
+
await makeReadOnly(directory);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await dispose();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
return { flow: { path: join(directory, relative(root, sources.entry)) }, dispose };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function makeReadOnly(directory: string): Promise<void> {
|
|
126
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
127
|
+
if (entry.isDirectory()) await makeReadOnly(join(directory, entry.name));
|
|
128
|
+
}
|
|
129
|
+
await chmod(directory, 0o555);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function makeWritable(directory: string): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
await chmod(directory, 0o755);
|
|
135
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
136
|
+
if (entry.isDirectory()) await makeWritable(join(directory, entry.name));
|
|
137
|
+
}
|
|
138
|
+
} catch { /* already gone */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function deepFreeze<T>(value: T): T {
|
|
142
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
143
|
+
Object.freeze(value);
|
|
144
|
+
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface CollectedSources {
|
|
150
|
+
entry: string;
|
|
151
|
+
version: string;
|
|
152
|
+
files: Array<{ path: string; sha256: string }>;
|
|
153
|
+
absolute: Array<{ path: string; bytes: Buffer }>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function collectSources(path: string): Promise<CollectedSources> {
|
|
157
|
+
let entry: string;
|
|
158
|
+
try {
|
|
159
|
+
entry = await realpath(path);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new FlowEvalError('unreadable_flow', `Flow "${path}" is not readable.`);
|
|
162
|
+
}
|
|
163
|
+
const root = dirname(entry);
|
|
164
|
+
const contents = new Map<string, Buffer>();
|
|
165
|
+
const missing = new Set<string>();
|
|
166
|
+
// One cap over everything sealed — sources, manifests, extension payloads —
|
|
167
|
+
// so no category can grow a version past it.
|
|
168
|
+
const add = async (file: string): Promise<Buffer> => {
|
|
169
|
+
const known = contents.get(file);
|
|
170
|
+
if (known !== undefined) return known;
|
|
171
|
+
if (contents.size >= MAX_FLOW_EVAL_SOURCES) {
|
|
172
|
+
throw new FlowEvalError('unreadable_flow', `Flow "${path}" reaches more than ${MAX_FLOW_EVAL_SOURCES} local files.`);
|
|
173
|
+
}
|
|
174
|
+
let bytes: Buffer;
|
|
175
|
+
try {
|
|
176
|
+
bytes = await readFile(file);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new FlowEvalError('unreadable_flow', `Flow source "${file}" is not readable.`);
|
|
179
|
+
}
|
|
180
|
+
contents.set(file, bytes);
|
|
181
|
+
return bytes;
|
|
182
|
+
};
|
|
183
|
+
const pending = [entry];
|
|
184
|
+
while (pending.length > 0) {
|
|
185
|
+
const file = pending.pop()!;
|
|
186
|
+
if (contents.has(file)) continue;
|
|
187
|
+
const bytes = await add(file);
|
|
188
|
+
// The package scope that decides how this file loads (`"type"`), per file:
|
|
189
|
+
// a nested package.json can differ from the entry's.
|
|
190
|
+
const scope = nearestFile(dirname(file), 'package.json');
|
|
191
|
+
if (scope !== undefined) await add(scope);
|
|
192
|
+
if (!/\.(?:[mc]?[jt]s)$/u.test(file)) continue;
|
|
193
|
+
for (const specifier of localSpecifiers(bytes.toString('utf8'))) {
|
|
194
|
+
const targets = resolveLocal(dirname(file), specifier);
|
|
195
|
+
if (targets.length === 0) missing.add(`${relative(root, dirname(file)) || '.'}:${specifier}`);
|
|
196
|
+
for (const target of targets) if (!contents.has(target)) pending.push(target);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// The nearest of each: package.json decides module semantics, flows.json and
|
|
200
|
+
// its lock the extensions, and a package lockfile pins node_modules. Each is
|
|
201
|
+
// found by walking up on its own, so a lockfile beside package.json (not
|
|
202
|
+
// beside flows.json) still pins the version.
|
|
203
|
+
for (const name of PROJECT_FILES) {
|
|
204
|
+
const file = nearestFile(root, name);
|
|
205
|
+
if (file !== undefined) await add(file);
|
|
206
|
+
}
|
|
207
|
+
// Locked flow extensions load from the project's content-addressed store,
|
|
208
|
+
// so their payloads are part of what runs: hash and seal them too.
|
|
209
|
+
const lockPath = nearestFile(root, 'flows.lock.json');
|
|
210
|
+
if (lockPath !== undefined) {
|
|
211
|
+
let lock: PluginLock;
|
|
212
|
+
try {
|
|
213
|
+
lock = parsePluginLock(JSON.parse(contents.get(lockPath)!.toString('utf8')));
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw new FlowEvalError('unreadable_flow', `flows.lock.json is not a valid plugin lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
for (const entry of lock.plugins) {
|
|
218
|
+
const store = pluginStoreDirectory(dirname(lockPath), entry.name, entry.digest);
|
|
219
|
+
if (!existsSync(store)) {
|
|
220
|
+
throw new FlowEvalError('unreadable_flow', `Locked extension ${entry.name} is not materialized at ${store}; run \`flows plugin verify\`.`);
|
|
221
|
+
}
|
|
222
|
+
for (const file of await listFiles(store)) await add(file);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const absolute = [...contents.entries()].map(([file, bytes]) => ({ path: file, bytes }));
|
|
226
|
+
const files = absolute
|
|
227
|
+
.map(file => ({ path: relative(root, file.path) || basename(file.path), sha256: sha256(file.bytes) }))
|
|
228
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
229
|
+
// An unresolved import is part of the identity too: creating that file later changes what runs.
|
|
230
|
+
const manifest = { entry: basename(entry), files, missing: [...missing].sort() };
|
|
231
|
+
return { entry, version: `sha256:${sha256(canonicalize(manifest))}`, files, absolute };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function listFiles(directory: string): Promise<string[]> {
|
|
235
|
+
const files: string[] = [];
|
|
236
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
237
|
+
const path = join(directory, entry.name);
|
|
238
|
+
if (entry.isDirectory()) files.push(...await listFiles(path));
|
|
239
|
+
else if (entry.isFile()) files.push(path);
|
|
240
|
+
}
|
|
241
|
+
return files;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function commonDirectory(paths: readonly string[]): string {
|
|
245
|
+
let common = dirname(paths[0]!);
|
|
246
|
+
for (const path of paths) {
|
|
247
|
+
while (common !== dirname(common) && path !== common && !path.startsWith(common + sep)) common = dirname(common);
|
|
248
|
+
}
|
|
249
|
+
return common;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function nearestFile(start: string, name: string): string | undefined {
|
|
253
|
+
for (let directory = start; ; directory = dirname(directory)) {
|
|
254
|
+
const candidate = join(directory, name);
|
|
255
|
+
if (existsSync(candidate)) return candidate;
|
|
256
|
+
if (dirname(directory) === directory) return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function ancestorNodeModules(start: string): string[] {
|
|
261
|
+
const found: string[] = [];
|
|
262
|
+
for (let directory = start; ; directory = dirname(directory)) {
|
|
263
|
+
const candidate = join(directory, 'node_modules');
|
|
264
|
+
try {
|
|
265
|
+
if (statSync(candidate).isDirectory()) found.push(candidate);
|
|
266
|
+
} catch { /* none here */ }
|
|
267
|
+
if (dirname(directory) === directory) return found;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Relative module specifiers and relative `.flow.ts` string literals (the
|
|
273
|
+
* `use:` header) in a source file. A lexical scan, deliberately: computing a
|
|
274
|
+
* version must never execute the flow.
|
|
275
|
+
*/
|
|
276
|
+
export function localSpecifiers(source: string): string[] {
|
|
277
|
+
const found = new Set<string>();
|
|
278
|
+
const patterns = [
|
|
279
|
+
/\b(?:import|export)\s+(?:type\s+)?[^'"`;]*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]/gu,
|
|
280
|
+
/\bimport\s*['"](\.{1,2}\/[^'"]+)['"]/gu,
|
|
281
|
+
/\b(?:import|require)\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/gu,
|
|
282
|
+
/['"](\.{1,2}\/[^'"\s]+\.flow\.ts)['"]/gu,
|
|
283
|
+
];
|
|
284
|
+
for (const pattern of patterns) for (const match of source.matchAll(pattern)) found.add(match[1]!);
|
|
285
|
+
return [...found];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The files a relative specifier can load. Where a TypeScript sibling and a
|
|
290
|
+
* JavaScript file both match (`./x.js` beside `./x.ts`), both are returned:
|
|
291
|
+
* which one a loader picks is the loader's business, and the version must not
|
|
292
|
+
* hash one while the run executes the other.
|
|
293
|
+
*/
|
|
294
|
+
function resolveLocal(from: string, specifier: string): string[] {
|
|
295
|
+
const base = resolve(from, specifier.replace(/[?#].*$/u, ''));
|
|
296
|
+
const isFile = (candidate: string): boolean => {
|
|
297
|
+
try {
|
|
298
|
+
return statSync(candidate).isFile();
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
// TypeScript's NodeNext convention: import './x.js' names ./x.ts.
|
|
304
|
+
const swapped = base.replace(/\.([mc]?)js$/u, '.$1ts');
|
|
305
|
+
const direct = [...new Set([swapped, base])].filter(isFile);
|
|
306
|
+
if (direct.length > 0) return direct;
|
|
307
|
+
for (const candidate of [
|
|
308
|
+
...SOURCE_EXTENSIONS.map(extension => base + extension),
|
|
309
|
+
...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`)),
|
|
310
|
+
]) if (isFile(candidate)) return [candidate];
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sha256(data: Buffer | string): string {
|
|
315
|
+
return createHash('sha256').update(data).digest('hex');
|
|
316
|
+
}
|
package/src/flow-eval.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Re-run evaluation: execute one exact flow version against a frozen set of
|
|
2
|
+
// input cases and score the result, so a deploy can be gated on it.
|
|
3
|
+
//
|
|
4
|
+
// `flows replay` reads a finished run's journal and never executes anything.
|
|
5
|
+
// This module is the other thing: it RE-EXECUTES the flow, once per case,
|
|
6
|
+
// through an executor (local daemon, Cloud, or one the caller supplies), and
|
|
7
|
+
// returns a report whose `gate.pass` is the deploy decision.
|
|
8
|
+
//
|
|
9
|
+
// It is a library first. `evaluateFlow` is what a step calls — from a
|
|
10
|
+
// deterministic step's script or an authored body — and `flows eval` is a thin
|
|
11
|
+
// CLI over it whose exit code is the same verdict (0 pass, 1 fail, 2 refused).
|
|
12
|
+
//
|
|
13
|
+
// Two things are frozen and recorded in every report, so a verdict names
|
|
14
|
+
// exactly what it judged: the flow version (flow-eval-version.ts) and the suite
|
|
15
|
+
// (flow-eval-suite.ts). Every case executes its own fresh, read-only copy
|
|
16
|
+
// written from the very bytes the version hashes, never the working tree, so
|
|
17
|
+
// neither an edit during the evaluation nor an earlier case can change what
|
|
18
|
+
// runs under the judged version.
|
|
19
|
+
// Aggregation and the verdict itself live in flow-eval-gate.ts.
|
|
20
|
+
|
|
21
|
+
import { FlowEvalError } from './flow-eval-error.js';
|
|
22
|
+
import { decideFlowEvalGate, parseFlowEvalBaseline, summarizeFlowEval } from './flow-eval-gate.js';
|
|
23
|
+
import {
|
|
24
|
+
FLOW_EVAL_REPORT_SCHEMA_VERSION,
|
|
25
|
+
type FlowEvalCaseResult, type FlowEvalExecutor, type FlowEvalReport, type FlowEvalRun, type FlowEvalScorer,
|
|
26
|
+
} from './flow-eval-report.js';
|
|
27
|
+
import { flowEvalSuiteSha256, parseFlowEvalSuite, type FlowEvalCase, type FlowEvalSuite } from './flow-eval-suite.js';
|
|
28
|
+
import { isPathTarget, sealFlowEvalSnapshot, type FlowEvalSnapshot, type FlowEvalTarget } from './flow-eval-version.js';
|
|
29
|
+
|
|
30
|
+
export { FlowEvalError, type FlowEvalErrorCode } from './flow-eval-error.js';
|
|
31
|
+
export { parseFlowEvalBaseline, type FlowEvalBaseline } from './flow-eval-gate.js';
|
|
32
|
+
export * from './flow-eval-report.js';
|
|
33
|
+
export {
|
|
34
|
+
MAX_FLOW_EVAL_CASES, flowEvalSuiteSha256, loadFlowEvalSuite, parseFlowEvalSuite,
|
|
35
|
+
type FlowEvalCase, type FlowEvalExpectation, type FlowEvalSuite, type FlowEvalThresholds,
|
|
36
|
+
} from './flow-eval-suite.js';
|
|
37
|
+
export {
|
|
38
|
+
MAX_FLOW_EVAL_SOURCES, flowEvalSources, flowEvalVersion, sealFlowEvalSnapshot,
|
|
39
|
+
type FlowEvalSnapshot, type FlowEvalTarget, type FlowEvalVersion,
|
|
40
|
+
} from './flow-eval-version.js';
|
|
41
|
+
|
|
42
|
+
export interface EvaluateFlowOptions {
|
|
43
|
+
flow: FlowEvalTarget;
|
|
44
|
+
suite: FlowEvalSuite;
|
|
45
|
+
executor: FlowEvalExecutor;
|
|
46
|
+
/** Recorded in the report; defaults to `custom`. */
|
|
47
|
+
executorName?: string;
|
|
48
|
+
scorers?: FlowEvalScorer[];
|
|
49
|
+
/** Cases executed at once. Defaults to 1, which keeps runs from contending. */
|
|
50
|
+
concurrency?: number;
|
|
51
|
+
/** Refuse unless the flow version is exactly this (`sha256:<hex>`). */
|
|
52
|
+
expectVersion?: string;
|
|
53
|
+
/** Refuse unless the suite hashes to exactly this (`sha256:<hex>`). */
|
|
54
|
+
expectSuiteSha256?: string;
|
|
55
|
+
/**
|
|
56
|
+
* A prior report on the same suite (parsed JSON is fine; it is validated
|
|
57
|
+
* before any case runs). Any case that passed there and does not pass here
|
|
58
|
+
* is a regression, and a regression fails the gate.
|
|
59
|
+
*/
|
|
60
|
+
baseline?: unknown;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
/** Clock injection for tests. */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Re-execute `flow` once per suite case and score the results.
|
|
68
|
+
*
|
|
69
|
+
* Never throws for a case that fails or errors: those are verdicts, recorded
|
|
70
|
+
* in the report. Throws `FlowEvalError` only when the evaluation itself is
|
|
71
|
+
* invalid (bad suite, baseline or options, or a pinned version/suite that does
|
|
72
|
+
* not match), and rethrows an abort.
|
|
73
|
+
*/
|
|
74
|
+
export async function evaluateFlow(options: EvaluateFlowOptions): Promise<FlowEvalReport> {
|
|
75
|
+
const now = options.now ?? Date.now;
|
|
76
|
+
const suite = parseFlowEvalSuite(options.suite);
|
|
77
|
+
const concurrency = options.concurrency ?? 1;
|
|
78
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) {
|
|
79
|
+
throw new FlowEvalError('invalid_options', 'concurrency must be an integer from 1 to 64.');
|
|
80
|
+
}
|
|
81
|
+
const names = new Set<string>();
|
|
82
|
+
for (const scorer of options.scorers ?? []) {
|
|
83
|
+
if (typeof scorer.name !== 'string' || scorer.name === '' || names.has(scorer.name)) {
|
|
84
|
+
throw new FlowEvalError('invalid_options', 'Each scorer needs a unique, nonempty name.');
|
|
85
|
+
}
|
|
86
|
+
names.add(scorer.name);
|
|
87
|
+
}
|
|
88
|
+
const suiteSha256 = flowEvalSuiteSha256(suite);
|
|
89
|
+
if (options.expectSuiteSha256 !== undefined && options.expectSuiteSha256 !== suiteSha256) {
|
|
90
|
+
throw new FlowEvalError('suite_mismatch', `Suite hashes to ${suiteSha256}; expected ${options.expectSuiteSha256}.`);
|
|
91
|
+
}
|
|
92
|
+
const baseline = options.baseline === undefined ? undefined : parseFlowEvalBaseline(options.baseline);
|
|
93
|
+
if (baseline !== undefined && baseline.suiteSha256 !== suiteSha256) {
|
|
94
|
+
throw new FlowEvalError('suite_mismatch', 'The baseline report was produced from a different suite; regressions would not be comparable.');
|
|
95
|
+
}
|
|
96
|
+
if (baseline !== undefined) {
|
|
97
|
+
// The hash is a claim; the cases are the evidence. A baseline missing a
|
|
98
|
+
// case would silently skip that case's regression check.
|
|
99
|
+
const ids = new Set(suite.cases.map(c => c.id));
|
|
100
|
+
const extra = [...baseline.outcomes.keys()].filter(id => !ids.has(id));
|
|
101
|
+
const absent = [...ids].filter(id => !baseline.outcomes.has(id));
|
|
102
|
+
if (extra.length > 0 || absent.length > 0) {
|
|
103
|
+
throw new FlowEvalError('invalid_baseline', 'The baseline report does not cover exactly this suite\'s cases'
|
|
104
|
+
+ (absent.length > 0 ? `; missing ${absent.join(', ')}` : '') + (extra.length > 0 ? `; unknown ${extra.join(', ')}` : '') + '.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const snapshot = await sealFlowEvalSnapshot(options.flow);
|
|
108
|
+
const { version } = snapshot;
|
|
109
|
+
if (options.expectVersion !== undefined && options.expectVersion !== version) {
|
|
110
|
+
throw new FlowEvalError('version_mismatch', `Flow version is ${version}; expected ${options.expectVersion}.`);
|
|
111
|
+
}
|
|
112
|
+
const startedAt = now();
|
|
113
|
+
const results: FlowEvalCaseResult[] = new Array(suite.cases.length);
|
|
114
|
+
let next = 0;
|
|
115
|
+
const worker = async (): Promise<void> => {
|
|
116
|
+
for (;;) {
|
|
117
|
+
options.signal?.throwIfAborted();
|
|
118
|
+
const index = next++;
|
|
119
|
+
if (index >= suite.cases.length) return;
|
|
120
|
+
results[index] = await runCase(suite.cases[index]!, options, now, snapshot);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, suite.cases.length) }, worker));
|
|
124
|
+
options.signal?.throwIfAborted();
|
|
125
|
+
|
|
126
|
+
const summary = summarizeFlowEval(results);
|
|
127
|
+
const gate = decideFlowEvalGate(suite, results, summary, baseline);
|
|
128
|
+
return {
|
|
129
|
+
schemaVersion: FLOW_EVAL_REPORT_SCHEMA_VERSION,
|
|
130
|
+
kind: 'flows.eval.report',
|
|
131
|
+
flow: { ...(isPathTarget(options.flow) ? { path: options.flow.path } : {}), version, files: snapshot.files },
|
|
132
|
+
suite: { name: suite.name, sha256: suiteSha256, cases: suite.cases.length },
|
|
133
|
+
executor: options.executorName ?? 'custom',
|
|
134
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
135
|
+
finishedAt: new Date(now()).toISOString(),
|
|
136
|
+
cases: results,
|
|
137
|
+
summary,
|
|
138
|
+
gate,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function runCase(
|
|
143
|
+
testCase: FlowEvalCase, options: EvaluateFlowOptions, now: () => number, snapshot: FlowEvalSnapshot,
|
|
144
|
+
): Promise<FlowEvalCaseResult> {
|
|
145
|
+
const copy = await snapshot.materialize();
|
|
146
|
+
try {
|
|
147
|
+
return await scoreCase(testCase, options, now, copy.flow);
|
|
148
|
+
} finally {
|
|
149
|
+
await copy.dispose();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function scoreCase(
|
|
154
|
+
testCase: FlowEvalCase, options: EvaluateFlowOptions, now: () => number, sealed: FlowEvalTarget,
|
|
155
|
+
): Promise<FlowEvalCaseResult> {
|
|
156
|
+
const started = now();
|
|
157
|
+
let run: FlowEvalRun;
|
|
158
|
+
try {
|
|
159
|
+
run = await options.executor({
|
|
160
|
+
flow: sealed, caseId: testCase.id,
|
|
161
|
+
...(testCase.input === undefined ? {} : { input: testCase.input }),
|
|
162
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// An abort is the caller stopping the evaluation, not this case's verdict.
|
|
166
|
+
if (options.signal?.aborted) throw options.signal.reason ?? error;
|
|
167
|
+
return {
|
|
168
|
+
id: testCase.id, outcome: 'error', latencyMs: Math.max(0, now() - started),
|
|
169
|
+
costUsd: null, tokensIn: null, tokensOut: null, metrics: {},
|
|
170
|
+
failures: [`executor failed: ${error instanceof Error ? error.message : String(error)}`],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const latencyMs = Math.max(0, now() - started);
|
|
174
|
+
const costUsd = finiteOrNull(run.costUsd);
|
|
175
|
+
const failures: string[] = [];
|
|
176
|
+
const expect = testCase.expect ?? {};
|
|
177
|
+
const wantReason = expect.completionReason ?? 'success';
|
|
178
|
+
if (run.completionReason !== wantReason) {
|
|
179
|
+
failures.push(`completionReason was ${run.completionReason}; expected ${wantReason}`
|
|
180
|
+
+ (run.completionDetail ? ` (${truncate(run.completionDetail)})` : ''));
|
|
181
|
+
}
|
|
182
|
+
const detail = run.completionDetail ?? '';
|
|
183
|
+
for (const fragment of expect.detailIncludes ?? []) {
|
|
184
|
+
if (!detail.includes(fragment)) failures.push(`completion detail does not include ${JSON.stringify(fragment)}`);
|
|
185
|
+
}
|
|
186
|
+
if (expect.detailMatches !== undefined && !new RegExp(expect.detailMatches, 'u').test(detail)) {
|
|
187
|
+
failures.push(`completion detail does not match /${expect.detailMatches}/`);
|
|
188
|
+
}
|
|
189
|
+
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) {
|
|
190
|
+
failures.push(`latency ${latencyMs}ms exceeds ${expect.maxLatencyMs}ms`);
|
|
191
|
+
}
|
|
192
|
+
if (expect.maxCostUsd !== undefined) {
|
|
193
|
+
if (costUsd === null) failures.push(`cost is unknown; the case caps it at $${expect.maxCostUsd}`);
|
|
194
|
+
else if (costUsd > expect.maxCostUsd) failures.push(`cost $${costUsd} exceeds $${expect.maxCostUsd}`);
|
|
195
|
+
}
|
|
196
|
+
const metrics: Record<string, number> = {};
|
|
197
|
+
for (const [name, value] of Object.entries(run.metrics ?? {})) {
|
|
198
|
+
if (Number.isFinite(value)) metrics[name] = value;
|
|
199
|
+
}
|
|
200
|
+
let errored = false;
|
|
201
|
+
for (const scorer of options.scorers ?? []) {
|
|
202
|
+
try {
|
|
203
|
+
const score = await scorer.score(run, testCase);
|
|
204
|
+
if (score.value !== undefined && Number.isFinite(score.value)) metrics[scorer.name] = score.value;
|
|
205
|
+
if (score.pass === false) failures.push(`scorer ${scorer.name} failed` + (score.detail ? `: ${score.detail}` : ''));
|
|
206
|
+
} catch (error) {
|
|
207
|
+
errored = true;
|
|
208
|
+
failures.push(`scorer ${scorer.name} threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
id: testCase.id,
|
|
213
|
+
outcome: errored ? 'error' : failures.length === 0 ? 'pass' : 'fail',
|
|
214
|
+
...(run.runId === undefined ? {} : { runId: run.runId }),
|
|
215
|
+
completionReason: run.completionReason,
|
|
216
|
+
latencyMs,
|
|
217
|
+
costUsd,
|
|
218
|
+
tokensIn: finiteOrNull(run.tokensIn),
|
|
219
|
+
tokensOut: finiteOrNull(run.tokensOut),
|
|
220
|
+
metrics,
|
|
221
|
+
...(run.steps === undefined ? {} : { steps: run.steps }),
|
|
222
|
+
failures,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A negative or non-finite figure is malformed; as unknown it fails every ceiling instead of offsetting other cases. */
|
|
227
|
+
function finiteOrNull(value: number | null | undefined): number | null {
|
|
228
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function truncate(text: string): string {
|
|
232
|
+
const line = text.replace(/\s+/gu, ' ').trim();
|
|
233
|
+
return line.length > 200 ? `${line.slice(0, 197)}...` : line;
|
|
234
|
+
}
|