@nextcommerce/campaigns-os 1.33.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/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
// Run Telemetry — per-run Run Record capture for Campaigns OS.
|
|
2
|
+
// See docs/workflow-findings-sidecar.md (Run Telemetry).
|
|
3
|
+
//
|
|
4
|
+
// A Run Record is a per-run MANIFEST keyed by one canonical run_id. It
|
|
5
|
+
// REFERENCES source artifacts by {path, schema_version, sha256,
|
|
6
|
+
// material_fingerprint} where available and carries normalized observation
|
|
7
|
+
// arrays; it never re-embeds full artifact bodies, so it survives upstream schema
|
|
8
|
+
// drift. Capture is ALWAYS local — consent gates remit only (see consent.mjs).
|
|
9
|
+
// This module owns the schema validator and the local assemble/write surface; it
|
|
10
|
+
// has no network or credential dependencies.
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
|
|
16
|
+
|
|
17
|
+
export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
|
|
18
|
+
export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
|
|
19
|
+
|
|
20
|
+
// Improvement-surface taxonomy: a list, not an enum — real signal is rarely
|
|
21
|
+
// one surface (see Improvement-Surface Taxonomy in the design).
|
|
22
|
+
export const RUN_RECORD_SURFACES = [
|
|
23
|
+
"skill",
|
|
24
|
+
"cli",
|
|
25
|
+
"template",
|
|
26
|
+
"design-source",
|
|
27
|
+
"docs",
|
|
28
|
+
"spec-rule",
|
|
29
|
+
"platform",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const RUN_RECORD_ARTIFACT_KINDS = [
|
|
33
|
+
"build_packet",
|
|
34
|
+
"build_brief",
|
|
35
|
+
"design_source_package",
|
|
36
|
+
"build_context",
|
|
37
|
+
"assembly_report",
|
|
38
|
+
"page_kit_build_summary",
|
|
39
|
+
"qa_verdict",
|
|
40
|
+
"findings_journal",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const RUN_RECORD_CONSENT_STATES = ["on", "off"];
|
|
44
|
+
// Toolkit provenance shapes. One definition each: the validator, the assembler
|
|
45
|
+
// and the CLI stamp all read these, and the JSON Schema carries the same
|
|
46
|
+
// literal (a schema cannot reference a JS constant; the schema test pins them
|
|
47
|
+
// equal).
|
|
48
|
+
export const RUN_RECORD_SURFACE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
49
|
+
export const RUN_RECORD_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/;
|
|
50
|
+
export const RUN_RECORD_REMIT_STATES = ["skipped", "pending", "ok", "failed"];
|
|
51
|
+
// What the receiver answered, and where the send went, as kinds. The schema
|
|
52
|
+
// carries the same literals; src/remit.mjs classifies into the first and
|
|
53
|
+
// resolves the second.
|
|
54
|
+
export const RUN_RECORD_REMIT_RESULTS = ["stored", "already_stored", "ok_unparsed_ack", "refused", "transport_error"];
|
|
55
|
+
export const RUN_RECORD_REMIT_BASE_KINDS = ["canonical", "loopback", "proxy"];
|
|
56
|
+
// The QA verdict publish outcome a record may carry beside its remit: which
|
|
57
|
+
// verdict went out, who sent it, and what the QA portal answered, classified
|
|
58
|
+
// into the same result vocabulary as the remit. `qa publish` reads the block
|
|
59
|
+
// to refuse re-posting a verdict the portal already holds.
|
|
60
|
+
export const RUN_RECORD_QA_VERDICT_PUBLISH_STATES = ["skipped", "ok", "failed"];
|
|
61
|
+
export const RUN_RECORD_QA_VERDICT_PUBLISHERS = ["qa run", "qa publish"];
|
|
62
|
+
|
|
63
|
+
// Required core. Strict here; permissive about optional sub-structures (the
|
|
64
|
+
// validator checks shapes, not nested artifact bodies — those are referenced
|
|
65
|
+
// by hash, not embedded).
|
|
66
|
+
const REQUIRED_FIELDS = [
|
|
67
|
+
"schema_version",
|
|
68
|
+
"run_id",
|
|
69
|
+
"package_version",
|
|
70
|
+
"command",
|
|
71
|
+
"created_at",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function isNonEmptyString(value) {
|
|
75
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isStringArray(value) {
|
|
79
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hand-rolled validator matching the repo convention (no AJV). Checks the
|
|
84
|
+
* stable envelope + the shapes of the observation arrays and artifact refs;
|
|
85
|
+
* it does NOT re-validate nested artifact bodies (those are referenced by
|
|
86
|
+
* hash, not embedded). Returns `{ ok, errors }` where each error is
|
|
87
|
+
* `{ code, message }`.
|
|
88
|
+
*/
|
|
89
|
+
export function validateRunRecord(record) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
const add = (code, message) => errors.push({ code, message });
|
|
92
|
+
|
|
93
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
94
|
+
add("record.type", "Run Record must be a JSON object.");
|
|
95
|
+
return { ok: false, errors };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const field of REQUIRED_FIELDS) {
|
|
99
|
+
if (!isNonEmptyString(record[field])) {
|
|
100
|
+
add(`record.${field}`, `Missing or empty required field "${field}".`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (record.schema_version != null && record.schema_version !== RUN_RECORD_SCHEMA) {
|
|
105
|
+
add("record.schema_version", `Expected schema_version "${RUN_RECORD_SCHEMA}".`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (record.argv_shape == null || !isStringArray(record.argv_shape)) {
|
|
109
|
+
add("record.argv_shape", "argv_shape is required and must be an array of strings (flag names, never values).");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Toolkit provenance is optional and nullable: a missing manifest or a
|
|
113
|
+
// tarball install without gitHead must never block capture. When present
|
|
114
|
+
// the shapes are strict so the server-side segmentation can trust them.
|
|
115
|
+
if (record.surface_version != null && !(typeof record.surface_version === "string" && RUN_RECORD_SURFACE_VERSION_PATTERN.test(record.surface_version))) {
|
|
116
|
+
add("record.surface_version", "surface_version must be a MAJOR.MINOR.PATCH string or null.");
|
|
117
|
+
}
|
|
118
|
+
if (record.toolkit_commit != null && !(typeof record.toolkit_commit === "string" && RUN_RECORD_COMMIT_PATTERN.test(record.toolkit_commit))) {
|
|
119
|
+
add("record.toolkit_commit", "toolkit_commit must be a 7–40 character lowercase hex commit or null.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!RUN_RECORD_CONSENT_STATES.includes(record.consent_state)) {
|
|
123
|
+
add("record.consent_state", `consent_state is required and must be one of: ${RUN_RECORD_CONSENT_STATES.join(", ")}.`);
|
|
124
|
+
}
|
|
125
|
+
if (record.consent_source != null && !["env", "file", "default"].includes(record.consent_source)) {
|
|
126
|
+
add("record.consent_source", 'consent_source must be one of: env, file, default (or omitted).');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof record.remit_attempted !== "boolean") {
|
|
130
|
+
add("record.remit_attempted", "remit_attempted is required and must be a boolean.");
|
|
131
|
+
}
|
|
132
|
+
if (record.remit_ok != null && typeof record.remit_ok !== "boolean") {
|
|
133
|
+
add("record.remit_ok", "remit_ok must be a boolean or null.");
|
|
134
|
+
}
|
|
135
|
+
if (record.remit_error != null && typeof record.remit_error !== "string") {
|
|
136
|
+
add("record.remit_error", "remit_error must be a string or null.");
|
|
137
|
+
}
|
|
138
|
+
if (record.remit_endpoint != null && (!isNonEmptyString(record.remit_endpoint) || !record.remit_endpoint.startsWith("/"))) {
|
|
139
|
+
add("record.remit_endpoint", "remit_endpoint must be a path beginning with / or null.");
|
|
140
|
+
}
|
|
141
|
+
if (record.remit_state != null && !RUN_RECORD_REMIT_STATES.includes(record.remit_state)) {
|
|
142
|
+
add("record.remit_state", `remit_state must be one of: ${RUN_RECORD_REMIT_STATES.join(", ")}.`);
|
|
143
|
+
}
|
|
144
|
+
if (record.remit_result != null && !RUN_RECORD_REMIT_RESULTS.includes(record.remit_result)) {
|
|
145
|
+
add("record.remit_result", `remit_result must be one of: ${RUN_RECORD_REMIT_RESULTS.join(", ")} (or null).`);
|
|
146
|
+
}
|
|
147
|
+
if (record.remit_base_kind != null && !RUN_RECORD_REMIT_BASE_KINDS.includes(record.remit_base_kind)) {
|
|
148
|
+
add("record.remit_base_kind", `remit_base_kind must be one of: ${RUN_RECORD_REMIT_BASE_KINDS.join(", ")} (or null).`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (record.qa_verdict_publish != null) {
|
|
152
|
+
for (const error of validateQaVerdictPublish(record.qa_verdict_publish)) errors.push(error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (record.identity != null) {
|
|
156
|
+
if (typeof record.identity !== "object" || Array.isArray(record.identity)) {
|
|
157
|
+
add("record.identity", "identity must be an object when present.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (record.artifacts != null) {
|
|
162
|
+
if (!Array.isArray(record.artifacts)) {
|
|
163
|
+
add("record.artifacts", "artifacts must be an array when present.");
|
|
164
|
+
} else {
|
|
165
|
+
record.artifacts.forEach((ref, index) => {
|
|
166
|
+
if (!ref || typeof ref !== "object" || Array.isArray(ref)) {
|
|
167
|
+
add(`record.artifacts[${index}]`, "each artifact ref must be an object.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!RUN_RECORD_ARTIFACT_KINDS.includes(ref.kind)) {
|
|
171
|
+
add(`record.artifacts[${index}].kind`, `unknown artifact kind "${ref.kind}". Allowed: ${RUN_RECORD_ARTIFACT_KINDS.join(", ")}.`);
|
|
172
|
+
}
|
|
173
|
+
if (!isNonEmptyString(ref.path)) {
|
|
174
|
+
add(`record.artifacts[${index}].path`, "artifact ref path is required and must be a non-empty string.");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (record.observations != null) {
|
|
181
|
+
const obs = record.observations;
|
|
182
|
+
if (typeof obs !== "object" || Array.isArray(obs)) {
|
|
183
|
+
add("record.observations", "observations must be an object when present.");
|
|
184
|
+
} else {
|
|
185
|
+
if (obs.spec_validation_rule_ids != null && !isStringArray(obs.spec_validation_rule_ids)) {
|
|
186
|
+
add("record.observations.spec_validation_rule_ids", "spec_validation_rule_ids must be an array of strings.");
|
|
187
|
+
}
|
|
188
|
+
if (obs.finding_ids != null && !isStringArray(obs.finding_ids)) {
|
|
189
|
+
add("record.observations.finding_ids", "finding_ids must be an array of strings.");
|
|
190
|
+
}
|
|
191
|
+
if (obs.findings_journal != null) {
|
|
192
|
+
const j = obs.findings_journal;
|
|
193
|
+
if (typeof j !== "object" || Array.isArray(j)) {
|
|
194
|
+
add("record.observations.findings_journal", "findings_journal must be an object.");
|
|
195
|
+
} else {
|
|
196
|
+
if (j.malformed_count != null && !Number.isInteger(j.malformed_count)) add("record.observations.findings_journal.malformed_count", "malformed_count must be an integer.");
|
|
197
|
+
if (j.malformed_lines != null && (!Array.isArray(j.malformed_lines) || !j.malformed_lines.every(Number.isInteger))) {
|
|
198
|
+
add("record.observations.findings_journal.malformed_lines", "malformed_lines must be an array of integers.");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (obs.doctor != null) {
|
|
203
|
+
const d = obs.doctor;
|
|
204
|
+
if (typeof d !== "object" || Array.isArray(d)) {
|
|
205
|
+
add("record.observations.doctor", "doctor must be an object.");
|
|
206
|
+
} else {
|
|
207
|
+
if (d.error_codes != null && !isStringArray(d.error_codes)) add("record.observations.doctor.error_codes", "error_codes must be an array of strings.");
|
|
208
|
+
if (d.warning_codes != null && !isStringArray(d.warning_codes)) add("record.observations.doctor.warning_codes", "warning_codes must be an array of strings.");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (obs.qa != null) {
|
|
212
|
+
const q = obs.qa;
|
|
213
|
+
if (typeof q !== "object" || Array.isArray(q)) {
|
|
214
|
+
add("record.observations.qa", "qa must be an object.");
|
|
215
|
+
} else if (q.gap_classes != null && !isStringArray(q.gap_classes)) {
|
|
216
|
+
add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (record.agent_usage != null) {
|
|
223
|
+
const usage = record.agent_usage;
|
|
224
|
+
if (typeof usage !== "object" || Array.isArray(usage)) {
|
|
225
|
+
add("record.agent_usage", "agent_usage must be an object when present.");
|
|
226
|
+
} else {
|
|
227
|
+
for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
|
|
228
|
+
if (usage[field] != null && (!Number.isInteger(usage[field]) || usage[field] < 0)) {
|
|
229
|
+
add(`record.agent_usage.${field}`, `${field} must be a non-negative integer when present.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (usage.model != null && typeof usage.model !== "string") add("record.agent_usage.model", "model must be a string when present.");
|
|
233
|
+
if (usage.source != null && typeof usage.source !== "string") add("record.agent_usage.source", "source must be a string when present.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (record.surfaces != null) {
|
|
238
|
+
if (!Array.isArray(record.surfaces)) {
|
|
239
|
+
add("record.surfaces", "surfaces must be an array when present.");
|
|
240
|
+
} else {
|
|
241
|
+
const unknown = record.surfaces.filter((s) => !RUN_RECORD_SURFACES.includes(s));
|
|
242
|
+
if (unknown.length) add("record.surfaces", `unknown surface(s): ${unknown.join(", ")}. Allowed: ${RUN_RECORD_SURFACES.join(", ")}.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (record.primary_surface != null && !RUN_RECORD_SURFACES.includes(record.primary_surface)) {
|
|
246
|
+
add("record.primary_surface", `unknown primary_surface "${record.primary_surface}". Allowed: ${RUN_RECORD_SURFACES.join(", ")}.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (record.lifecycle != null) {
|
|
250
|
+
for (const error of validateRunRecordLifecycle(record.lifecycle)) errors.push(error);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { ok: errors.length === 0, errors };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate the embedded lifecycle block against the SAME rules the published
|
|
257
|
+
// JSON schema enforces (types + stages[].name + numeric duration_ms). Exported
|
|
258
|
+
// so the CLI can drop a corrupt/foreign lifecycle journal entry BEFORE assembly
|
|
259
|
+
// rather than fail the whole Run Record at write time. Returns an error array.
|
|
260
|
+
/**
|
|
261
|
+
* The `qa_verdict_publish` block: the same shape discipline as the remit
|
|
262
|
+
* fields, scoped to one verdict. Returns `{ code, message }` errors.
|
|
263
|
+
*/
|
|
264
|
+
export function validateQaVerdictPublish(block) {
|
|
265
|
+
const errors = [];
|
|
266
|
+
const add = (code, message) => errors.push({ code, message });
|
|
267
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
268
|
+
add("record.qa_verdict_publish", "qa_verdict_publish must be an object or null.");
|
|
269
|
+
return errors;
|
|
270
|
+
}
|
|
271
|
+
if (!isNonEmptyString(block.verdict_run_id)) {
|
|
272
|
+
add("record.qa_verdict_publish.verdict_run_id", "verdict_run_id is required and must be a non-empty string.");
|
|
273
|
+
}
|
|
274
|
+
if (!RUN_RECORD_QA_VERDICT_PUBLISHERS.includes(block.publisher)) {
|
|
275
|
+
add("record.qa_verdict_publish.publisher", `publisher must be one of: ${RUN_RECORD_QA_VERDICT_PUBLISHERS.join(", ")}.`);
|
|
276
|
+
}
|
|
277
|
+
if (typeof block.attempted !== "boolean") {
|
|
278
|
+
add("record.qa_verdict_publish.attempted", "attempted is required and must be a boolean.");
|
|
279
|
+
}
|
|
280
|
+
if (block.ok != null && typeof block.ok !== "boolean") {
|
|
281
|
+
add("record.qa_verdict_publish.ok", "ok must be a boolean or null.");
|
|
282
|
+
}
|
|
283
|
+
if (block.error != null && typeof block.error !== "string") {
|
|
284
|
+
add("record.qa_verdict_publish.error", "error must be a string or null.");
|
|
285
|
+
}
|
|
286
|
+
if (block.endpoint != null && (!isNonEmptyString(block.endpoint) || !block.endpoint.startsWith("/"))) {
|
|
287
|
+
add("record.qa_verdict_publish.endpoint", "endpoint must be a path beginning with / or null.");
|
|
288
|
+
}
|
|
289
|
+
if (!RUN_RECORD_QA_VERDICT_PUBLISH_STATES.includes(block.state)) {
|
|
290
|
+
add("record.qa_verdict_publish.state", `state must be one of: ${RUN_RECORD_QA_VERDICT_PUBLISH_STATES.join(", ")}.`);
|
|
291
|
+
}
|
|
292
|
+
if (block.result != null && !RUN_RECORD_REMIT_RESULTS.includes(block.result)) {
|
|
293
|
+
add("record.qa_verdict_publish.result", `result must be one of: ${RUN_RECORD_REMIT_RESULTS.join(", ")} (or null).`);
|
|
294
|
+
}
|
|
295
|
+
if (block.base_kind != null && !RUN_RECORD_REMIT_BASE_KINDS.includes(block.base_kind)) {
|
|
296
|
+
add("record.qa_verdict_publish.base_kind", `base_kind must be one of: ${RUN_RECORD_REMIT_BASE_KINDS.join(", ")} (or null).`);
|
|
297
|
+
}
|
|
298
|
+
if (block.published_at != null && !isNonEmptyString(block.published_at)) {
|
|
299
|
+
add("record.qa_verdict_publish.published_at", "published_at must be a non-empty string or null.");
|
|
300
|
+
}
|
|
301
|
+
const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at"]);
|
|
302
|
+
for (const key of Object.keys(block)) {
|
|
303
|
+
if (!allowed.has(key)) add(`record.qa_verdict_publish.${key}`, `unknown qa_verdict_publish field "${key}".`);
|
|
304
|
+
}
|
|
305
|
+
return errors;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function validateRunRecordLifecycle(lc) {
|
|
309
|
+
const errors = [];
|
|
310
|
+
const add = (code, message) => errors.push({ code, message });
|
|
311
|
+
if (typeof lc !== "object" || lc === null || Array.isArray(lc)) {
|
|
312
|
+
add("record.lifecycle", "lifecycle must be an object when present.");
|
|
313
|
+
return errors;
|
|
314
|
+
}
|
|
315
|
+
if (lc.command != null && typeof lc.command !== "string") add("record.lifecycle.command", "command must be a string.");
|
|
316
|
+
if (lc.run_id != null && typeof lc.run_id !== "string") add("record.lifecycle.run_id", "run_id must be a string or null.");
|
|
317
|
+
if (lc.argv_shape != null && !isStringArray(lc.argv_shape)) add("record.lifecycle.argv_shape", "argv_shape must be an array of strings.");
|
|
318
|
+
if (lc.exit_status != null && !Number.isInteger(lc.exit_status)) add("record.lifecycle.exit_status", "exit_status must be an integer or null.");
|
|
319
|
+
if (lc.started_at != null && typeof lc.started_at !== "string") add("record.lifecycle.started_at", "started_at must be a string or null.");
|
|
320
|
+
if (lc.completed_at != null && typeof lc.completed_at !== "string") add("record.lifecycle.completed_at", "completed_at must be a string or null.");
|
|
321
|
+
if (lc.duration_ms != null && typeof lc.duration_ms !== "number") add("record.lifecycle.duration_ms", "duration_ms must be a number or null.");
|
|
322
|
+
if (lc.wall_clock_duration_ms != null && typeof lc.wall_clock_duration_ms !== "number") add("record.lifecycle.wall_clock_duration_ms", "wall_clock_duration_ms must be a number or null.");
|
|
323
|
+
if (lc.repair_loop_count != null && !Number.isInteger(lc.repair_loop_count)) add("record.lifecycle.repair_loop_count", "repair_loop_count must be an integer or null.");
|
|
324
|
+
if (lc.stages != null) {
|
|
325
|
+
if (!Array.isArray(lc.stages)) {
|
|
326
|
+
add("record.lifecycle.stages", "stages must be an array when present.");
|
|
327
|
+
} else {
|
|
328
|
+
lc.stages.forEach((stage, index) => {
|
|
329
|
+
if (!stage || typeof stage !== "object" || Array.isArray(stage) || typeof stage.name !== "string") {
|
|
330
|
+
add(`record.lifecycle.stages[${index}].name`, "each stage requires a string name (matches the published schema).");
|
|
331
|
+
} else {
|
|
332
|
+
if (stage.duration_ms != null && typeof stage.duration_ms !== "number") add(`record.lifecycle.stages[${index}].duration_ms`, "stage duration_ms must be a number or null.");
|
|
333
|
+
if (stage.exit_status != null && !Number.isInteger(stage.exit_status)) add(`record.lifecycle.stages[${index}].exit_status`, "stage exit_status must be an integer or null.");
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return errors;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Capture: mint a canonical run_id, assemble the manifest from already-read
|
|
343
|
+
// artifacts (the CLI reuses the same readers `findings harvest` uses), and
|
|
344
|
+
// write it to .campaign-runtime/run-records/<run_id>.json. Assembly is a pure
|
|
345
|
+
// function over inputs so it is unit-testable without a packet on disk.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Mint the single canonical run_id at the run boundary. Threaded through the
|
|
350
|
+
* run so every artifact and finding correlates, and used as the remit
|
|
351
|
+
* idempotency key.
|
|
352
|
+
*/
|
|
353
|
+
export function mintRunId(now = new Date()) {
|
|
354
|
+
return `run_${now.getTime()}_${randomBytes(4).toString("hex")}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sanitizeRunId(runId) {
|
|
358
|
+
return String(runId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "unnamed";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve where a Run Record is written: <baseDir>/.campaign-runtime/
|
|
363
|
+
* run-records/<run_id>.json. baseDir is the run root (the CLI passes the
|
|
364
|
+
* packet's directory) so the records sit next to the findings journal.
|
|
365
|
+
*/
|
|
366
|
+
export function resolveRunRecordPath(runId, baseDir = process.cwd()) {
|
|
367
|
+
return join(resolve(baseDir), RUN_RECORDS_DIR_REL_PATH, `${sanitizeRunId(runId)}.json`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function extractDoctorObservations(doctor) {
|
|
371
|
+
if (!doctor || typeof doctor !== "object") return null;
|
|
372
|
+
const codes = (issues) => (Array.isArray(issues) ? issues : []).map((issue) => issue?.code).filter((code) => typeof code === "string");
|
|
373
|
+
return {
|
|
374
|
+
status: typeof doctor.status === "string" ? doctor.status : null,
|
|
375
|
+
error_codes: codes(doctor.errors),
|
|
376
|
+
warning_codes: codes(doctor.warnings),
|
|
377
|
+
ready_count: Array.isArray(doctor.ready) ? doctor.ready.length : null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// spec.validation issues carry the per-violation rule identity in `detail`
|
|
382
|
+
// (ruleId + path + data). Pull the rule IDs so a finding maps back to the
|
|
383
|
+
// exact rule that fired.
|
|
384
|
+
function extractSpecValidationRuleIds(doctor) {
|
|
385
|
+
if (!doctor || typeof doctor !== "object") return [];
|
|
386
|
+
const ids = [];
|
|
387
|
+
for (const issue of [...(doctor.errors || []), ...(doctor.warnings || [])]) {
|
|
388
|
+
if (issue?.code === "spec.validation" && typeof issue.detail?.ruleId === "string") {
|
|
389
|
+
ids.push(issue.detail.ruleId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return [...new Set(ids)];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function extractQaObservations(verdict) {
|
|
396
|
+
if (!verdict || typeof verdict !== "object") return null;
|
|
397
|
+
const families = new Set();
|
|
398
|
+
for (const exception of Array.isArray(verdict.exceptions) ? verdict.exceptions : []) {
|
|
399
|
+
if (typeof exception?.family === "string") families.add(exception.family);
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
disposition: typeof verdict.disposition === "string" ? verdict.disposition : null,
|
|
403
|
+
gap_classes: [...families],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Adapter decisions live in (precedence): report.adapter_decisions ->
|
|
408
|
+
// context.adapter_decisions -> packet.source_html.adapter_contract — the same
|
|
409
|
+
// precedence the doctor uses when it validates them.
|
|
410
|
+
function selectAdapterDecisions({ packet, report, context }) {
|
|
411
|
+
return (
|
|
412
|
+
report?.adapter_decisions
|
|
413
|
+
|| context?.adapter_decisions
|
|
414
|
+
|| packet?.source_html?.adapter_contract
|
|
415
|
+
|| null
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function extractAdapterDecisions(decisions) {
|
|
420
|
+
if (!decisions || typeof decisions !== "object" || Array.isArray(decisions)) return null;
|
|
421
|
+
const out = {};
|
|
422
|
+
for (const key of ADAPTER_DECISION_STRATEGY_FIELDS) {
|
|
423
|
+
if (typeof decisions[key] === "string") out[key] = decisions[key];
|
|
424
|
+
}
|
|
425
|
+
if (typeof decisions.template_files_copied?.status === "string") {
|
|
426
|
+
out.template_files_copied_status = decisions.template_files_copied.status;
|
|
427
|
+
}
|
|
428
|
+
return Object.keys(out).length ? out : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function selectRunFindings(journal, runId) {
|
|
432
|
+
const findings = Array.isArray(journal?.findings)
|
|
433
|
+
? journal.findings
|
|
434
|
+
: Array.isArray(journal)
|
|
435
|
+
? journal
|
|
436
|
+
: [];
|
|
437
|
+
return findings.filter((finding) => typeof finding?.id === "string" && finding.run_id === runId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The per-run findings snapshot, by ID. Exact (not time-inferred): only
|
|
442
|
+
* findings explicitly stamped with this run_id are included. Findings
|
|
443
|
+
* captured before Run Telemetry (no run_id) are correctly excluded.
|
|
444
|
+
*/
|
|
445
|
+
export function selectRunFindingIds(journal, runId) {
|
|
446
|
+
return selectRunFindings(journal, runId).map((finding) => finding.id);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function normalizeIdentity(identity = {}) {
|
|
450
|
+
return {
|
|
451
|
+
map_id: identity.map_id ?? null,
|
|
452
|
+
campaign_slug: identity.campaign_slug ?? null,
|
|
453
|
+
template_family: identity.template_family ?? null,
|
|
454
|
+
entry_point_shape: identity.entry_point_shape ?? null,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeRemitState(remit) {
|
|
459
|
+
if (RUN_RECORD_REMIT_STATES.includes(remit?.state)) return remit.state;
|
|
460
|
+
if (remit?.attempted) return remit.ok === true ? "ok" : "failed";
|
|
461
|
+
return "skipped";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function normalizeAgentUsage(usage) {
|
|
465
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
|
466
|
+
const out = {};
|
|
467
|
+
for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
|
|
468
|
+
if (Number.isInteger(usage[field]) && usage[field] >= 0) out[field] = usage[field];
|
|
469
|
+
}
|
|
470
|
+
if (isNonEmptyString(usage.model)) out.model = usage.model.trim();
|
|
471
|
+
if (isNonEmptyString(usage.source)) out.source = usage.source.trim();
|
|
472
|
+
return Object.keys(out).length ? out : null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const PRIMARY_SURFACE_PRIORITY = ["platform", "template", "design-source", "spec-rule", "cli", "skill", "docs"];
|
|
476
|
+
|
|
477
|
+
const ADAPTER_DECISION_SURFACE_BY_FIELD = {
|
|
478
|
+
raw_html_conversion_status: "design-source",
|
|
479
|
+
source_asset_strategy: "design-source",
|
|
480
|
+
route_rewrite_policy: "design-source",
|
|
481
|
+
wrapper_policy: "design-source",
|
|
482
|
+
script_style_reference_policy: "design-source",
|
|
483
|
+
cta_rewrite_policy: "design-source",
|
|
484
|
+
config_script_strategy: "template",
|
|
485
|
+
commerce_shell_adoption: "template",
|
|
486
|
+
frontmatter_policy: "template",
|
|
487
|
+
layout_choice: "template",
|
|
488
|
+
template_files_copied_status: "template",
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
function addSurface(counts, surface) {
|
|
492
|
+
if (!RUN_RECORD_SURFACES.includes(surface)) return;
|
|
493
|
+
counts.set(surface, (counts.get(surface) || 0) + 1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function surfaceFromOwner(value) {
|
|
497
|
+
if (!isNonEmptyString(value)) return null;
|
|
498
|
+
const normalized = value.trim().toLowerCase().replace(/_/g, "-");
|
|
499
|
+
if (RUN_RECORD_SURFACES.includes(normalized)) return normalized;
|
|
500
|
+
const aliases = {
|
|
501
|
+
design: "design-source",
|
|
502
|
+
source: "design-source",
|
|
503
|
+
"source-html": "design-source",
|
|
504
|
+
spec: "spec-rule",
|
|
505
|
+
rule: "spec-rule",
|
|
506
|
+
rules: "spec-rule",
|
|
507
|
+
"spec-rules": "spec-rule",
|
|
508
|
+
doc: "docs",
|
|
509
|
+
documentation: "docs",
|
|
510
|
+
tooling: "cli",
|
|
511
|
+
tool: "cli",
|
|
512
|
+
automation: "cli",
|
|
513
|
+
templates: "template",
|
|
514
|
+
};
|
|
515
|
+
return aliases[normalized] || null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function addIssueSurfaces(counts, code) {
|
|
519
|
+
if (!isNonEmptyString(code)) return;
|
|
520
|
+
const normalized = code.trim();
|
|
521
|
+
if (normalized === "spec.validation" || normalized.startsWith("scope.") || normalized.startsWith("build_brief.")) {
|
|
522
|
+
addSurface(counts, "spec-rule");
|
|
523
|
+
}
|
|
524
|
+
if (normalized.startsWith("adapter.") || normalized.startsWith("source_html.") || normalized === "context.prompts_required") {
|
|
525
|
+
addSurface(counts, "design-source");
|
|
526
|
+
}
|
|
527
|
+
if (normalized.startsWith("template_contract.") || normalized.startsWith("frontmatter.")) {
|
|
528
|
+
addSurface(counts, "template");
|
|
529
|
+
}
|
|
530
|
+
if (normalized.startsWith("qa.") || normalized.startsWith("report.proof_policy") || normalized.startsWith("deploy.") || normalized === "campaign.allowed_domains_confirmed") {
|
|
531
|
+
addSurface(counts, "platform");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function addFindingSurfaces(counts, finding) {
|
|
536
|
+
const ownerSurface = surfaceFromOwner(finding?.suggested_owner);
|
|
537
|
+
if (ownerSurface) addSurface(counts, ownerSurface);
|
|
538
|
+
|
|
539
|
+
switch (finding?.kind) {
|
|
540
|
+
case "docs_gap":
|
|
541
|
+
addSurface(counts, "docs");
|
|
542
|
+
break;
|
|
543
|
+
case "missing_prompt":
|
|
544
|
+
addSurface(counts, "skill");
|
|
545
|
+
break;
|
|
546
|
+
case "automation_gap":
|
|
547
|
+
addSurface(counts, "cli");
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
switch (finding?.stage) {
|
|
552
|
+
case "setup":
|
|
553
|
+
case "build":
|
|
554
|
+
case "polish":
|
|
555
|
+
addSurface(counts, "template");
|
|
556
|
+
break;
|
|
557
|
+
case "deploy":
|
|
558
|
+
case "qa":
|
|
559
|
+
case "test-order":
|
|
560
|
+
addSurface(counts, "platform");
|
|
561
|
+
break;
|
|
562
|
+
case "intake":
|
|
563
|
+
case "start":
|
|
564
|
+
addSurface(counts, "design-source");
|
|
565
|
+
break;
|
|
566
|
+
case "doctor":
|
|
567
|
+
addSurface(counts, "spec-rule");
|
|
568
|
+
break;
|
|
569
|
+
case "next":
|
|
570
|
+
addSurface(counts, "cli");
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function addAdapterDecisionSurfaces(counts, adapter) {
|
|
576
|
+
if (!adapter) return;
|
|
577
|
+
for (const [field, surface] of Object.entries(ADAPTER_DECISION_SURFACE_BY_FIELD)) {
|
|
578
|
+
if (adapter[field] != null) addSurface(counts, surface);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function deriveSurfaceCounts({ doctor, adapter, qaObs, journal, runId }) {
|
|
583
|
+
const counts = new Map();
|
|
584
|
+
|
|
585
|
+
// Counts are signal-strength hints for primary-surface selection, not exact
|
|
586
|
+
// event tallies; one run can legitimately emit several signals for a surface.
|
|
587
|
+
for (const issue of [...(doctor?.errors || []), ...(doctor?.warnings || [])]) {
|
|
588
|
+
addIssueSurfaces(counts, issue?.code);
|
|
589
|
+
}
|
|
590
|
+
addAdapterDecisionSurfaces(counts, adapter);
|
|
591
|
+
if (qaObs && (qaObs.disposition || qaObs.gap_classes?.length)) addSurface(counts, "platform");
|
|
592
|
+
|
|
593
|
+
for (const finding of selectRunFindings(journal, runId)) {
|
|
594
|
+
addFindingSurfaces(counts, finding);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return counts;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function rankSurface(surface, counts, preferredSurfaces) {
|
|
601
|
+
const base = rankSurfaceByPriority(surface, counts);
|
|
602
|
+
const preferredIndex = preferredSurfaces.indexOf(surface);
|
|
603
|
+
return {
|
|
604
|
+
...base,
|
|
605
|
+
preferredIndex: preferredIndex === -1 ? Number.POSITIVE_INFINITY : preferredIndex,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function rankSurfaceByPriority(surface, counts) {
|
|
610
|
+
const priority = PRIMARY_SURFACE_PRIORITY.indexOf(surface);
|
|
611
|
+
return {
|
|
612
|
+
count: counts.get(surface) || 0,
|
|
613
|
+
priority: priority === -1 ? PRIMARY_SURFACE_PRIORITY.length : priority,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function selectPrimarySurface(surfaces, counts, preferredSurfaces = []) {
|
|
618
|
+
return [...surfaces].sort((a, b) => {
|
|
619
|
+
const left = rankSurface(a, counts, preferredSurfaces);
|
|
620
|
+
const right = rankSurface(b, counts, preferredSurfaces);
|
|
621
|
+
return right.count - left.count
|
|
622
|
+
|| left.preferredIndex - right.preferredIndex
|
|
623
|
+
|| left.priority - right.priority
|
|
624
|
+
|| a.localeCompare(b);
|
|
625
|
+
})[0] || null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function uniqueEntries(entries) {
|
|
629
|
+
const out = [];
|
|
630
|
+
const seen = new Set();
|
|
631
|
+
for (const entry of entries) {
|
|
632
|
+
if (!entry || seen.has(entry)) continue;
|
|
633
|
+
seen.add(entry);
|
|
634
|
+
out.push(entry);
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Assemble a Run Record manifest from already-read inputs. Pure: the caller
|
|
641
|
+
* does the file reading (reusing the doctor / packet / report / verdict /
|
|
642
|
+
* journal readers) and passes the parsed structures in. Missing identity or
|
|
643
|
+
* absent artifacts never throw — capture is best-effort.
|
|
644
|
+
*/
|
|
645
|
+
export function assembleRunRecord({
|
|
646
|
+
runId,
|
|
647
|
+
packageVersion,
|
|
648
|
+
surfaceVersion = null,
|
|
649
|
+
toolkitCommit = null,
|
|
650
|
+
command,
|
|
651
|
+
argvShape = [],
|
|
652
|
+
consent = { state: "off", source: "default" },
|
|
653
|
+
remit = { attempted: false, ok: null, error: null, endpoint: null },
|
|
654
|
+
identity = {},
|
|
655
|
+
artifacts = [],
|
|
656
|
+
packet = null,
|
|
657
|
+
doctor = null,
|
|
658
|
+
report = null,
|
|
659
|
+
context = null,
|
|
660
|
+
qaVerdict = null,
|
|
661
|
+
journal = { findings: [] },
|
|
662
|
+
surfaces = [],
|
|
663
|
+
primarySurface = null,
|
|
664
|
+
surfaceConfidence = null,
|
|
665
|
+
lifecycle = null,
|
|
666
|
+
agentUsage = null,
|
|
667
|
+
qaVerdictPublish = null,
|
|
668
|
+
now = new Date(),
|
|
669
|
+
} = {}) {
|
|
670
|
+
const observations = {};
|
|
671
|
+
const doctorObs = extractDoctorObservations(doctor);
|
|
672
|
+
if (doctorObs) {
|
|
673
|
+
observations.doctor = doctorObs;
|
|
674
|
+
observations.spec_validation_rule_ids = extractSpecValidationRuleIds(doctor);
|
|
675
|
+
}
|
|
676
|
+
const adapter = extractAdapterDecisions(selectAdapterDecisions({ packet, report, context }));
|
|
677
|
+
if (adapter) observations.adapter_decisions = adapter;
|
|
678
|
+
const qaObs = extractQaObservations(qaVerdict);
|
|
679
|
+
if (qaObs) observations.qa = qaObs;
|
|
680
|
+
observations.finding_ids = selectRunFindingIds(journal, runId);
|
|
681
|
+
const malformedJournalLines = Array.isArray(journal?.malformed)
|
|
682
|
+
? journal.malformed.map((entry) => entry?.line).filter(Number.isInteger)
|
|
683
|
+
: [];
|
|
684
|
+
if (malformedJournalLines.length) {
|
|
685
|
+
observations.findings_journal = {
|
|
686
|
+
malformed_count: malformedJournalLines.length,
|
|
687
|
+
malformed_lines: malformedJournalLines,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const record = {
|
|
692
|
+
schema_version: RUN_RECORD_SCHEMA,
|
|
693
|
+
run_id: runId,
|
|
694
|
+
package_version: packageVersion,
|
|
695
|
+
surface_version: typeof surfaceVersion === "string" && RUN_RECORD_SURFACE_VERSION_PATTERN.test(surfaceVersion) ? surfaceVersion : null,
|
|
696
|
+
toolkit_commit: typeof toolkitCommit === "string" && RUN_RECORD_COMMIT_PATTERN.test(toolkitCommit) ? toolkitCommit : null,
|
|
697
|
+
command,
|
|
698
|
+
argv_shape: Array.isArray(argvShape) ? argvShape : [],
|
|
699
|
+
created_at: now.toISOString(),
|
|
700
|
+
consent_state: consent?.state === "on" ? "on" : "off",
|
|
701
|
+
consent_source: consent?.source ?? null,
|
|
702
|
+
remit_attempted: Boolean(remit?.attempted),
|
|
703
|
+
remit_ok: remit?.ok ?? null,
|
|
704
|
+
remit_error: remit?.error ?? null,
|
|
705
|
+
remit_endpoint: remit?.endpoint ?? null,
|
|
706
|
+
remit_state: normalizeRemitState(remit),
|
|
707
|
+
remit_result: RUN_RECORD_REMIT_RESULTS.includes(remit?.result) ? remit.result : null,
|
|
708
|
+
remit_base_kind: RUN_RECORD_REMIT_BASE_KINDS.includes(remit?.base_kind) ? remit.base_kind : null,
|
|
709
|
+
identity: normalizeIdentity(identity),
|
|
710
|
+
artifacts: Array.isArray(artifacts) ? artifacts : [],
|
|
711
|
+
observations,
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const surfaceCounts = deriveSurfaceCounts({ doctor, adapter, qaObs, journal, runId });
|
|
715
|
+
const inferredSurfaces = PRIMARY_SURFACE_PRIORITY.filter((surface) => surfaceCounts.has(surface));
|
|
716
|
+
const cleanSurfaces = Array.isArray(surfaces)
|
|
717
|
+
? surfaces.filter(isNonEmptyString).map((surface) => surface.trim()).filter((surface) => RUN_RECORD_SURFACES.includes(surface))
|
|
718
|
+
: [];
|
|
719
|
+
const combinedSurfaces = uniqueEntries([...cleanSurfaces, ...inferredSurfaces]);
|
|
720
|
+
if (combinedSurfaces.length) record.surfaces = combinedSurfaces;
|
|
721
|
+
if (isNonEmptyString(primarySurface)) {
|
|
722
|
+
record.primary_surface = primarySurface.trim();
|
|
723
|
+
} else {
|
|
724
|
+
const explicitSurfaces = cleanSurfaces.filter((surface) => RUN_RECORD_SURFACES.includes(surface));
|
|
725
|
+
const candidateSurfaces = combinedSurfaces.filter((surface) => RUN_RECORD_SURFACES.includes(surface));
|
|
726
|
+
const inferredPrimary = selectPrimarySurface(candidateSurfaces, surfaceCounts, explicitSurfaces);
|
|
727
|
+
if (inferredPrimary) record.primary_surface = inferredPrimary;
|
|
728
|
+
}
|
|
729
|
+
if (surfaceConfidence) record.surface_confidence = surfaceConfidence;
|
|
730
|
+
if (lifecycle && typeof lifecycle === "object" && !Array.isArray(lifecycle)) record.lifecycle = lifecycle;
|
|
731
|
+
const normalizedUsage = normalizeAgentUsage(agentUsage);
|
|
732
|
+
if (normalizedUsage) record.agent_usage = normalizedUsage;
|
|
733
|
+
// Present only when a publish outcome is known: an absent block reads as
|
|
734
|
+
// "nothing recorded", which is what every record before this field says.
|
|
735
|
+
if (qaVerdictPublish && typeof qaVerdictPublish === "object" && !Array.isArray(qaVerdictPublish)) record.qa_verdict_publish = qaVerdictPublish;
|
|
736
|
+
|
|
737
|
+
return record;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Validate then write a Run Record to its canonical path. Throws on an
|
|
742
|
+
* invalid record (a malformed manifest is a build-tooling bug, not run state
|
|
743
|
+
* to swallow). Returns the written path.
|
|
744
|
+
*/
|
|
745
|
+
export function writeRunRecord(record, { baseDir = process.cwd() } = {}) {
|
|
746
|
+
const validation = validateRunRecord(record);
|
|
747
|
+
if (!validation.ok) {
|
|
748
|
+
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
749
|
+
throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
|
|
750
|
+
}
|
|
751
|
+
const path = resolveRunRecordPath(record.run_id, baseDir);
|
|
752
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
753
|
+
const tmpPath = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
754
|
+
writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
755
|
+
renameSync(tmpPath, path);
|
|
756
|
+
return path;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Run Records are machine-local (they are in the runtime-state ignore block), so
|
|
760
|
+
// `next` reads them best-effort: an absent directory is the normal case, not an
|
|
761
|
+
// error, and a slow or hostile records directory must never stall orchestration.
|
|
762
|
+
// Bounded, but bounded well above any plausible per-target history rather than
|
|
763
|
+
// at the working-set size. The old bound was 50, which is reachable on a
|
|
764
|
+
// long-lived campaign, and truncation here is not a performance trade — it
|
|
765
|
+
// silently changes the ANSWER: an older matching record outside the window
|
|
766
|
+
// reads as `no_record` or `foreign_campaign`, which asks the operator to
|
|
767
|
+
// re-make a record that already exists. That is the exact regression this
|
|
768
|
+
// closeout work is closing, so the cap is now a runaway-directory guard and
|
|
769
|
+
// nothing else. The parse cost stays tolerable because `readJson` failures are
|
|
770
|
+
// swallowed per file, so a malformed record still costs one failed parse rather
|
|
771
|
+
// than aborting the scan.
|
|
772
|
+
const RUN_RECORDS_SCAN_LIMIT = 5000;
|
|
773
|
+
|
|
774
|
+
// Run ids are `run_<epoch-ms>_<hex>`, and `readdirSync().sort().reverse()` only
|
|
775
|
+
// ordered those correctly by accident: lexicographic order matches numeric order
|
|
776
|
+
// only while every id has the same digit count. A 13-to-14 digit rollover (or a
|
|
777
|
+
// hand-named file, or an id minted on a machine with a different clock) flips
|
|
778
|
+
// "newest" silently. Parse the timestamp and compare it as a number; anything
|
|
779
|
+
// unparseable sorts last, because a file that cannot say when it was written
|
|
780
|
+
// must never displace one that can.
|
|
781
|
+
// A Run Record file is named for its run id, which always carries the `run_`
|
|
782
|
+
// prefix. Admitting every *.json in the directory means an operator note, an
|
|
783
|
+
// editor backup, or any unrelated artifact that happens to land there gets
|
|
784
|
+
// parsed as run state and — worse — can be selected as THE previous run.
|
|
785
|
+
// Filter at scan time rather than leaning on the orderer's
|
|
786
|
+
// unstamped-sorts-last fallback: that fallback keeps ordering deterministic,
|
|
787
|
+
// it does not make a stray file not a record.
|
|
788
|
+
//
|
|
789
|
+
// Prefix, deliberately, and not the full minted `run_<epoch-ms>_<hex>` shape.
|
|
790
|
+
// mintRunId produces that shape, but `--run-id` lets an operator supply their
|
|
791
|
+
// own and writeRunRecord writes whatever it is given after sanitizing — so a
|
|
792
|
+
// digits-required pattern would hide real records (the closeout suite writes
|
|
793
|
+
// `run_synth_*` ones) and report a run that exists as missing. That failure is
|
|
794
|
+
// worse than the one this filter closes.
|
|
795
|
+
export const RUN_RECORD_FILE_NAME_PATTERN = /^run_.+\.json$/;
|
|
796
|
+
|
|
797
|
+
export function orderRunRecordFileNames(names) {
|
|
798
|
+
const stamp = (name) => {
|
|
799
|
+
const match = /^run_(\d+)_/.exec(name);
|
|
800
|
+
if (!match) return null;
|
|
801
|
+
const value = Number(match[1]);
|
|
802
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
803
|
+
};
|
|
804
|
+
return [...names].sort((a, b) => {
|
|
805
|
+
const left = stamp(a);
|
|
806
|
+
const right = stamp(b);
|
|
807
|
+
if (left !== right) {
|
|
808
|
+
if (left === null) return 1;
|
|
809
|
+
if (right === null) return -1;
|
|
810
|
+
return right - left;
|
|
811
|
+
}
|
|
812
|
+
// Same stamp, or both unstamped: fall back to a stable name comparison so
|
|
813
|
+
// the order is at least deterministic across platforms.
|
|
814
|
+
return a < b ? 1 : a > b ? -1 : 0;
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function readRunRecordsForTarget(baseDir) {
|
|
819
|
+
try {
|
|
820
|
+
const dir = join(resolve(baseDir), RUN_RECORDS_DIR_REL_PATH);
|
|
821
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
822
|
+
const names = orderRunRecordFileNames(readdirSync(dir).filter((name) => RUN_RECORD_FILE_NAME_PATTERN.test(name)))
|
|
823
|
+
.slice(0, RUN_RECORDS_SCAN_LIMIT);
|
|
824
|
+
const entries = [];
|
|
825
|
+
for (const name of names) {
|
|
826
|
+
const path = join(dir, name);
|
|
827
|
+
try {
|
|
828
|
+
entries.push({ path, record: JSON.parse(readFileSync(path, "utf8")) });
|
|
829
|
+
} catch {
|
|
830
|
+
// One corrupt record must not hide a good one beside it. The assessor
|
|
831
|
+
// ignores unreadable entries rather than treating them as evidence.
|
|
832
|
+
entries.push({ path, record: null });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return entries;
|
|
836
|
+
} catch {
|
|
837
|
+
return [];
|
|
838
|
+
}
|
|
839
|
+
}
|