@nextcommerce/campaigns-os 1.54.0 → 1.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +6 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- package/src/stage-record.mjs +53 -16
package/src/doctor/next-step.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The next step doctor recommends, and the gate issues `next` reads from doctor.
|
|
2
2
|
import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { NO_CAPTURABLE_ROUTES_CODE, hostedTemplateNeedsUsablePreview, polishCarriedForwardForLadder, polishGateCanCarryPreview } from "../local-preview-policy.mjs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { orderPathDepthDriftText } from "../proof-policy.mjs";
|
|
6
|
-
import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
|
|
6
|
+
import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
|
|
7
7
|
import {
|
|
8
8
|
SOURCE_PREP_DOCUMENT_WRAPPER,
|
|
9
9
|
SOURCE_PREP_FRONTMATTER_RESIDUE,
|
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
stageIsBlocked,
|
|
17
17
|
stageIsTerminal,
|
|
18
18
|
} from "../orchestration-stage-contract.mjs";
|
|
19
|
-
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
19
|
+
import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
20
|
import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
|
|
21
21
|
import { cmd } from "../install-invocation.mjs";
|
|
22
|
+
import { shellToken } from "../shell-token.mjs";
|
|
22
23
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
23
24
|
import { orderPathDepthDrift } from "./checks.mjs";
|
|
24
25
|
|
|
@@ -412,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
|
|
|
412
413
|
};
|
|
413
414
|
}
|
|
414
415
|
|
|
415
|
-
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
|
|
416
|
+
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
|
|
416
417
|
const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
|
|
417
418
|
const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
|
|
418
419
|
// prepare-build is the earliest lifecycle prerequisite. Surface its
|
|
@@ -473,9 +474,15 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
473
474
|
}
|
|
474
475
|
|
|
475
476
|
for (const cliStage of NEXT_STAGE_ORDER) {
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
if (cliStage === "polish" && polishGate
|
|
477
|
+
// A first local preview may carry missing Polish forward. A theme waiver
|
|
478
|
+
// or prior capture makes the recorded Polish stage owed again.
|
|
479
|
+
if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
|
|
480
|
+
// A hosted all-template preview has no hosted deploy recorder. The
|
|
481
|
+
// recorded preview URL satisfies this one deploy handoff while the
|
|
482
|
+
// carried-forward gate identifies the exact preview-only shape.
|
|
483
|
+
if (cliStage === "deploy" && packet?.deploy?.preview_url
|
|
484
|
+
&& polishGate?.carried_forward?.policy === "hosted_template_preview"
|
|
485
|
+
&& polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
|
|
479
486
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
480
487
|
const stage = report.stages[reportKey];
|
|
481
488
|
if (!stage) {
|
|
@@ -500,6 +507,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
500
507
|
reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
|
|
501
508
|
};
|
|
502
509
|
}
|
|
510
|
+
if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
|
|
511
|
+
&& !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
|
|
512
|
+
return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
|
|
513
|
+
}
|
|
503
514
|
// A terminal QA status is not the same claim as purchase proof. QA finalizes
|
|
504
515
|
// a verdict and records a terminal status even when no order path ran, so a
|
|
505
516
|
// `--test-order off` diagnostic used to carry the pipeline to "done" against
|
|
@@ -513,9 +524,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
513
524
|
}
|
|
514
525
|
}
|
|
515
526
|
|
|
527
|
+
const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
|
|
516
528
|
return {
|
|
517
529
|
stage: "done",
|
|
518
|
-
|
|
530
|
+
outcome: hostedPreview ? "hosted_preview_satisfies_deploy" : "standard_closeout",
|
|
531
|
+
reason: hostedPreview
|
|
532
|
+
? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
|
|
533
|
+
: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
519
534
|
};
|
|
520
535
|
}
|
|
521
536
|
|
|
@@ -573,7 +588,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
|
|
|
573
588
|
// The code -> action strings doctor prints under `Next:`. They describe the
|
|
574
589
|
// repairs the findings ask for and are independent of which stage the picker
|
|
575
590
|
// names, so they survive the picker consolidation unchanged.
|
|
576
|
-
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
|
|
591
|
+
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
|
|
577
592
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
578
593
|
const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
|
|
579
594
|
const actions = [];
|
|
@@ -629,11 +644,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
|
|
|
629
644
|
actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
|
|
630
645
|
}
|
|
631
646
|
if (polishBlocked) {
|
|
632
|
-
|
|
647
|
+
const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
|
|
648
|
+
if (needsHostedPreview) {
|
|
649
|
+
actions.push(`Every mapped page is template stock and needs a hosted preview URL that differs from the production URL. Run ${cmd("qa")} policy set --packet ${packetRef} --preview-url <url>, then run ${cmd("next")} --packet ${packetRef}.`);
|
|
650
|
+
}
|
|
651
|
+
if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
|
|
633
652
|
actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
|
|
634
653
|
}
|
|
635
|
-
if (polishCheckpointGate?.status === "blocked") {
|
|
636
|
-
actions.push(
|
|
654
|
+
if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
|
|
655
|
+
actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
|
|
656
|
+
? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
|
|
657
|
+
: `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
|
|
637
658
|
}
|
|
638
659
|
}
|
|
639
660
|
return actions;
|
|
@@ -652,7 +673,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
652
673
|
&& (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
|
|
653
674
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
654
675
|
const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
|
|
655
|
-
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
|
|
676
|
+
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
|
|
656
677
|
// The picker's vocabulary and this table must not drift apart: a stage the
|
|
657
678
|
// table does not know would otherwise be relabelled as an operator step and
|
|
658
679
|
// sliced into the whole ladder. Fail loudly instead.
|
|
@@ -661,8 +682,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
661
682
|
}
|
|
662
683
|
// An explicit --context / --report is carried into every recommended
|
|
663
684
|
// command, so a recovery reads the same artifacts the recommendation did.
|
|
664
|
-
const packetRef = `${derived.packet_path
|
|
665
|
-
const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
|
|
685
|
+
const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
|
|
686
|
+
const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
|
|
666
687
|
const deployStatus = String(report?.stages?.deploy?.status || "");
|
|
667
688
|
const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
|
|
668
689
|
|| Boolean(deployUrlFromReportOutputs(report));
|
|
@@ -709,8 +730,16 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
709
730
|
? `${cmd("next")} --packet ${packetRef}`
|
|
710
731
|
: `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
|
|
711
732
|
const fallbackAction = picked.stage === "done"
|
|
712
|
-
?
|
|
733
|
+
? (picked.outcome === "hosted_preview_satisfies_deploy"
|
|
734
|
+
? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
|
|
735
|
+
: `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
|
|
713
736
|
: `Run ${command}.`;
|
|
737
|
+
if (picked.stage === "done") {
|
|
738
|
+
actions.unshift(fallbackAction);
|
|
739
|
+
if (report?.stages?.qa?.status === "completed_with_warnings") {
|
|
740
|
+
actions.push("QA passed with exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass.");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
714
743
|
return {
|
|
715
744
|
stage: picked.stage,
|
|
716
745
|
status: blocked ? "blocked" : readinessStatus(warnings, derived),
|
|
@@ -723,6 +752,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
723
752
|
default_skill: owners.default_skill,
|
|
724
753
|
command,
|
|
725
754
|
reason: picked.reason,
|
|
755
|
+
...(picked.outcome ? { outcome: picked.outcome } : {}),
|
|
726
756
|
actions: actions.length ? actions : [fallbackAction],
|
|
727
757
|
// Gate-blocked stages stay listed even when the recommended stage is
|
|
728
758
|
// runnable (a Design Source Package change after assembly names build,
|
package/src/doctor-sidecar.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { portableArtifactPaths } from "./cli-helpers.mjs";
|
|
4
5
|
|
|
5
6
|
export const DOCTOR_SIDECAR_REL_PATH = ".campaign-runtime/doctor-output.json";
|
|
6
7
|
export const DOCTOR_SIDECAR_SCHEMA = "campaigns-os-doctor-output/v0";
|
|
@@ -61,8 +62,9 @@ export function stampDoctorProducer(doctor, command) {
|
|
|
61
62
|
// through this (or through `stampDoctorProducer` when its own transactional
|
|
62
63
|
// writer must do the rename), so `generated_by` cannot be skipped by one of
|
|
63
64
|
// them the way #327's cause labels once were.
|
|
64
|
-
export function writeDoctorSidecar(path, doctor, { command } = {}) {
|
|
65
|
-
|
|
65
|
+
export function writeDoctorSidecar(path, doctor, { command, targetRepo = null } = {}) {
|
|
66
|
+
const stamped = stampDoctorProducer(doctor, command);
|
|
67
|
+
writeJsonAtomic(path, targetRepo ? portableArtifactPaths(stamped, targetRepo) : stamped);
|
|
66
68
|
return path;
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -89,7 +91,7 @@ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason =
|
|
|
89
91
|
stale_reason: reason
|
|
90
92
|
|| "A later command changed doctor inputs after this snapshot was written. Re-run campaigns-os doctor (or campaigns-os next) for current state.",
|
|
91
93
|
};
|
|
92
|
-
writeJsonAtomic(path, stamped);
|
|
94
|
+
writeJsonAtomic(path, portableArtifactPaths(stamped, targetBaseDir));
|
|
93
95
|
return path;
|
|
94
96
|
}
|
|
95
97
|
|
package/src/gate-actions.mjs
CHANGED
|
@@ -68,10 +68,30 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
|
|
|
68
68
|
id: "polish.hidden_eager_media.map_design_route",
|
|
69
69
|
kind: "manual",
|
|
70
70
|
command: null,
|
|
71
|
-
description: "Every mapped page is template stock, so
|
|
71
|
+
description: "Every mapped page is template stock, so there is no design route to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or use a local or hosted preview for QA with the missing evidence carried forward as a warning.",
|
|
72
72
|
}),
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
export function qaRunCommand(packetPath, url, bumpCart = null) {
|
|
76
|
+
return `${invocationPrefixFor(PACKAGE_ROOT)} qa run --packet ${packetPath ? shellToken(packetPath) : "<packet>"} --base-url ${url === "<preview-url>" ? url : shellToken(url)} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hostedTemplateQaAction(previewUrl) {
|
|
80
|
+
return {
|
|
81
|
+
id: "polish.hidden_eager_media.hosted_preview_qa",
|
|
82
|
+
kind: "command",
|
|
83
|
+
command: qaRunCommand(null, previewUrl),
|
|
84
|
+
description: "Run QA against the packet's hosted preview URL; missing Polish evidence remains a warning.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const HOSTED_TEMPLATE_PREVIEW_URL_ACTION = Object.freeze({
|
|
89
|
+
id: "polish.hidden_eager_media.set_hosted_preview_url",
|
|
90
|
+
kind: "command",
|
|
91
|
+
command: "campaigns-os qa policy set --packet <packet> --preview-url <url>",
|
|
92
|
+
description: "Record a hosted preview URL that differs from the production URL with qa policy set, then run next again; missing Polish evidence can be carried forward on that preview.",
|
|
93
|
+
});
|
|
94
|
+
|
|
75
95
|
const PACKET_PLACEHOLDER = "--packet <packet>";
|
|
76
96
|
|
|
77
97
|
// The placeholder becomes the packet this run read, shell-quoted, so the
|
package/src/input-refresh.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
loadCampaignBuildBriefFile,
|
|
27
27
|
} from "./build-brief.mjs";
|
|
28
28
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
29
|
-
import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
|
|
29
|
+
import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
|
|
30
30
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
31
31
|
import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
|
|
32
32
|
import {
|
|
@@ -475,9 +475,9 @@ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget,
|
|
|
475
475
|
if (!dryRun) {
|
|
476
476
|
// Intake's publication order, the report last: a failure between files
|
|
477
477
|
// leaves the report bound to the old material, which reads as owed.
|
|
478
|
-
writeJsonAtomic(packetPath, nextPacket);
|
|
479
|
-
writeJsonAtomic(normalizedPath, buildBrief.artifact);
|
|
480
|
-
writeJsonAtomic(contextPath, saved.nextContext);
|
|
478
|
+
writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
|
|
479
|
+
writeJsonAtomic(normalizedPath, portableArtifactPaths(buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
|
|
480
|
+
writeJsonAtomic(contextPath, portableArtifactPaths(saved.nextContext, targetRepo, { artifactPath: contextPath }));
|
|
481
481
|
commitAssemblyReport(workspace, () => nextReport, {
|
|
482
482
|
command: "record brief",
|
|
483
483
|
staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
|
|
@@ -674,8 +674,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
674
674
|
if (!dryRun) {
|
|
675
675
|
// Intake's publication order, the report last: a failure between files
|
|
676
676
|
// leaves the report bound to the old spec material, which reads as owed.
|
|
677
|
-
if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
|
|
678
|
-
writeJsonAtomic(contextPath, nextContext);
|
|
677
|
+
if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
|
|
678
|
+
writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
|
|
679
679
|
commitAssemblyReport(workspace, () => nextReport, {
|
|
680
680
|
command: "record spec",
|
|
681
681
|
staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
|
package/src/invocation.mjs
CHANGED
|
@@ -132,14 +132,15 @@ export function resolveInvocationPolicy(command, args) {
|
|
|
132
132
|
const steps = CLASS_STEPS[rule.class];
|
|
133
133
|
const noWrite = args["no-write"] === true;
|
|
134
134
|
const implementsDryRun = rule.dryRun === true;
|
|
135
|
-
const
|
|
135
|
+
const help = Boolean(args.help);
|
|
136
|
+
const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
|
|
136
137
|
const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
|
|
137
138
|
return Object.freeze({
|
|
138
139
|
class: rule.class,
|
|
139
140
|
wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
|
|
140
141
|
sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
|
|
141
|
-
journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
|
|
142
|
-
implementsDryRun, autoEnd: rule.autoEnd === true,
|
|
142
|
+
journalExempt: help || !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
|
|
143
|
+
implementsDryRun, autoEnd: !help && rule.autoEnd === true,
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
|
package/src/lifecycle.mjs
CHANGED
|
@@ -66,6 +66,7 @@ export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.j
|
|
|
66
66
|
// from anywhere.
|
|
67
67
|
export const REFUSED_INVOCATION = "refused_invocation";
|
|
68
68
|
const refusalScope = new AsyncLocalStorage();
|
|
69
|
+
const invocationScope = new AsyncLocalStorage();
|
|
69
70
|
|
|
70
71
|
/** Run `fn` with its own refusal verdict. Returns whatever `fn` returns. */
|
|
71
72
|
export function runWithRefusalScope(fn) {
|
|
@@ -96,6 +97,14 @@ export function refusalSeen() {
|
|
|
96
97
|
return refusalScope.getStore()?.seen === true;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Prompt sites share this hook. Nested prompts count only the outer blocked
|
|
101
|
+
// interval, so a prompt helper can call another without inflating wait time.
|
|
102
|
+
export async function timeOperatorWait(fn) {
|
|
103
|
+
const active = invocationScope.getStore();
|
|
104
|
+
if (!active) return fn();
|
|
105
|
+
return active.wait(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
/**
|
|
100
109
|
* Run `fn()` and re-throw anything it throws as a tagged refusal, message
|
|
101
110
|
* byte-identical. The contract it encodes: THE TAG IS APPLIED AT THE UP-FRONT
|
|
@@ -123,6 +132,10 @@ function isStringArray(value) {
|
|
|
123
132
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
function isFindingCodes(value) {
|
|
136
|
+
return isStringArray(value) && value.length <= 5 && new Set(value).size === value.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
// Injectable so tests are deterministic. `now` is wall-clock (ISO source);
|
|
127
140
|
// `monotonic` is a steadily-increasing millisecond counter for durations.
|
|
128
141
|
const defaultClock = {
|
|
@@ -146,13 +159,22 @@ function defaultReadExitStatus() {
|
|
|
146
159
|
export function createLifecycleRecorder(clock = defaultClock) {
|
|
147
160
|
const stages = [];
|
|
148
161
|
let repairLoopCount = 0;
|
|
162
|
+
let waitMs = 0;
|
|
163
|
+
let waitDepth = 0;
|
|
164
|
+
const activeStages = [];
|
|
165
|
+
const findingCodes = [];
|
|
149
166
|
function stage(name) {
|
|
150
167
|
const t0 = clock.monotonic();
|
|
168
|
+
const phase = { name: String(name), wait_ms: 0 };
|
|
169
|
+
activeStages.push(phase);
|
|
151
170
|
let stopped = false;
|
|
152
171
|
return function stop() {
|
|
153
172
|
if (stopped) return;
|
|
154
173
|
stopped = true;
|
|
155
|
-
|
|
174
|
+
activeStages.splice(activeStages.indexOf(phase), 1);
|
|
175
|
+
// Wait accumulates unrounded and is rounded once here, so it never
|
|
176
|
+
// exceeds the stage's own rounded duration.
|
|
177
|
+
stages.push({ ...phase, wait_ms: Math.round(phase.wait_ms), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
|
|
156
178
|
};
|
|
157
179
|
}
|
|
158
180
|
// Convenience: time `fn` as a named sub-phase. Records the stage even if fn
|
|
@@ -168,11 +190,38 @@ export function createLifecycleRecorder(clock = defaultClock) {
|
|
|
168
190
|
return {
|
|
169
191
|
stage,
|
|
170
192
|
time,
|
|
193
|
+
async wait(fn) {
|
|
194
|
+
if (waitDepth++) {
|
|
195
|
+
try { return await fn(); } finally { waitDepth -= 1; }
|
|
196
|
+
}
|
|
197
|
+
const t0 = clock.monotonic();
|
|
198
|
+
const phase = activeStages.at(-1);
|
|
199
|
+
try { return await fn(); }
|
|
200
|
+
finally {
|
|
201
|
+
const elapsed = Math.max(0, clock.monotonic() - t0);
|
|
202
|
+
waitMs += elapsed;
|
|
203
|
+
if (phase) phase.wait_ms += elapsed;
|
|
204
|
+
waitDepth -= 1;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
recordFindingCodes(codes) {
|
|
208
|
+
// Codes attach to the phase active when they are recorded; outside any
|
|
209
|
+
// phase they stay invocation-level only.
|
|
210
|
+
const phase = activeStages.at(-1);
|
|
211
|
+
for (const code of Array.isArray(codes) ? codes : []) {
|
|
212
|
+
if (typeof code !== "string") continue;
|
|
213
|
+
if (!findingCodes.includes(code) && findingCodes.length < 5) findingCodes.push(code);
|
|
214
|
+
if (phase) {
|
|
215
|
+
phase.finding_codes ??= [];
|
|
216
|
+
if (!phase.finding_codes.includes(code) && phase.finding_codes.length < 5) phase.finding_codes.push(code);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
171
220
|
recordRepairLoop() {
|
|
172
221
|
repairLoopCount += 1;
|
|
173
222
|
},
|
|
174
223
|
snapshot() {
|
|
175
|
-
return { stages: stages.slice(), repair_loop_count: repairLoopCount };
|
|
224
|
+
return { stages: stages.slice(), repair_loop_count: repairLoopCount, wait_ms: Math.round(waitMs), finding_codes: findingCodes.slice() };
|
|
176
225
|
},
|
|
177
226
|
};
|
|
178
227
|
}
|
|
@@ -183,7 +232,9 @@ export const NOOP_RECORDER = {
|
|
|
183
232
|
stage: () => () => {},
|
|
184
233
|
time: async (_name, fn) => fn(),
|
|
185
234
|
recordRepairLoop: () => {},
|
|
186
|
-
|
|
235
|
+
wait: (fn) => fn(),
|
|
236
|
+
recordFindingCodes: () => {},
|
|
237
|
+
snapshot: () => ({ stages: [], repair_loop_count: 0, wait_ms: 0, finding_codes: [] }),
|
|
187
238
|
};
|
|
188
239
|
|
|
189
240
|
function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, completedAt, durationMs, recorder }) {
|
|
@@ -197,7 +248,10 @@ function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, comp
|
|
|
197
248
|
started_at: startedAt,
|
|
198
249
|
completed_at: completedAt,
|
|
199
250
|
duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
|
|
200
|
-
|
|
251
|
+
wait_ms: recorded.wait_ms,
|
|
252
|
+
...(Number.isInteger(exitStatus) && exitStatus !== 0 && recorded.finding_codes.length ? { finding_codes: recorded.finding_codes } : {}),
|
|
253
|
+
stages: recorded.stages.map(({ finding_codes, ...stage }) =>
|
|
254
|
+
Number.isInteger(exitStatus) && exitStatus !== 0 && finding_codes?.length ? { ...stage, finding_codes } : stage),
|
|
201
255
|
repair_loop_count: recorded.repair_loop_count,
|
|
202
256
|
};
|
|
203
257
|
}
|
|
@@ -225,7 +279,7 @@ export async function withCommandLifecycle({
|
|
|
225
279
|
let thrown = null;
|
|
226
280
|
let exitStatus = 0;
|
|
227
281
|
try {
|
|
228
|
-
result = await fn(recorder);
|
|
282
|
+
result = await invocationScope.run(recorder, () => fn(recorder));
|
|
229
283
|
// A clean return is exit 0 unless the command set process.exitCode.
|
|
230
284
|
const raw = readExitStatus();
|
|
231
285
|
exitStatus = Number.isInteger(raw) ? raw : 0;
|
|
@@ -278,6 +332,8 @@ export function validateLifecycle(entry) {
|
|
|
278
332
|
if (entry.exit_status != null && !Number.isInteger(entry.exit_status)) add("lifecycle.exit_status", "exit_status must be an integer or null.");
|
|
279
333
|
if (entry.run_id != null && typeof entry.run_id !== "string") add("lifecycle.run_id", "run_id must be a string or null.");
|
|
280
334
|
if (entry.duration_ms != null && typeof entry.duration_ms !== "number") add("lifecycle.duration_ms", "duration_ms must be a number or null.");
|
|
335
|
+
if (entry.wait_ms != null && (!Number.isInteger(entry.wait_ms) || entry.wait_ms < 0)) add("lifecycle.wait_ms", "wait_ms must be a non-negative integer or null.");
|
|
336
|
+
if (entry.finding_codes !== undefined && (!isFindingCodes(entry.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add("lifecycle.finding_codes", "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
|
|
281
337
|
if (entry.repair_loop_count != null && !Number.isInteger(entry.repair_loop_count)) add("lifecycle.repair_loop_count", "repair_loop_count must be an integer.");
|
|
282
338
|
if (entry.stages != null) {
|
|
283
339
|
if (!Array.isArray(entry.stages)) {
|
|
@@ -285,7 +341,11 @@ export function validateLifecycle(entry) {
|
|
|
285
341
|
} else {
|
|
286
342
|
entry.stages.forEach((stage, index) => {
|
|
287
343
|
if (!stage || typeof stage !== "object" || Array.isArray(stage)) add(`lifecycle.stages[${index}]`, "each stage must be an object.");
|
|
288
|
-
else
|
|
344
|
+
else {
|
|
345
|
+
if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
|
|
346
|
+
if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`lifecycle.stages[${index}].wait_ms`, "wait_ms must be a non-negative integer or null.");
|
|
347
|
+
if (stage.finding_codes !== undefined && (!isFindingCodes(stage.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add(`lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
|
|
348
|
+
}
|
|
289
349
|
});
|
|
290
350
|
}
|
|
291
351
|
}
|
|
@@ -366,6 +426,8 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
|
|
|
366
426
|
let latest = null;
|
|
367
427
|
let durationSum = 0;
|
|
368
428
|
let explicitRepairLoops = 0;
|
|
429
|
+
let needsInputCount = 0;
|
|
430
|
+
let failureCount = 0;
|
|
369
431
|
|
|
370
432
|
// Only finite, parseable ISO timestamps participate in span timing; a junk
|
|
371
433
|
// string (e.g. a hand-edited journal) is ignored rather than emitted as a
|
|
@@ -376,6 +438,11 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
|
|
|
376
438
|
const command = typeof entry.command === "string" ? entry.command : "(unknown)";
|
|
377
439
|
commandCounts.set(command, (commandCounts.get(command) || 0) + 1);
|
|
378
440
|
const exitStatus = Number.isInteger(entry.exit_status) ? entry.exit_status : null;
|
|
441
|
+
if (exitStatus === 2) needsInputCount += 1;
|
|
442
|
+
else if (exitStatus !== null && exitStatus !== 0) failureCount += 1;
|
|
443
|
+
const findings = exitStatus !== null && exitStatus !== 0 && isStringArray(entry.finding_codes)
|
|
444
|
+
? [...new Set(entry.finding_codes)].slice(0, 5) : [];
|
|
445
|
+
const findingFields = findings.length ? { finding_codes: findings } : {};
|
|
379
446
|
// A command may have recorded its own repair loops via recordRepairLoop().
|
|
380
447
|
if (Number.isInteger(entry.repair_loop_count)) explicitRepairLoops += entry.repair_loop_count;
|
|
381
448
|
|
|
@@ -384,12 +451,25 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
|
|
|
384
451
|
name: `${command}:${typeof stage?.name === "string" ? stage.name : "stage"}`,
|
|
385
452
|
duration_ms: typeof stage?.duration_ms === "number" ? stage.duration_ms : null,
|
|
386
453
|
exit_status: exitStatus,
|
|
454
|
+
...(Number.isInteger(stage?.wait_ms) && stage.wait_ms >= 0 ? { wait_ms: stage.wait_ms } : {}),
|
|
455
|
+
...(exitStatus !== null && exitStatus !== 0 && isFindingCodes(stage?.finding_codes) && stage.finding_codes.length
|
|
456
|
+
? { finding_codes: stage.finding_codes } : {}),
|
|
387
457
|
}))
|
|
388
458
|
: [{
|
|
389
459
|
name: command,
|
|
390
460
|
duration_ms: typeof entry.duration_ms === "number" ? entry.duration_ms : null,
|
|
391
461
|
exit_status: exitStatus,
|
|
462
|
+
...(Number.isInteger(entry.wait_ms) && entry.wait_ms >= 0 ? { wait_ms: entry.wait_ms } : {}),
|
|
463
|
+
...findingFields,
|
|
392
464
|
}];
|
|
465
|
+
if (subStages.length > 1 || (Array.isArray(entry.stages) && entry.stages.length)) {
|
|
466
|
+
const attributed = new Set(subStages.flatMap((stage) => stage.finding_codes ?? []));
|
|
467
|
+
const unknown = findings.filter((code) => !attributed.has(code));
|
|
468
|
+
if (unknown.length) {
|
|
469
|
+
const lastStage = subStages.at(-1);
|
|
470
|
+
lastStage.finding_codes = [...new Set([...(lastStage.finding_codes ?? []), ...unknown])].slice(0, 5);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
393
473
|
stages.push(...subStages);
|
|
394
474
|
|
|
395
475
|
if (typeof entry.duration_ms === "number") durationSum += entry.duration_ms;
|
|
@@ -430,5 +510,7 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
|
|
|
430
510
|
wall_clock_duration_ms: wallClockDurationMs == null ? null : Math.round(wallClockDurationMs),
|
|
431
511
|
stages,
|
|
432
512
|
repair_loop_count: repairLoopCount,
|
|
513
|
+
needs_input_count: needsInputCount,
|
|
514
|
+
failure_count: failureCount,
|
|
433
515
|
};
|
|
434
516
|
}
|