@nextcommerce/campaigns-os 1.53.0 → 1.55.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 +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- 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 +3 -3
- 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 +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
package/src/doctor/checks.mjs
CHANGED
|
@@ -108,7 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
|
|
|
108
108
|
// `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
|
|
109
109
|
// node engine in package.json without type-stripping. build runs on `prepare`,
|
|
110
110
|
// so a fresh install (including the git-ref consumer) always has dist.
|
|
111
|
-
import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
111
|
+
import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
112
112
|
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
113
113
|
import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
|
|
114
114
|
import {
|
|
@@ -440,7 +440,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
440
440
|
{
|
|
441
441
|
id: SDK_MARKUP,
|
|
442
442
|
phase: "built-output",
|
|
443
|
-
run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
|
|
443
|
+
run: ({ spec, packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived, spec),
|
|
444
444
|
},
|
|
445
445
|
{
|
|
446
446
|
id: SCRIPT_SYNTAX,
|
|
@@ -832,7 +832,7 @@ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, re
|
|
|
832
832
|
const packetContract = packet.source_html?.adapter_contract;
|
|
833
833
|
validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
|
|
834
834
|
validateAdapterSourceFiles({
|
|
835
|
-
decisions: packetContract,
|
|
835
|
+
decisions: buildState.report?.adapter_decisions || buildState.context?.adapter_decisions || packetContract,
|
|
836
836
|
sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
|
|
837
837
|
pages: packet.source_html?.pages || [],
|
|
838
838
|
warnings,
|
|
@@ -2147,6 +2147,10 @@ export function validateCampaignIdentity(packet, errors, ready, derived, spec =
|
|
|
2147
2147
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2148
2148
|
},
|
|
2149
2149
|
pages,
|
|
2150
|
+
expectedApiKey: (() => {
|
|
2151
|
+
const resolved = resolveCampaignsApiKeySource(packet, null, process.env, { spec });
|
|
2152
|
+
return resolved.key ? { key: resolved.key, source: resolved.origin } : null;
|
|
2153
|
+
})(),
|
|
2150
2154
|
errors,
|
|
2151
2155
|
ready,
|
|
2152
2156
|
derived,
|
|
@@ -2290,8 +2294,8 @@ function readBoundedScript(realSiteRoot, path, maxBytes) {
|
|
|
2290
2294
|
}
|
|
2291
2295
|
}
|
|
2292
2296
|
|
|
2293
|
-
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
2294
|
-
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
2297
|
+
function recordCampaignIdentityGate({ subject, pages, expectedApiKey = null, errors, ready, derived }) {
|
|
2298
|
+
const gate = evaluateCampaignIdentity({ subject, pages, expectedApiKey });
|
|
2295
2299
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
2296
2300
|
|
|
2297
2301
|
if (gate.status === "blocked") {
|
|
@@ -2315,17 +2319,23 @@ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived })
|
|
|
2315
2319
|
// Static SDK markup checks (#303). Every doctor invocation, both entry points,
|
|
2316
2320
|
// filesystem enumeration, blocking regardless of stage status — the same
|
|
2317
2321
|
// contract as the two gates above, for the same reasons.
|
|
2318
|
-
function validateSdkMarkup(packet, errors, warnings, ready, derived) {
|
|
2322
|
+
function validateSdkMarkup(packet, errors, warnings, ready, derived, spec = null) {
|
|
2319
2323
|
const targetRepo = derived.target_repo;
|
|
2320
2324
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2321
2325
|
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2322
2326
|
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
2327
|
+
const declaredTypes = new Map(activeSpecPages(spec).filter((page) => ["receipt", "thankyou"].includes(page.type)).map((page) => [
|
|
2328
|
+
builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived), "receipt",
|
|
2329
|
+
]));
|
|
2323
2330
|
recordSdkMarkupGate({
|
|
2324
2331
|
subject: {
|
|
2325
2332
|
public_route_slug: publicRouteSlug || null,
|
|
2326
2333
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2327
2334
|
},
|
|
2328
|
-
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo)
|
|
2335
|
+
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo).map((page) => ({
|
|
2336
|
+
...page,
|
|
2337
|
+
page_type: declaredTypes.get(resolve(targetRepo, page.file)) || null,
|
|
2338
|
+
})) : [],
|
|
2329
2339
|
errors,
|
|
2330
2340
|
warnings,
|
|
2331
2341
|
ready,
|
|
@@ -2522,7 +2532,7 @@ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, wa
|
|
|
2522
2532
|
}
|
|
2523
2533
|
|
|
2524
2534
|
function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
|
|
2525
|
-
const gate = evaluateSdkMarkup({ subject, pages });
|
|
2535
|
+
const gate = evaluateSdkMarkup({ subject, pages, sdkVersion: campaignSdkPin(derived?.target_repo, subject?.public_route_slug) });
|
|
2526
2536
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
2527
2537
|
|
|
2528
2538
|
if (gate.status === "not_applicable") {
|
|
@@ -2553,6 +2563,16 @@ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived
|
|
|
2553
2563
|
return gate;
|
|
2554
2564
|
}
|
|
2555
2565
|
|
|
2566
|
+
// The campaign's SDK pin from the target's campaigns.json entry, when it is an
|
|
2567
|
+
// exact released version; else null. The SDK markup check reads a page's own
|
|
2568
|
+
// loader pin first and falls back to this one.
|
|
2569
|
+
function campaignSdkPin(targetRepo, publicRouteSlug) {
|
|
2570
|
+
if (!targetRepo || !publicRouteSlug) return null;
|
|
2571
|
+
const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
|
|
2572
|
+
const version = load.status === "ok" ? load.entry?.sdk_version : null;
|
|
2573
|
+
return isReleasedSdkVersion(version) ? version : null;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2556
2576
|
// Route drift: a CampaignSpec page whose declared route has no built page at
|
|
2557
2577
|
// that path. page-kit derives the public route from the source FILENAME, so a
|
|
2558
2578
|
// file named presell-running.html builds at /presell-running/ even if the spec
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { shellToken } from "../shell-token.mjs";
|
|
8
|
+
import { cmd } from "../install-invocation.mjs";
|
|
8
9
|
import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
|
|
9
10
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
10
11
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
@@ -244,6 +245,18 @@ export function doctorBuiltOutput(args) {
|
|
|
244
245
|
qc_results: [],
|
|
245
246
|
};
|
|
246
247
|
ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
|
|
248
|
+
// #583: --base-url reads like a check of the deployed site, but these checks
|
|
249
|
+
// read only the local built files. Say so wherever the URL is given, and
|
|
250
|
+
// name the command that does load the served pages.
|
|
251
|
+
if (baseUrl) {
|
|
252
|
+
derived.base_url = { value: baseUrl, fetched: false, recorded_as: "deploy.preview_url" };
|
|
253
|
+
// Without --family there is no value to print that the CLI would accept,
|
|
254
|
+
// so the command is printed only when it is runnable as shown.
|
|
255
|
+
const servedCheck = family
|
|
256
|
+
? `To check the served pages, run ${cmd("qa", `run --site ${shellToken(String(args.built || args.site))} --base-url ${shellToken(baseUrl)} --family ${shellToken(family)} --browser`)}.`
|
|
257
|
+
: `To check the served pages, run ${cmd("qa")} run --site with this --base-url and the campaign's --family, plus --browser.`;
|
|
258
|
+
ready.push(`Checked the local built files only: --base-url ${baseUrl} was not fetched; it only fills deploy.preview_url in the minimal Build Packet. ${servedCheck}`);
|
|
259
|
+
}
|
|
247
260
|
|
|
248
261
|
const resolution = resolveBrandContractOnce(derived, family);
|
|
249
262
|
let brandContract = null;
|
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 { polishCarriedForwardForLadder } 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,7 +16,7 @@ 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
22
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
@@ -473,9 +473,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
for (const cliStage of NEXT_STAGE_ORDER) {
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
if (cliStage === "polish" && polishGate
|
|
476
|
+
// A first local preview may carry missing Polish forward. A theme waiver
|
|
477
|
+
// or prior capture makes the recorded Polish stage owed again.
|
|
478
|
+
if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
|
|
479
479
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
480
480
|
const stage = report.stages[reportKey];
|
|
481
481
|
if (!stage) {
|
|
@@ -500,6 +500,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
500
500
|
reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
|
|
501
501
|
};
|
|
502
502
|
}
|
|
503
|
+
if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
|
|
504
|
+
&& !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
|
|
505
|
+
return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
|
|
506
|
+
}
|
|
503
507
|
// A terminal QA status is not the same claim as purchase proof. QA finalizes
|
|
504
508
|
// a verdict and records a terminal status even when no order path ran, so a
|
|
505
509
|
// `--test-order off` diagnostic used to carry the pipeline to "done" against
|
|
@@ -711,6 +715,12 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
711
715
|
const fallbackAction = picked.stage === "done"
|
|
712
716
|
? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
|
|
713
717
|
: `Run ${command}.`;
|
|
718
|
+
if (picked.stage === "done") {
|
|
719
|
+
actions.unshift(fallbackAction);
|
|
720
|
+
if (report?.stages?.qa?.status === "completed_with_warnings") {
|
|
721
|
+
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.");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
714
724
|
return {
|
|
715
725
|
stage: picked.stage,
|
|
716
726
|
status: blocked ? "blocked" : readinessStatus(warnings, derived),
|
package/src/invocation.mjs
CHANGED
|
@@ -54,7 +54,7 @@ const COMMANDS = frozen({
|
|
|
54
54
|
logout: { class: "auth" },
|
|
55
55
|
demo: { class: "inline" },
|
|
56
56
|
tooling: { subcommands: ["diagnose", "setup", "status"] },
|
|
57
|
-
sdk: { subcommands: ["storage-check"] },
|
|
57
|
+
sdk: { subcommands: ["repin", "storage-check"] },
|
|
58
58
|
readback: { class: "projection" },
|
|
59
59
|
start: { sweepRoot: "target" },
|
|
60
60
|
"prepare-build": { sweepRoot: "target" },
|
|
@@ -86,6 +86,8 @@ const SUBCOMMAND_OVERRIDES = frozen({
|
|
|
86
86
|
"tooling diagnose": { class: "inline" },
|
|
87
87
|
"tooling setup": { class: "inline" },
|
|
88
88
|
"sdk storage-check": { class: "inspection" },
|
|
89
|
+
// Preview by default: only --apply writes, so only --apply journals.
|
|
90
|
+
"sdk repin": { journalExemptWhen: { given: "target", unlessBare: "apply" } },
|
|
89
91
|
"theme waive": { dryRun: true },
|
|
90
92
|
"checkpoint accept": { dryRun: true },
|
|
91
93
|
"checkpoint waive": { dryRun: true },
|
|
@@ -130,14 +132,15 @@ export function resolveInvocationPolicy(command, args) {
|
|
|
130
132
|
const steps = CLASS_STEPS[rule.class];
|
|
131
133
|
const noWrite = args["no-write"] === true;
|
|
132
134
|
const implementsDryRun = rule.dryRun === true;
|
|
133
|
-
const
|
|
135
|
+
const help = Boolean(args.help);
|
|
136
|
+
const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
|
|
134
137
|
const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
|
|
135
138
|
return Object.freeze({
|
|
136
139
|
class: rule.class,
|
|
137
140
|
wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
|
|
138
141
|
sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
|
|
139
|
-
journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
|
|
140
|
-
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,
|
|
141
144
|
});
|
|
142
145
|
}
|
|
143
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
|
}
|
|
@@ -75,6 +75,12 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
|
|
|
75
75
|
|
|
76
76
|
// The polish gate folds the checkpoint in, so it is carried forward only when
|
|
77
77
|
// whatever it owns from the checkpoint was carried forward too.
|
|
78
|
+
export function polishCarriedForwardForLadder(report, gate) {
|
|
79
|
+
return gate?.status === CARRIED_FORWARD
|
|
80
|
+
&& !report?.theme?.waiver
|
|
81
|
+
&& !report?.stages?.polish?.evidence?.visual_review?.page_load;
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
|
|
79
85
|
if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
|
|
80
86
|
const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
|
package/src/login.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCredentialStore } from './credential-store.mjs';
|
|
2
|
+
import { timeOperatorWait } from './lifecycle.mjs';
|
|
2
3
|
|
|
3
4
|
export const GATEWAY = 'https://mcp.nextcommerce.com';
|
|
4
5
|
export const CLIENT_ID = 'campaigns-os-owned-store-pilot';
|
|
@@ -27,7 +28,7 @@ export function authenticationArguments(argv) {
|
|
|
27
28
|
async function askStore() {
|
|
28
29
|
const { createInterface } = await import('node:readline/promises');
|
|
29
30
|
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
-
try { return await prompt.question('Store subdomain (or network domain): '); }
|
|
31
|
+
try { return await timeOperatorWait(() => prompt.question('Store subdomain (or network domain): ')); }
|
|
31
32
|
finally { prompt.close(); }
|
|
32
33
|
}
|
|
33
34
|
const bindingFor = store => ({ gateway: GATEWAY, client_id: CLIENT_ID, store });
|
package/src/polish-browser.mjs
CHANGED
|
@@ -1012,6 +1012,10 @@ function polishBrowserMissing(kind, error) {
|
|
|
1012
1012
|
].join(" "));
|
|
1013
1013
|
}
|
|
1014
1014
|
|
|
1015
|
+
// `networkIdleTimeoutMs`, like the deadlines, can only shorten its bound: the
|
|
1016
|
+
// wait for network idle after each navigation (NETWORKIDLE_TIMEOUT_MS). The
|
|
1017
|
+
// CLI never passes it; a test whose page holds a transfer open passes a
|
|
1018
|
+
// shorter window rather than wait out the production one on every cell.
|
|
1015
1019
|
export async function createPolishBrowserAdapter({
|
|
1016
1020
|
headed = false,
|
|
1017
1021
|
authCookie = null,
|
|
@@ -1019,6 +1023,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1019
1023
|
cellDeadlineMs,
|
|
1020
1024
|
cleanupDeadlineMs,
|
|
1021
1025
|
startupDeadlineMs,
|
|
1026
|
+
networkIdleTimeoutMs,
|
|
1022
1027
|
} = {}) {
|
|
1023
1028
|
const authCookies = parseAuthCookie(authCookie);
|
|
1024
1029
|
const boundedCellDeadlineMs = boundedPolishDeadline(cellDeadlineMs, POLISH_BROWSER_CELL_DEADLINE_MS);
|
|
@@ -1030,6 +1035,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1030
1035
|
startupDeadlineMs,
|
|
1031
1036
|
POLISH_BROWSER_STARTUP_DEADLINE_MS,
|
|
1032
1037
|
);
|
|
1038
|
+
const networkIdleMs = boundedPolishDeadline(networkIdleTimeoutMs, NETWORKIDLE_TIMEOUT_MS);
|
|
1033
1039
|
let browser;
|
|
1034
1040
|
let startupTimedOut = false;
|
|
1035
1041
|
const startupAbort = new AbortController();
|
|
@@ -1234,7 +1240,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1234
1240
|
const initialMediaElements = await awaitActive(collectMediaElements(page));
|
|
1235
1241
|
let networkidle;
|
|
1236
1242
|
try {
|
|
1237
|
-
await awaitActive(page.waitForLoadState("networkidle", { timeout:
|
|
1243
|
+
await awaitActive(page.waitForLoadState("networkidle", { timeout: networkIdleMs }));
|
|
1238
1244
|
networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
|
|
1239
1245
|
} catch (error) {
|
|
1240
1246
|
if (!timeoutError(error)) throw error;
|
|
@@ -1327,7 +1333,7 @@ export async function createPolishBrowserAdapter({
|
|
|
1327
1333
|
if (initialTree === BOUND_ENDED) return unmeasured(boundEnded());
|
|
1328
1334
|
if (initialTree.error) return unmeasured("document_changed");
|
|
1329
1335
|
const initialMainFrame = initialTree.value?.frameTree?.frame;
|
|
1330
|
-
const idleTimeout = Math.max(1, Math.min(
|
|
1336
|
+
const idleTimeout = Math.max(1, Math.min(networkIdleMs, Math.floor(addedLeft()), Math.floor(cellLeftMs())));
|
|
1331
1337
|
const idle = await awaitActive(steps.step(() => page.waitForLoadState("networkidle", { timeout: idleTimeout })));
|
|
1332
1338
|
if (idle === BOUND_ENDED) return unmeasured(boundEnded());
|
|
1333
1339
|
if (idle.error && !timeoutError(idle.error)) throw idle.error;
|
package/src/polish-node.mjs
CHANGED
|
@@ -669,6 +669,7 @@ async function runPolishCapture({
|
|
|
669
669
|
captureCellDeadlineMs,
|
|
670
670
|
adapterCloseDeadlineMs,
|
|
671
671
|
probeClock = null,
|
|
672
|
+
mediaProbeRunBudgetMs,
|
|
672
673
|
targetRepo = null,
|
|
673
674
|
now = new Date(),
|
|
674
675
|
} = {}) {
|
|
@@ -687,6 +688,10 @@ async function runPolishCapture({
|
|
|
687
688
|
adapterCloseDeadlineMs,
|
|
688
689
|
POLISH_CAPTURE_CLOSE_DEADLINE_MS,
|
|
689
690
|
);
|
|
691
|
+
// The 1.3 image probe's run budget. Like the deadlines it can only shorten:
|
|
692
|
+
// the CLI never passes it, and a test that spends the budget passes a
|
|
693
|
+
// smaller one so that fewer slow cells spend it.
|
|
694
|
+
const probeRunBudgetMs = boundedPolishDeadline(mediaProbeRunBudgetMs, MEDIA_PROBE_LIMITS.runBudgetMs);
|
|
690
695
|
|
|
691
696
|
let adapter = null;
|
|
692
697
|
// Failure is tracked apart from its reason: a rejection may carry any value,
|
|
@@ -806,7 +811,7 @@ async function runPolishCapture({
|
|
|
806
811
|
const abortController = new AbortController();
|
|
807
812
|
const imageProbe = {
|
|
808
813
|
...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
|
|
809
|
-
remainingMs:
|
|
814
|
+
remainingMs: probeRunBudgetMs - probeSpentMs,
|
|
810
815
|
cellBoundMs: MEDIA_PROBE_LIMITS.cellBoundMs,
|
|
811
816
|
imageCap: MEDIA_PROBE_LIMITS.imageCap,
|
|
812
817
|
};
|
|
@@ -433,8 +433,8 @@ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
|
|
|
433
433
|
// ancestor matches. checkout_label and checkout_hint are read separately.
|
|
434
434
|
const ROLES = [
|
|
435
435
|
["add_to_cart", "[data-next-action=\"add-to-cart\"]"],
|
|
436
|
-
["upsell_accept", "[data-next-upsell-action=\"add\"]"],
|
|
437
|
-
["upsell_decline", "[data-next-upsell-action=\"skip\"]"],
|
|
436
|
+
["upsell_accept", "[data-next-upsell-action=\"add\"], [data-next-upsell-action=\"accept\"]"],
|
|
437
|
+
["upsell_decline", "[data-next-upsell-action=\"skip\"], [data-next-upsell-action=\"decline\"]"],
|
|
438
438
|
["submit_control", "button.submit-button[os-checkout-payment=\"combo\"], button[os-checkout-payment=\"combo\"], button[type=\"submit\"], input[type=\"submit\"], input[type=\"button\"]"],
|
|
439
439
|
["sdk_action", "[data-next-action]"],
|
|
440
440
|
["bundle_card", "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]"],
|
package/src/progress-node.mjs
CHANGED
|
@@ -96,7 +96,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
96
96
|
}
|
|
97
97
|
function lock(dir,fn,{budgetMs=1500}={}) {
|
|
98
98
|
const lockPath=join(dir,'.allocation-lock');
|
|
99
|
-
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
|
|
99
|
+
return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
|
|
100
100
|
}
|
|
101
101
|
export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
|
|
102
102
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
@@ -164,7 +164,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
|
|
|
164
164
|
if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
|
|
165
165
|
return {...remit,snapshot_id:snapshot.snapshot_id,reused};
|
|
166
166
|
} catch(error) {
|
|
167
|
-
if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock
|
|
167
|
+
if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
|
|
168
|
+
else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
|
|
168
169
|
else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
|
|
169
170
|
return {state:'failed',reason:'capture_unavailable'};
|
|
170
171
|
}
|