@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,338 @@
|
|
|
1
|
+
// Run Telemetry — command-lifecycle instrumentation for Campaigns OS.
|
|
2
|
+
// See docs/workflow-findings-sidecar.md (Deferred / scope cut).
|
|
3
|
+
//
|
|
4
|
+
// A thin wrapper that times one command and captures its lifecycle: the
|
|
5
|
+
// command name, the argv SHAPE (flag names, never values), the exit status,
|
|
6
|
+
// and wall-clock start/end + monotonic duration. This is the instrumentation
|
|
7
|
+
// the v0 scope cut said the deferred fields needed first: stage timings and
|
|
8
|
+
// repair-loop count have recorder hooks here (stages[]/repair_loop_count) so
|
|
9
|
+
// they can be populated as command boundaries are instrumented — v0 captures
|
|
10
|
+
// command/argv-shape/exit-status/duration and leaves those hooks empty.
|
|
11
|
+
//
|
|
12
|
+
// The wrapper never changes a command's behavior: it re-throws after recording
|
|
13
|
+
// so the CLI exit code is unchanged, and lifecycle persistence is opt-in and
|
|
14
|
+
// non-fatal. No network, no credentials.
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const LIFECYCLE_SCHEMA = "campaigns-os-command-lifecycle/v0";
|
|
20
|
+
export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.jsonl";
|
|
21
|
+
|
|
22
|
+
function isNonEmptyString(value) {
|
|
23
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isStringArray(value) {
|
|
27
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Injectable so tests are deterministic. `now` is wall-clock (ISO source);
|
|
31
|
+
// `monotonic` is a steadily-increasing millisecond counter for durations.
|
|
32
|
+
const defaultClock = {
|
|
33
|
+
now: () => new Date(),
|
|
34
|
+
monotonic: () => performance.now(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Raw process.exitCode: an integer when a command set it (INCLUDING 0), or
|
|
38
|
+
// undefined when unset. The wrapper coerces per path so an explicit 0 is
|
|
39
|
+
// distinguishable from "unset" (the bug a plain `|| 0` / `|| 1` would hide).
|
|
40
|
+
function defaultReadExitStatus() {
|
|
41
|
+
return process.exitCode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A small recorder passed into the wrapped command. `stage(name)` returns a
|
|
46
|
+
* stop() that records that stage's duration; `recordRepairLoop()` bumps the
|
|
47
|
+
* repair-loop counter. v0 commands don't mark stages yet — the hooks exist so
|
|
48
|
+
* the deferred fields can be filled without reshaping the Run Record.
|
|
49
|
+
*/
|
|
50
|
+
export function createLifecycleRecorder(clock = defaultClock) {
|
|
51
|
+
const stages = [];
|
|
52
|
+
let repairLoopCount = 0;
|
|
53
|
+
function stage(name) {
|
|
54
|
+
const t0 = clock.monotonic();
|
|
55
|
+
let stopped = false;
|
|
56
|
+
return function stop() {
|
|
57
|
+
if (stopped) return;
|
|
58
|
+
stopped = true;
|
|
59
|
+
stages.push({ name: String(name), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Convenience: time `fn` as a named sub-phase. Records the stage even if fn
|
|
63
|
+
// throws (the phase still ran), then re-throws. Async-aware.
|
|
64
|
+
async function time(name, fn) {
|
|
65
|
+
const stop = stage(name);
|
|
66
|
+
try {
|
|
67
|
+
return await fn();
|
|
68
|
+
} finally {
|
|
69
|
+
stop();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
stage,
|
|
74
|
+
time,
|
|
75
|
+
recordRepairLoop() {
|
|
76
|
+
repairLoopCount += 1;
|
|
77
|
+
},
|
|
78
|
+
snapshot() {
|
|
79
|
+
return { stages: stages.slice(), repair_loop_count: repairLoopCount };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A no-op recorder for callers that run a command without lifecycle capture.
|
|
85
|
+
// Same surface as createLifecycleRecorder(); records nothing.
|
|
86
|
+
export const NOOP_RECORDER = {
|
|
87
|
+
stage: () => () => {},
|
|
88
|
+
time: async (_name, fn) => fn(),
|
|
89
|
+
recordRepairLoop: () => {},
|
|
90
|
+
snapshot: () => ({ stages: [], repair_loop_count: 0 }),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, completedAt, durationMs, recorder }) {
|
|
94
|
+
const recorded = recorder ? recorder.snapshot() : { stages: [], repair_loop_count: 0 };
|
|
95
|
+
return {
|
|
96
|
+
schema_version: LIFECYCLE_SCHEMA,
|
|
97
|
+
run_id: isNonEmptyString(runId) ? runId : null,
|
|
98
|
+
command: String(command || ""),
|
|
99
|
+
argv_shape: isStringArray(argvShape) ? argvShape : [],
|
|
100
|
+
exit_status: Number.isInteger(exitStatus) ? exitStatus : null,
|
|
101
|
+
started_at: startedAt,
|
|
102
|
+
completed_at: completedAt,
|
|
103
|
+
duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
|
|
104
|
+
stages: recorded.stages,
|
|
105
|
+
repair_loop_count: recorded.repair_loop_count,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run `fn(recorder)` while capturing its command lifecycle. Returns
|
|
111
|
+
* `{ result, lifecycle }`. `onFinish(lifecycle, error)` runs on BOTH the
|
|
112
|
+
* success and error paths (before re-throw) so persistence happens even when
|
|
113
|
+
* the command fails. Re-throws any error so the CLI's exit behavior is
|
|
114
|
+
* unchanged — the lifecycle just records the resulting exit status.
|
|
115
|
+
*/
|
|
116
|
+
export async function withCommandLifecycle({
|
|
117
|
+
command,
|
|
118
|
+
argvShape = [],
|
|
119
|
+
runId = null,
|
|
120
|
+
clock = defaultClock,
|
|
121
|
+
readExitStatus = defaultReadExitStatus,
|
|
122
|
+
onFinish = null,
|
|
123
|
+
} = {}, fn) {
|
|
124
|
+
const startedAtDate = clock.now();
|
|
125
|
+
const t0 = clock.monotonic();
|
|
126
|
+
const recorder = createLifecycleRecorder(clock);
|
|
127
|
+
|
|
128
|
+
let result;
|
|
129
|
+
let thrown = null;
|
|
130
|
+
let exitStatus = 0;
|
|
131
|
+
try {
|
|
132
|
+
result = await fn(recorder);
|
|
133
|
+
// A clean return is exit 0 unless the command set process.exitCode.
|
|
134
|
+
const raw = readExitStatus();
|
|
135
|
+
exitStatus = Number.isInteger(raw) ? raw : 0;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
thrown = error;
|
|
138
|
+
// Symmetric with the success path, but using an integer test (not `||`) so an
|
|
139
|
+
// explicit process.exitCode = 0 set before throwing is preserved rather than
|
|
140
|
+
// treated as falsy. An integer (incl. 0) wins; else the error's own exitCode;
|
|
141
|
+
// else 1.
|
|
142
|
+
const raw = readExitStatus();
|
|
143
|
+
exitStatus = Number.isInteger(raw) ? raw : (Number.isInteger(error?.exitCode) ? error.exitCode : 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lifecycle = buildLifecycle({
|
|
147
|
+
command,
|
|
148
|
+
argvShape,
|
|
149
|
+
runId,
|
|
150
|
+
exitStatus,
|
|
151
|
+
startedAt: startedAtDate.toISOString(),
|
|
152
|
+
completedAt: clock.now().toISOString(),
|
|
153
|
+
durationMs: clock.monotonic() - t0,
|
|
154
|
+
recorder,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (typeof onFinish === "function") {
|
|
158
|
+
try {
|
|
159
|
+
await onFinish(lifecycle, thrown);
|
|
160
|
+
} catch {
|
|
161
|
+
// Persistence is non-fatal — never let a lifecycle write mask the command.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (thrown) throw thrown;
|
|
166
|
+
return { result, lifecycle };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Light validator (no AJV), matching the repo convention. Checks the lifecycle
|
|
171
|
+
* envelope shape. Returns `{ ok, errors }`.
|
|
172
|
+
*/
|
|
173
|
+
export function validateLifecycle(entry) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
const add = (code, message) => errors.push({ code, message });
|
|
176
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
177
|
+
add("lifecycle.type", "Lifecycle must be a JSON object.");
|
|
178
|
+
return { ok: false, errors };
|
|
179
|
+
}
|
|
180
|
+
if (!isNonEmptyString(entry.command)) add("lifecycle.command", "command is required and must be a non-empty string.");
|
|
181
|
+
if (!isStringArray(entry.argv_shape)) add("lifecycle.argv_shape", "argv_shape must be an array of strings.");
|
|
182
|
+
if (entry.exit_status != null && !Number.isInteger(entry.exit_status)) add("lifecycle.exit_status", "exit_status must be an integer or null.");
|
|
183
|
+
if (entry.run_id != null && typeof entry.run_id !== "string") add("lifecycle.run_id", "run_id must be a string or null.");
|
|
184
|
+
if (entry.duration_ms != null && typeof entry.duration_ms !== "number") add("lifecycle.duration_ms", "duration_ms must be a number or null.");
|
|
185
|
+
if (entry.repair_loop_count != null && !Number.isInteger(entry.repair_loop_count)) add("lifecycle.repair_loop_count", "repair_loop_count must be an integer.");
|
|
186
|
+
if (entry.stages != null) {
|
|
187
|
+
if (!Array.isArray(entry.stages)) {
|
|
188
|
+
add("lifecycle.stages", "stages must be an array.");
|
|
189
|
+
} else {
|
|
190
|
+
entry.stages.forEach((stage, index) => {
|
|
191
|
+
if (!stage || typeof stage !== "object" || Array.isArray(stage)) add(`lifecycle.stages[${index}]`, "each stage must be an object.");
|
|
192
|
+
else if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { ok: errors.length === 0, errors };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Append one validated lifecycle entry as one JSONL line. Throws on an invalid
|
|
201
|
+
* entry so a bug is caught; callers that want non-fatal behavior (the CLI) wrap
|
|
202
|
+
* this.
|
|
203
|
+
*
|
|
204
|
+
* Single-writer-per-run assumption: each entry is one append, but appendFileSync
|
|
205
|
+
* is only atomic for writes under PIPE_BUF, so two processes appending to the
|
|
206
|
+
* SAME journal concurrently can interleave. A journal is scoped to one run
|
|
207
|
+
* (one run-id, cleared by `run end`), so the normal path is a single writer.
|
|
208
|
+
* readLifecycleJournal tolerates the rare malformed line either way.
|
|
209
|
+
*/
|
|
210
|
+
export function appendLifecycleEntry(journalPath, entry) {
|
|
211
|
+
const validation = validateLifecycle(entry);
|
|
212
|
+
if (!validation.ok) {
|
|
213
|
+
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
214
|
+
throw new Error(`Command lifecycle failed validation: ${detail}`);
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(resolve(journalPath)), { recursive: true });
|
|
217
|
+
appendFileSync(resolve(journalPath), `${JSON.stringify(entry)}\n`);
|
|
218
|
+
return entry;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read the lifecycle journal. Returns `{ entries, malformed }`; malformed
|
|
223
|
+
* lines are preserved rather than thrown, so one bad line never blocks the rest.
|
|
224
|
+
* Reads the whole file: journals are per-run and short-lived (a run session
|
|
225
|
+
* clears on `run end`), and run-record aggregation needs every entry for the
|
|
226
|
+
* run_id, so there is no last-N shortcut. Callers treat read failures as
|
|
227
|
+
* "no journal" (best-effort) — see the run-record embed path.
|
|
228
|
+
*/
|
|
229
|
+
export function readLifecycleJournal(journalPath) {
|
|
230
|
+
const resolved = resolve(journalPath);
|
|
231
|
+
if (!existsSync(resolved)) return { entries: [], malformed: [] };
|
|
232
|
+
const entries = [];
|
|
233
|
+
const malformed = [];
|
|
234
|
+
const lines = readFileSync(resolved, "utf8").split("\n");
|
|
235
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
236
|
+
const raw = lines[index];
|
|
237
|
+
if (!raw.trim()) continue;
|
|
238
|
+
try {
|
|
239
|
+
entries.push(JSON.parse(raw));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
malformed.push({ line: index + 1, raw, error: error.message });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { entries, malformed };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function entriesForRun(journal, runId, excludeCommands) {
|
|
248
|
+
const entries = Array.isArray(journal?.entries) ? journal.entries : Array.isArray(journal) ? journal : [];
|
|
249
|
+
return entries.filter((entry) => entry && entry.run_id === runId && !excludeCommands.includes(entry.command));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Aggregate ALL lifecycle journal entries for `runId` into one run-level
|
|
254
|
+
* lifecycle block — the populated form of the deferred stage-timings /
|
|
255
|
+
* repair-loop fields. Each command invocation becomes a stage; when a command
|
|
256
|
+
* marked its own sub-phases (Tier 2), those become `command:phase` stages
|
|
257
|
+
* instead. `repair_loop_count` = re-runs of any command (a re-run is a repair
|
|
258
|
+
* loop: doctor -> fix -> doctor). Run-level duration is summed active command
|
|
259
|
+
* time, while started_at/completed_at preserve the outer observed bounds.
|
|
260
|
+
* Returns null when no entry matches, so embedding stays best-effort and
|
|
261
|
+
* backward-compatible.
|
|
262
|
+
*/
|
|
263
|
+
export function aggregateLifecycleForRun(journal, runId, { excludeCommands = [] } = {}) {
|
|
264
|
+
const matching = entriesForRun(journal, runId, excludeCommands);
|
|
265
|
+
if (!matching.length) return null;
|
|
266
|
+
|
|
267
|
+
const stages = [];
|
|
268
|
+
const commandCounts = new Map();
|
|
269
|
+
let earliest = null;
|
|
270
|
+
let latest = null;
|
|
271
|
+
let durationSum = 0;
|
|
272
|
+
let explicitRepairLoops = 0;
|
|
273
|
+
|
|
274
|
+
// Only finite, parseable ISO timestamps participate in span timing; a junk
|
|
275
|
+
// string (e.g. a hand-edited journal) is ignored rather than emitted as a
|
|
276
|
+
// bogus started_at/completed_at.
|
|
277
|
+
const isParseableTimestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
278
|
+
|
|
279
|
+
for (const entry of matching) {
|
|
280
|
+
const command = typeof entry.command === "string" ? entry.command : "(unknown)";
|
|
281
|
+
commandCounts.set(command, (commandCounts.get(command) || 0) + 1);
|
|
282
|
+
const exitStatus = Number.isInteger(entry.exit_status) ? entry.exit_status : null;
|
|
283
|
+
// A command may have recorded its own repair loops via recordRepairLoop().
|
|
284
|
+
if (Number.isInteger(entry.repair_loop_count)) explicitRepairLoops += entry.repair_loop_count;
|
|
285
|
+
|
|
286
|
+
const subStages = Array.isArray(entry.stages) && entry.stages.length
|
|
287
|
+
? entry.stages.map((stage) => ({
|
|
288
|
+
name: `${command}:${typeof stage?.name === "string" ? stage.name : "stage"}`,
|
|
289
|
+
duration_ms: typeof stage?.duration_ms === "number" ? stage.duration_ms : null,
|
|
290
|
+
exit_status: exitStatus,
|
|
291
|
+
}))
|
|
292
|
+
: [{
|
|
293
|
+
name: command,
|
|
294
|
+
duration_ms: typeof entry.duration_ms === "number" ? entry.duration_ms : null,
|
|
295
|
+
exit_status: exitStatus,
|
|
296
|
+
}];
|
|
297
|
+
stages.push(...subStages);
|
|
298
|
+
|
|
299
|
+
if (typeof entry.duration_ms === "number") durationSum += entry.duration_ms;
|
|
300
|
+
if (isParseableTimestamp(entry.started_at) && (!earliest || entry.started_at < earliest)) earliest = entry.started_at;
|
|
301
|
+
if (isParseableTimestamp(entry.completed_at) && (!latest || entry.completed_at > latest)) latest = entry.completed_at;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// repair_loop_count = command re-runs (doctor -> fix -> doctor) PLUS any loops
|
|
305
|
+
// a command recorded explicitly. Re-runs are the v0 heuristic; explicit loops
|
|
306
|
+
// refine it once commands call recordRepairLoop().
|
|
307
|
+
let repairLoopCount = explicitRepairLoops;
|
|
308
|
+
for (const count of commandCounts.values()) if (count > 1) repairLoopCount += count - 1;
|
|
309
|
+
|
|
310
|
+
// Duration is active work, not the idle wall-clock gap between separate
|
|
311
|
+
// invocations. Report the full run span separately so operator/review/idle
|
|
312
|
+
// time remains visible without inflating command execution time.
|
|
313
|
+
const durationMs = durationSum;
|
|
314
|
+
const wallClockDurationMs = earliest && latest
|
|
315
|
+
? Math.max(0, Date.parse(latest) - Date.parse(earliest))
|
|
316
|
+
: null;
|
|
317
|
+
|
|
318
|
+
// Top-level command/argv_shape describe the RUN, not its earliest invocation.
|
|
319
|
+
// They are meaningful only when the run is a single distinct command; for a
|
|
320
|
+
// multi-command run (doctor -> start -> qa) they would mislead, so null them
|
|
321
|
+
// and let stages[] carry the per-command detail. exit_status is the LAST
|
|
322
|
+
// command's (the run's final outcome).
|
|
323
|
+
const first = matching[0];
|
|
324
|
+
const last = matching[matching.length - 1];
|
|
325
|
+
const singleCommand = commandCounts.size === 1 && typeof first.command === "string";
|
|
326
|
+
return {
|
|
327
|
+
run_id: runId,
|
|
328
|
+
command: singleCommand ? first.command : null,
|
|
329
|
+
argv_shape: singleCommand && isStringArray(first.argv_shape) ? first.argv_shape : [],
|
|
330
|
+
exit_status: Number.isInteger(last.exit_status) ? last.exit_status : null,
|
|
331
|
+
started_at: earliest,
|
|
332
|
+
completed_at: latest,
|
|
333
|
+
duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
|
|
334
|
+
wall_clock_duration_ms: wallClockDurationMs == null ? null : Math.round(wallClockDurationMs),
|
|
335
|
+
stages,
|
|
336
|
+
repair_loop_count: repairLoopCount,
|
|
337
|
+
};
|
|
338
|
+
}
|