@monster-orange/den 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +104 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journeys deterministic assertion engine (plan: jrn-verifier packet,
|
|
3
|
+
* Approach §Assertion contract).
|
|
4
|
+
*
|
|
5
|
+
* Plain code, zero LLM involvement: every JourneyAssertion is evaluated by
|
|
6
|
+
* executing its probe request ({method, path, body?}) through an
|
|
7
|
+
* allowlisted + JWT-authenticated probe client (mirroring the scoped HTTP
|
|
8
|
+
* tool's contract — the credential is injected harness-side and never
|
|
9
|
+
* appears in errors or results), then checking the response
|
|
10
|
+
* deterministically.
|
|
11
|
+
*
|
|
12
|
+
* `{placeholders}` in probe paths bind to ledger-extracted resource ids
|
|
13
|
+
* from EITHER feed (http-tool or browser network interception). Binding
|
|
14
|
+
* convention: `{<resourceType>_id}` binds to the MOST RECENT ledger entry
|
|
15
|
+
* of that resourceType. A missing binding fails the assertion with a clear
|
|
16
|
+
* message — it never crashes the verifier and never issues the probe.
|
|
17
|
+
*
|
|
18
|
+
* Assertions are API probes even for browser-surface journeys: the UI
|
|
19
|
+
* writes app state; the verifier reads it back through the API, which
|
|
20
|
+
* keeps the verdict independent of UI markup.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { JourneyAssertion, JourneyProbeRequest } from "../spec/schema.js";
|
|
24
|
+
import { resolveExtraHeaders } from "../tools/http-tool.js";
|
|
25
|
+
import type { RunLedger } from "../tools/ledger.js";
|
|
26
|
+
|
|
27
|
+
// ── Probe client (http-tool contract mirror, harness-side) ──────────────────
|
|
28
|
+
|
|
29
|
+
// Per-request transport cap applied to every probe fetch even when no
|
|
30
|
+
// deadline signal is provided (review finding 6) — verification and
|
|
31
|
+
// teardown requests can never hang a journey run indefinitely.
|
|
32
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/** Response the probe client hands to the evaluator. */
|
|
35
|
+
export interface ProbeResponse {
|
|
36
|
+
status: number;
|
|
37
|
+
/** Parsed JSON body when the response is JSON; raw text otherwise. */
|
|
38
|
+
body: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Executes one bound probe request. Thrown errors never carry the token. */
|
|
42
|
+
export type VerifierProbeClient = (
|
|
43
|
+
request: JourneyProbeRequest,
|
|
44
|
+
) => Promise<ProbeResponse>;
|
|
45
|
+
|
|
46
|
+
export interface VerifierProbeClientOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Base URLs the probes may reach; relative paths resolve against the
|
|
49
|
+
* first entry (the API base URL by convention) — same contract as the
|
|
50
|
+
* scoped HTTP tool.
|
|
51
|
+
*/
|
|
52
|
+
allowedBaseUrls: string[];
|
|
53
|
+
/** Harness-held JWT source; the value never leaves the client. */
|
|
54
|
+
tokenProvider: () => Promise<string>;
|
|
55
|
+
/**
|
|
56
|
+
* Optional extra-header source (the app's session-derived scoping
|
|
57
|
+
* headers), matching the scoped HTTP tool. Derived from the same browser
|
|
58
|
+
* session as the token.
|
|
59
|
+
*/
|
|
60
|
+
headersProvider?: () => Promise<Record<string, string>>;
|
|
61
|
+
/** Injectable fetch (unit tests stub it; defaults to global fetch). */
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
/**
|
|
64
|
+
* Optional deadline AbortSignal; combined with the per-request timeout
|
|
65
|
+
* so in-flight probes abort when the caller's budget expires.
|
|
66
|
+
*/
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
/** Per-request transport cap; defaults to DEFAULT_PROBE_TIMEOUT_MS. */
|
|
69
|
+
requestTimeoutMs?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the verifier's probe client: allowlist resolution + harness-side
|
|
74
|
+
* `Authorization: Bearer <jwt>` (plus any session-derived headers)
|
|
75
|
+
* injection, mirroring the scoped HTTP tool. Unlike the tool (agent-facing,
|
|
76
|
+
* never throws), this client is
|
|
77
|
+
* harness-facing and throws typed errors — the assertion engine converts
|
|
78
|
+
* them into structured failures; the token is redacted from any
|
|
79
|
+
* transport-error message before it propagates.
|
|
80
|
+
*/
|
|
81
|
+
export function buildVerifierProbeClient(
|
|
82
|
+
options: VerifierProbeClientOptions,
|
|
83
|
+
): VerifierProbeClient {
|
|
84
|
+
const {
|
|
85
|
+
allowedBaseUrls,
|
|
86
|
+
tokenProvider,
|
|
87
|
+
headersProvider,
|
|
88
|
+
fetchImpl = fetch,
|
|
89
|
+
signal: deadlineSignal,
|
|
90
|
+
requestTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
91
|
+
} = options;
|
|
92
|
+
if (allowedBaseUrls.length === 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"buildVerifierProbeClient requires at least one allowlisted base URL",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return async (request) => {
|
|
99
|
+
const isAbsolute = /^https?:\/\//i.test(request.path);
|
|
100
|
+
const url = isAbsolute
|
|
101
|
+
? new URL(request.path)
|
|
102
|
+
: new URL(request.path, allowedBaseUrls[0]);
|
|
103
|
+
|
|
104
|
+
const allowed = allowedBaseUrls.some(
|
|
105
|
+
(base) => url.origin === new URL(base).origin,
|
|
106
|
+
);
|
|
107
|
+
if (!allowed) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Probe target "${url.origin}" is not an allowlisted base URL ` +
|
|
110
|
+
"(verifier probes carry the same allowlist as the HTTP tool).",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const token = await tokenProvider();
|
|
115
|
+
const headers: Record<string, string> = {
|
|
116
|
+
...(await resolveExtraHeaders(headersProvider)),
|
|
117
|
+
Authorization: `Bearer ${token}`,
|
|
118
|
+
};
|
|
119
|
+
// Every probe fetch is bounded (finding 6): per-request timeout
|
|
120
|
+
// always, plus the caller's deadline signal when provided.
|
|
121
|
+
const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
|
|
122
|
+
const init: RequestInit = {
|
|
123
|
+
method: request.method,
|
|
124
|
+
headers,
|
|
125
|
+
signal: deadlineSignal
|
|
126
|
+
? AbortSignal.any([deadlineSignal, timeoutSignal])
|
|
127
|
+
: timeoutSignal,
|
|
128
|
+
};
|
|
129
|
+
if (request.body !== undefined) {
|
|
130
|
+
headers["Content-Type"] = "application/json";
|
|
131
|
+
init.body = JSON.stringify(request.body);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let response: Response;
|
|
135
|
+
try {
|
|
136
|
+
response = await fetchImpl(url.toString(), init);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Redact the credential from transport-error messages before they
|
|
139
|
+
// propagate into assertion diagnostics.
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Probe ${request.method} ${url.pathname} failed: ` +
|
|
143
|
+
message.split(token).join("[REDACTED]"),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rawBody = await response.text();
|
|
148
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
149
|
+
let body: unknown = rawBody;
|
|
150
|
+
if (contentType.includes("application/json")) {
|
|
151
|
+
try {
|
|
152
|
+
body = JSON.parse(rawBody);
|
|
153
|
+
} catch {
|
|
154
|
+
body = rawBody;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { status: response.status, body };
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Placeholder binding ──────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
|
164
|
+
const ID_SUFFIX = "_id";
|
|
165
|
+
|
|
166
|
+
export type PlaceholderBinding =
|
|
167
|
+
| { ok: true; path: string }
|
|
168
|
+
| { ok: false; missing: string[] };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bind every `{placeholder}` in a probe path from the run ledger.
|
|
172
|
+
* `{<resourceType>_id}` binds to the MOST RECENT ledger entry of that
|
|
173
|
+
* resourceType, regardless of which feed recorded it. Unresolvable
|
|
174
|
+
* placeholders are reported (assertion failure), never thrown.
|
|
175
|
+
*/
|
|
176
|
+
export function bindPlaceholders(
|
|
177
|
+
path: string,
|
|
178
|
+
ledger: RunLedger,
|
|
179
|
+
): PlaceholderBinding {
|
|
180
|
+
const missing: string[] = [];
|
|
181
|
+
|
|
182
|
+
const bound = path.replace(PLACEHOLDER_PATTERN, (whole, name: string) => {
|
|
183
|
+
const resourceType = name.endsWith(ID_SUFFIX)
|
|
184
|
+
? name.slice(0, -ID_SUFFIX.length)
|
|
185
|
+
: name;
|
|
186
|
+
// Most recent entry wins when a journey created several resources of
|
|
187
|
+
// one type — the assertion targets what the agent just finished with.
|
|
188
|
+
for (let i = ledger.entries.length - 1; i >= 0; i -= 1) {
|
|
189
|
+
if (ledger.entries[i].resourceType === resourceType) {
|
|
190
|
+
return ledger.entries[i].id;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
missing.push(name);
|
|
194
|
+
return whole;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return missing.length > 0
|
|
198
|
+
? { ok: false, missing }
|
|
199
|
+
: { ok: true, path: bound };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Minimal JSONPath evaluator ───────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Result of resolving a JSONPath: the value at the path, or a reason it
|
|
206
|
+
* could not be resolved. Filter expressions resolve to the ARRAY of
|
|
207
|
+
* matching items (so array_* assertion kinds operate on the match set).
|
|
208
|
+
*/
|
|
209
|
+
export type JsonPathResult =
|
|
210
|
+
| { resolved: true; value: unknown }
|
|
211
|
+
| { resolved: false; reason: string };
|
|
212
|
+
|
|
213
|
+
type PathSegment =
|
|
214
|
+
| { kind: "property"; name: string }
|
|
215
|
+
| { kind: "index"; index: number }
|
|
216
|
+
| { kind: "filter"; field: string; equals: string | number | boolean };
|
|
217
|
+
|
|
218
|
+
// Segment grammar: .prop | [n] | [?(@.field=='literal')] (single-field
|
|
219
|
+
// equality filters — the subset the journey specs use).
|
|
220
|
+
const SEGMENT_PATTERN =
|
|
221
|
+
/\.([A-Za-z0-9_-]+)|\[(\d+)\]|\[\?\(@\.([A-Za-z0-9_-]+)\s*==\s*(?:'([^']*)'|"([^"]*)"|(true|false)|(-?\d+(?:\.\d+)?))\)\]/y;
|
|
222
|
+
|
|
223
|
+
function parseJsonPath(path: string): PathSegment[] | undefined {
|
|
224
|
+
if (!path.startsWith("$")) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const segments: PathSegment[] = [];
|
|
228
|
+
SEGMENT_PATTERN.lastIndex = 1;
|
|
229
|
+
while (SEGMENT_PATTERN.lastIndex < path.length) {
|
|
230
|
+
const match = SEGMENT_PATTERN.exec(path);
|
|
231
|
+
if (match === null) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const [, property, index, field, single, double, boolean, numeric] = match;
|
|
235
|
+
if (property !== undefined) {
|
|
236
|
+
segments.push({ kind: "property", name: property });
|
|
237
|
+
} else if (index !== undefined) {
|
|
238
|
+
segments.push({ kind: "index", index: Number(index) });
|
|
239
|
+
} else if (field !== undefined) {
|
|
240
|
+
const equals =
|
|
241
|
+
single ??
|
|
242
|
+
double ??
|
|
243
|
+
(boolean !== undefined ? boolean === "true" : Number(numeric));
|
|
244
|
+
segments.push({ kind: "filter", field, equals });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return segments;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Evaluate the JSONPath subset used by journey assertions against a
|
|
252
|
+
* response body: `$`, dotted properties (`$.items`), numeric indices
|
|
253
|
+
* (`$.items[0]`), and single-field equality filters over arrays
|
|
254
|
+
* (`$[?(@.role=='assistant')]` — resolves to the array of matches).
|
|
255
|
+
* Never throws: malformed paths and unresolvable steps report a reason.
|
|
256
|
+
*/
|
|
257
|
+
export function evaluateJsonPath(body: unknown, path: string): JsonPathResult {
|
|
258
|
+
const segments = parseJsonPath(path);
|
|
259
|
+
if (segments === undefined) {
|
|
260
|
+
return { resolved: false, reason: `Malformed JSONPath: ${path}` };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let current: unknown = body;
|
|
264
|
+
for (const segment of segments) {
|
|
265
|
+
switch (segment.kind) {
|
|
266
|
+
case "property": {
|
|
267
|
+
if (
|
|
268
|
+
typeof current !== "object" ||
|
|
269
|
+
current === null ||
|
|
270
|
+
Array.isArray(current) ||
|
|
271
|
+
!(segment.name in current)
|
|
272
|
+
) {
|
|
273
|
+
return {
|
|
274
|
+
resolved: false,
|
|
275
|
+
reason: `Property "${segment.name}" not found while resolving ${path}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
current = (current as Record<string, unknown>)[segment.name];
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "index": {
|
|
282
|
+
if (!Array.isArray(current) || segment.index >= current.length) {
|
|
283
|
+
return {
|
|
284
|
+
resolved: false,
|
|
285
|
+
reason: `Index [${segment.index}] out of range while resolving ${path}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
current = current[segment.index];
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "filter": {
|
|
292
|
+
if (!Array.isArray(current)) {
|
|
293
|
+
return {
|
|
294
|
+
resolved: false,
|
|
295
|
+
reason: `Filter applied to a non-array while resolving ${path}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
current = current.filter(
|
|
299
|
+
(item) =>
|
|
300
|
+
typeof item === "object" &&
|
|
301
|
+
item !== null &&
|
|
302
|
+
(item as Record<string, unknown>)[segment.field] === segment.equals,
|
|
303
|
+
);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { resolved: true, value: current };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Assertion evaluation ─────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/** Structured outcome of one assertion (diagnostics for the report). */
|
|
314
|
+
export interface AssertionResult {
|
|
315
|
+
assertion: JourneyAssertion;
|
|
316
|
+
passed: boolean;
|
|
317
|
+
expected?: unknown;
|
|
318
|
+
actual?: unknown;
|
|
319
|
+
message?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function failed(
|
|
323
|
+
assertion: JourneyAssertion,
|
|
324
|
+
message: string,
|
|
325
|
+
expected?: unknown,
|
|
326
|
+
actual?: unknown,
|
|
327
|
+
): AssertionResult {
|
|
328
|
+
return { assertion, passed: false, message, expected, actual };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function passed(
|
|
332
|
+
assertion: JourneyAssertion,
|
|
333
|
+
expected?: unknown,
|
|
334
|
+
actual?: unknown,
|
|
335
|
+
): AssertionResult {
|
|
336
|
+
return { assertion, passed: true, expected, actual };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function resolvePath(
|
|
340
|
+
assertion: JourneyAssertion & { path: string },
|
|
341
|
+
response: ProbeResponse,
|
|
342
|
+
): { value: unknown } | { failure: AssertionResult } {
|
|
343
|
+
const result = evaluateJsonPath(response.body, assertion.path);
|
|
344
|
+
if (!result.resolved) {
|
|
345
|
+
return { failure: failed(assertion, result.reason) };
|
|
346
|
+
}
|
|
347
|
+
return { value: result.value };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function evaluateAssertion(
|
|
351
|
+
assertion: JourneyAssertion,
|
|
352
|
+
response: ProbeResponse,
|
|
353
|
+
): AssertionResult {
|
|
354
|
+
switch (assertion.kind) {
|
|
355
|
+
case "http_status": {
|
|
356
|
+
return response.status === assertion.equals
|
|
357
|
+
? passed(assertion, assertion.equals, response.status)
|
|
358
|
+
: failed(
|
|
359
|
+
assertion,
|
|
360
|
+
`Expected HTTP ${assertion.equals}, got ${response.status}`,
|
|
361
|
+
assertion.equals,
|
|
362
|
+
response.status,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
case "json_path_exists": {
|
|
366
|
+
const result = evaluateJsonPath(response.body, assertion.path);
|
|
367
|
+
return result.resolved
|
|
368
|
+
? passed(assertion, assertion.path, result.value)
|
|
369
|
+
: failed(
|
|
370
|
+
assertion,
|
|
371
|
+
`JSONPath ${assertion.path} did not resolve: ${result.reason}`,
|
|
372
|
+
assertion.path,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
case "json_path_equals": {
|
|
376
|
+
const resolved = resolvePath(assertion, response);
|
|
377
|
+
if ("failure" in resolved) return resolved.failure;
|
|
378
|
+
return resolved.value === assertion.equals
|
|
379
|
+
? passed(assertion, assertion.equals, resolved.value)
|
|
380
|
+
: failed(
|
|
381
|
+
assertion,
|
|
382
|
+
`Value at ${assertion.path} does not equal the expected literal`,
|
|
383
|
+
assertion.equals,
|
|
384
|
+
resolved.value,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
case "json_path_contains": {
|
|
388
|
+
const resolved = resolvePath(assertion, response);
|
|
389
|
+
if ("failure" in resolved) return resolved.failure;
|
|
390
|
+
const value = resolved.value;
|
|
391
|
+
const contains =
|
|
392
|
+
typeof value === "string" && typeof assertion.contains === "string"
|
|
393
|
+
? value.includes(assertion.contains)
|
|
394
|
+
: Array.isArray(value) && value.includes(assertion.contains);
|
|
395
|
+
return contains
|
|
396
|
+
? passed(assertion, assertion.contains, value)
|
|
397
|
+
: failed(
|
|
398
|
+
assertion,
|
|
399
|
+
`Value at ${assertion.path} does not contain the expected target`,
|
|
400
|
+
assertion.contains,
|
|
401
|
+
value,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
case "array_min_length": {
|
|
405
|
+
const resolved = resolvePath(assertion, response);
|
|
406
|
+
if ("failure" in resolved) return resolved.failure;
|
|
407
|
+
if (!Array.isArray(resolved.value)) {
|
|
408
|
+
return failed(
|
|
409
|
+
assertion,
|
|
410
|
+
`Value at ${assertion.path} is not an array`,
|
|
411
|
+
assertion.min,
|
|
412
|
+
resolved.value,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const length = resolved.value.length;
|
|
416
|
+
return length >= assertion.min
|
|
417
|
+
? passed(assertion, assertion.min, length)
|
|
418
|
+
: failed(
|
|
419
|
+
assertion,
|
|
420
|
+
`Array at ${assertion.path} has ${length} item(s); expected >= ${assertion.min}`,
|
|
421
|
+
assertion.min,
|
|
422
|
+
length,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
case "array_contains_match": {
|
|
426
|
+
const resolved = resolvePath(assertion, response);
|
|
427
|
+
if ("failure" in resolved) return resolved.failure;
|
|
428
|
+
if (!Array.isArray(resolved.value)) {
|
|
429
|
+
return failed(
|
|
430
|
+
assertion,
|
|
431
|
+
`Value at ${assertion.path} is not an array`,
|
|
432
|
+
assertion.match,
|
|
433
|
+
resolved.value,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
const matches = resolved.value.some(
|
|
437
|
+
(item) =>
|
|
438
|
+
typeof item === "object" &&
|
|
439
|
+
item !== null &&
|
|
440
|
+
Object.entries(assertion.match).every(
|
|
441
|
+
([field, literal]) =>
|
|
442
|
+
(item as Record<string, unknown>)[field] === literal,
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
return matches
|
|
446
|
+
? passed(assertion, assertion.match)
|
|
447
|
+
: failed(
|
|
448
|
+
assertion,
|
|
449
|
+
`No item in the array at ${assertion.path} matches every field predicate`,
|
|
450
|
+
assertion.match,
|
|
451
|
+
resolved.value,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Evaluate every assertion against live probe responses. ALL assertions
|
|
459
|
+
* run (a failure never short-circuits the rest — the report wants the
|
|
460
|
+
* full picture); the verdict composer applies all-must-pass semantics.
|
|
461
|
+
* Probe failures — missing placeholder bindings, thrown client errors —
|
|
462
|
+
* become structured assertion failures, never crashes.
|
|
463
|
+
*/
|
|
464
|
+
export async function runAssertions(
|
|
465
|
+
assertions: readonly JourneyAssertion[],
|
|
466
|
+
ledger: RunLedger,
|
|
467
|
+
client: VerifierProbeClient,
|
|
468
|
+
): Promise<AssertionResult[]> {
|
|
469
|
+
const results: AssertionResult[] = [];
|
|
470
|
+
|
|
471
|
+
for (const assertion of assertions) {
|
|
472
|
+
const binding = bindPlaceholders(assertion.request.path, ledger);
|
|
473
|
+
if (!binding.ok) {
|
|
474
|
+
results.push(
|
|
475
|
+
failed(
|
|
476
|
+
assertion,
|
|
477
|
+
`Unbound placeholder(s) [${binding.missing.join(", ")}] in probe ` +
|
|
478
|
+
`path "${assertion.request.path}" — no matching resource was ` +
|
|
479
|
+
"recorded in the run ledger (either feed).",
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let response: ProbeResponse;
|
|
486
|
+
try {
|
|
487
|
+
response = await client({ ...assertion.request, path: binding.path });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
results.push(
|
|
490
|
+
failed(
|
|
491
|
+
assertion,
|
|
492
|
+
`Probe request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
results.push(evaluateAssertion(assertion, response));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return results;
|
|
502
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journeys verdict composition (plan: jrn-verifier packet, Approach
|
|
3
|
+
* §Verdict discipline and packet ordering).
|
|
4
|
+
*
|
|
5
|
+
* The deterministic assertions are the SOLE pass/fail authority — agent
|
|
6
|
+
* completion status alone never passes a journey. Composition rule: a
|
|
7
|
+
* journey passes only when ALL assertions pass AND the RunOutcome carries
|
|
8
|
+
* no failure_kind. Any failure_kind (budget_exceeded, budget_unverifiable,
|
|
9
|
+
* infra_error, agent_error) can NEVER compose to pass, even with every
|
|
10
|
+
* assertion green. The advisory rubric is NOT part of the verdict.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FailureKind } from "../runner.js";
|
|
14
|
+
import type { JourneyAssertion } from "../spec/schema.js";
|
|
15
|
+
import type { RunLedger } from "../tools/ledger.js";
|
|
16
|
+
import {
|
|
17
|
+
runAssertions,
|
|
18
|
+
type AssertionResult,
|
|
19
|
+
type VerifierProbeClient,
|
|
20
|
+
} from "./assertions.js";
|
|
21
|
+
|
|
22
|
+
/** Structural subset of RunOutcome the verdict composer consumes. */
|
|
23
|
+
export interface VerifiableOutcome {
|
|
24
|
+
failure_kind?: FailureKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The journey's final deterministic verdict. */
|
|
28
|
+
export interface Verdict {
|
|
29
|
+
passed: boolean;
|
|
30
|
+
/** Propagated from the RunOutcome when the run itself failed. */
|
|
31
|
+
failure_kind?: FailureKind;
|
|
32
|
+
/** Every assertion's structured result, for reporting. */
|
|
33
|
+
results: AssertionResult[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compose the verdict: pass only if ALL assertions passed and the run
|
|
38
|
+
* carried no failure_kind. Pure and synchronous — probes have already run.
|
|
39
|
+
*/
|
|
40
|
+
export function composeVerdict(
|
|
41
|
+
outcome: VerifiableOutcome,
|
|
42
|
+
results: AssertionResult[],
|
|
43
|
+
): Verdict {
|
|
44
|
+
const verdict: Verdict = {
|
|
45
|
+
passed:
|
|
46
|
+
outcome.failure_kind === undefined &&
|
|
47
|
+
results.every((result) => result.passed),
|
|
48
|
+
results,
|
|
49
|
+
};
|
|
50
|
+
if (outcome.failure_kind !== undefined) {
|
|
51
|
+
verdict.failure_kind = outcome.failure_kind;
|
|
52
|
+
}
|
|
53
|
+
return verdict;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify one finished journey: execute its assertions as API probes
|
|
58
|
+
* against the run's ledger (placeholder binding from either feed), then
|
|
59
|
+
* compose the verdict. Called by the entrypoint strictly AFTER runJourney
|
|
60
|
+
* and BEFORE any teardown — probes must observe the created state. Even
|
|
61
|
+
* when the run carried a failure_kind the probes still execute (their
|
|
62
|
+
* results are diagnostics for the report), but the verdict can never pass.
|
|
63
|
+
*/
|
|
64
|
+
export async function verifyJourney(
|
|
65
|
+
assertions: readonly JourneyAssertion[],
|
|
66
|
+
outcome: VerifiableOutcome & { ledger: RunLedger },
|
|
67
|
+
client: VerifierProbeClient,
|
|
68
|
+
): Promise<Verdict> {
|
|
69
|
+
const results = await runAssertions(assertions, outcome.ledger, client);
|
|
70
|
+
return composeVerdict(outcome, results);
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { writeCurrentPerfRunReport } from "../../shared/perf/report-store.js";
|
|
2
|
+
|
|
3
|
+
function main(): void {
|
|
4
|
+
const environment = process.env.PERF_ENVIRONMENT;
|
|
5
|
+
const rawModules = process.env.PERF_MODULES ?? "";
|
|
6
|
+
const modules = rawModules
|
|
7
|
+
.split(",")
|
|
8
|
+
.map((moduleName) => moduleName.trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
|
|
11
|
+
if (!environment) {
|
|
12
|
+
throw new Error("PERF_ENVIRONMENT is required");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (modules.length === 0) {
|
|
16
|
+
throw new Error("PERF_MODULES must include at least one module");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const report = writeCurrentPerfRunReport({
|
|
20
|
+
environment,
|
|
21
|
+
modules,
|
|
22
|
+
finishedAt: new Date().toISOString(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
console.log(
|
|
26
|
+
`Generated perf report for ${report.modules.length} module(s) in ${report.environment}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
main();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
console.error(`Failed to build perf report: ${message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|