@nextcommerce/campaigns-os 1.37.2 → 1.41.2
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 +115 -11
- package/CHANGELOG.md +556 -0
- package/README.md +43 -36
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4794 -0
- package/contracts/release-ledger.json +873 -0
- package/contracts/supported-surface.json +25 -5
- package/docs/activation-and-evidence.md +1 -1
- package/docs/build-packet.md +27 -16
- package/docs/demo-preview.md +3 -4
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +281 -0
- package/docs/gateway-login.md +113 -0
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +3 -3
- package/docs/qa-and-test-orders.md +3 -3
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +12 -4
- package/docs/versioning.md +8 -4
- package/package.json +8 -3
- package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +140 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +45 -21
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +48 -24
- package/skills.json +39 -6
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +991 -200
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +95 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/qa-node.mjs +56 -19
- package/src/qa-publish.mjs +108 -2
- package/src/readback.mjs +1936 -0
- package/src/remit.mjs +17 -3
package/src/qa-node.mjs
CHANGED
|
@@ -60,6 +60,10 @@ import {
|
|
|
60
60
|
import { resolveConsent } from "./consent.mjs";
|
|
61
61
|
import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
|
|
62
62
|
import { commitAssemblyReport } from "./stage-ledger.mjs";
|
|
63
|
+
// Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
|
|
64
|
+
// persist step suppresses the journal append from one place. lifecycle.mjs
|
|
65
|
+
// imports nothing from this repository, so this cannot be circular.
|
|
66
|
+
import { refused, refusing } from "./lifecycle.mjs";
|
|
63
67
|
import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
64
68
|
import { loadParityFixture } from "./qa-parity-fixture.mjs";
|
|
65
69
|
import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
|
|
@@ -99,7 +103,7 @@ Usage:
|
|
|
99
103
|
campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
|
|
100
104
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
101
105
|
campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
102
|
-
campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
|
|
106
|
+
campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
|
|
103
107
|
campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
|
|
104
108
|
campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
|
|
105
109
|
campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--browser] # L7: QA a built _site/ with no packet/spec
|
|
@@ -146,6 +150,10 @@ Options:
|
|
|
146
150
|
per the Run Record (already_published), an untrusted one, or one for another
|
|
147
151
|
campaign. Exit 2 on a refusal, 1 on a failed post, 0 when published.
|
|
148
152
|
--republish qa publish: post a verdict its Run Record already records as published.
|
|
153
|
+
--dry-run qa publish: run every refusal check and print what would be posted (endpoint,
|
|
154
|
+
verdict run id, payload bytes) without the POST. Nothing is sent and the Run
|
|
155
|
+
Record is not stamped; a refusal still exits 2, a clean dry run exits 0
|
|
156
|
+
(--json: dry_run, would_publish, would_post).
|
|
149
157
|
--no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
|
|
150
158
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
151
159
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
@@ -250,7 +258,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
|
|
|
250
258
|
return result;
|
|
251
259
|
}
|
|
252
260
|
if (subcommand === "policy") {
|
|
253
|
-
if (args._[2] !== "set") throw
|
|
261
|
+
if (args._[2] !== "set") throw refused(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
|
|
254
262
|
const result = updateQaPolicy(args);
|
|
255
263
|
output(result, args);
|
|
256
264
|
return result;
|
|
@@ -281,7 +289,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
|
|
|
281
289
|
process.exitCode = result.ok ? 0 : 1;
|
|
282
290
|
return result;
|
|
283
291
|
}
|
|
284
|
-
throw
|
|
292
|
+
throw refused(`Unknown qa command: ${subcommand}`);
|
|
285
293
|
}
|
|
286
294
|
|
|
287
295
|
// The package-owned browser install, runnable from any install mode. It is
|
|
@@ -338,7 +346,7 @@ async function resolveQaInputs(args, {
|
|
|
338
346
|
loadCampaignEntry = loadPageKitCampaignEntry,
|
|
339
347
|
} = {}) {
|
|
340
348
|
if (args.packet && args.spec) {
|
|
341
|
-
throw
|
|
349
|
+
throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
|
|
342
350
|
}
|
|
343
351
|
// Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
|
|
344
352
|
// that has only a built _site/ and a served URL — no Build Packet, no Map ID,
|
|
@@ -363,7 +371,15 @@ async function resolveQaInputs(args, {
|
|
|
363
371
|
const mapId = stringArg(args["map-id"])
|
|
364
372
|
|| stringArg(args._[2])
|
|
365
373
|
|| stringArg(packet?.spec?.map_id);
|
|
366
|
-
|
|
374
|
+
// A refusal to identify a campaign, not a failure to QA one: with no
|
|
375
|
+
// --packet, no --site/--built and no positional <map-id>, every read above
|
|
376
|
+
// was skipped and `qa run` has nothing to work on. Tagged on both paths, not
|
|
377
|
+
// just the empty one — the journal question is whether the CLI reached a
|
|
378
|
+
// handler's WORK, and a missing-identity refusal decides that before any
|
|
379
|
+
// spec is fetched, any browser launches and anything is written. (When a
|
|
380
|
+
// packet WAS named, it has been read by here; the packet read is a lookup
|
|
381
|
+
// for the same identity question, and one message cannot be two verdicts.)
|
|
382
|
+
if (!mapId) throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
|
|
367
383
|
|
|
368
384
|
const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
|
|
369
385
|
const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
|
|
@@ -1726,7 +1742,7 @@ const REMOVED_QA_POLICY_FLAGS = ["test-orders-allowed", "sandbox-test-card-confi
|
|
|
1726
1742
|
|
|
1727
1743
|
function updateQaPolicy(args) {
|
|
1728
1744
|
const packetPath = args.packet ? resolve(args.packet) : null;
|
|
1729
|
-
if (!packetPath) throw
|
|
1745
|
+
if (!packetPath) throw refused("qa policy set requires --packet <campaign-runtime.build.json>.");
|
|
1730
1746
|
const packet = readJson(packetPath);
|
|
1731
1747
|
packet.campaign ||= {};
|
|
1732
1748
|
packet.deploy ||= {};
|
|
@@ -1735,12 +1751,15 @@ function updateQaPolicy(args) {
|
|
|
1735
1751
|
// Test Orders have no permission flag. The two flags that once set one
|
|
1736
1752
|
// were removed with their packet fields in supported surface 1.28.0; a
|
|
1737
1753
|
// script still passing them gets told so instead of a silent no-op.
|
|
1754
|
+
// This check and the setOptional* value checks below run after reading only
|
|
1755
|
+
// the packet and before the packet write: refusals, so they journal nothing.
|
|
1738
1756
|
const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
|
|
1739
1757
|
if (removedFlags.length) {
|
|
1740
|
-
throw
|
|
1758
|
+
throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders run from --test-order <mode> alone; there is no permission flag). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
|
|
1741
1759
|
}
|
|
1742
|
-
// Validated with the other argv checks, before anything is written
|
|
1743
|
-
|
|
1760
|
+
// Validated with the other argv checks, before anything is written: a
|
|
1761
|
+
// refusal at this call site, like the removed-flag check above.
|
|
1762
|
+
const orderPathDepth = refusing(() => parseOrderPathDepthFlag(args, { command: "qa policy set" }));
|
|
1744
1763
|
|
|
1745
1764
|
const changed = [];
|
|
1746
1765
|
setOptionalBoolean(packet.campaign, "allowed_domains_confirmed", args, "allowed-domains-confirmed", changed);
|
|
@@ -1810,21 +1829,24 @@ const WAIVABLE_QA_ASSERTIONS = Object.freeze(["analytics-correctness:purchase-fi
|
|
|
1810
1829
|
// report.theme.waiver: { reason, waived_by, waived_at }.
|
|
1811
1830
|
export function qaWaive(args) {
|
|
1812
1831
|
const packetPath = args.packet ? resolve(args.packet) : null;
|
|
1813
|
-
if (!packetPath) throw
|
|
1832
|
+
if (!packetPath) throw refused("qa waive requires --packet <campaign-runtime.build.json>.");
|
|
1814
1833
|
const packet = readJson(packetPath);
|
|
1834
|
+
// The three flag checks run after reading only argv and the packet, ahead of
|
|
1835
|
+
// the report lookup: refusals, so they journal nothing. The missing report
|
|
1836
|
+
// below is a handler failure and is journaled.
|
|
1815
1837
|
const assertionId = stringArg(args.assertion);
|
|
1816
1838
|
if (!assertionId) {
|
|
1817
|
-
throw
|
|
1839
|
+
throw refused(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
|
|
1818
1840
|
}
|
|
1819
1841
|
if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) {
|
|
1820
|
-
throw
|
|
1842
|
+
throw refused(
|
|
1821
1843
|
`qa waive does not accept --assertion "${assertionId}". The waiver lane is scoped to exactly: ${WAIVABLE_QA_ASSERTIONS.join(", ")}. `
|
|
1822
1844
|
+ "Extending the lane to another assertion is a design decision, not a flag.",
|
|
1823
1845
|
);
|
|
1824
1846
|
}
|
|
1825
1847
|
const reason = stringArg(args.reason);
|
|
1826
1848
|
if (!reason) {
|
|
1827
|
-
throw
|
|
1849
|
+
throw refused("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
|
|
1828
1850
|
}
|
|
1829
1851
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
1830
1852
|
packet,
|
|
@@ -1998,15 +2020,25 @@ function parityReplayEvidence(bundle) {
|
|
|
1998
2020
|
return { order, capture, baselineCapture, orders: Array.isArray(bundle.orders) ? bundle.orders : [order] };
|
|
1999
2021
|
}
|
|
2000
2022
|
|
|
2023
|
+
// The up-front half of the `--max-order-creations` check. The validator is
|
|
2024
|
+
// SHARED with the order-creation budget, which every browser path builds after
|
|
2025
|
+
// a browser has launched and orders may already have been created; a throw from
|
|
2026
|
+
// there is a handler failure and must still be journaled, so the refusal tag
|
|
2027
|
+
// cannot live inside the validator. It goes here via `refusing()`, at the two
|
|
2028
|
+
// entries that check the flag before anything is resolved or launched, where a
|
|
2029
|
+
// bad value has cost the operator nothing. Message and exit code are the
|
|
2030
|
+
// validator's own.
|
|
2031
|
+
const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
|
|
2032
|
+
|
|
2001
2033
|
async function runParityQa(args) {
|
|
2002
2034
|
// Checked here as well as on the budget itself: the budget is built after a
|
|
2003
2035
|
// browser has launched, and a flag the operator typed wrong should cost them
|
|
2004
2036
|
// nothing. The budget stays the authority — this is fail-fast, not the gate.
|
|
2005
|
-
|
|
2037
|
+
refuseBadOrderCreationLimit(args);
|
|
2006
2038
|
const fixturePath = stringArg(args.fixture);
|
|
2007
2039
|
const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
|
|
2008
|
-
if (!fixturePath) throw
|
|
2009
|
-
if (!scenarioId) throw
|
|
2040
|
+
if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
|
|
2041
|
+
if (!scenarioId) throw refused("QA parity requires --scenario <scenario-id>.");
|
|
2010
2042
|
|
|
2011
2043
|
const fixture = await loadParityFixture(resolve(fixturePath));
|
|
2012
2044
|
const scenario = resolveParityScenario(fixture, scenarioId);
|
|
@@ -2072,7 +2104,7 @@ async function runParityQa(args) {
|
|
|
2072
2104
|
async function runQa(args, options = {}) {
|
|
2073
2105
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2074
2106
|
// lives on the creation budget itself, which every browser path builds.
|
|
2075
|
-
|
|
2107
|
+
refuseBadOrderCreationLimit(args);
|
|
2076
2108
|
const resolved = await resolveQaInputs(args);
|
|
2077
2109
|
return runResolvedQa(args, resolved, options);
|
|
2078
2110
|
}
|
|
@@ -3232,16 +3264,21 @@ function stringArg(value) {
|
|
|
3232
3264
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
3233
3265
|
}
|
|
3234
3266
|
|
|
3267
|
+
// setOptionalBoolean and setOptionalString serve `qa policy set` only, ahead of
|
|
3268
|
+
// its packet write, so a bad value there is a refusal. booleanArg itself stays
|
|
3269
|
+
// untagged: it is shared with `qa run`'s analytics leg, which calls it mid-run,
|
|
3270
|
+
// where a throw is a handler failure — so the tag goes on this call site
|
|
3271
|
+
// (`refusing()`), not in the validator.
|
|
3235
3272
|
function setOptionalBoolean(target, property, args, key, changed) {
|
|
3236
3273
|
if (!(key in args)) return;
|
|
3237
|
-
const value = booleanArg(args[key], key);
|
|
3274
|
+
const value = refusing(() => booleanArg(args[key], key));
|
|
3238
3275
|
setIfChanged(target, property, value, changed);
|
|
3239
3276
|
}
|
|
3240
3277
|
|
|
3241
3278
|
function setOptionalString(target, property, args, key, changed) {
|
|
3242
3279
|
if (!(key in args)) return;
|
|
3243
3280
|
const value = stringArg(args[key]);
|
|
3244
|
-
if (!value) throw
|
|
3281
|
+
if (!value) throw refused(`--${key} requires a value.`);
|
|
3245
3282
|
setIfChanged(target, property, value, changed);
|
|
3246
3283
|
}
|
|
3247
3284
|
|
package/src/qa-publish.mjs
CHANGED
|
@@ -20,10 +20,12 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
20
20
|
import { basename, dirname, join, resolve } from "node:path";
|
|
21
21
|
|
|
22
22
|
import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
|
|
23
|
+
import { refused } from "./lifecycle.mjs";
|
|
23
24
|
import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
|
|
24
25
|
import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
|
|
25
|
-
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
|
|
26
|
+
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
|
|
26
27
|
import { validateVerdict } from "./qa-verdict.mjs";
|
|
28
|
+
import { assertFetchAvailable, assertSecureProxyBase, classifyRemitOutcome, describeRemitBaseKind } from "./remit.mjs";
|
|
27
29
|
import { readRunRecordsForTarget, writeRunRecord } from "./run-record.mjs";
|
|
28
30
|
import { identityMatches } from "./run-record-closeout.mjs";
|
|
29
31
|
import { DEFAULT_PROXY_BASE } from "./spec-fetch.mjs";
|
|
@@ -32,6 +34,9 @@ import { singleLineFragment } from "./text-safety.mjs";
|
|
|
32
34
|
|
|
33
35
|
export const QA_PUBLISH_STATUSES = Object.freeze({
|
|
34
36
|
published: "published",
|
|
37
|
+
// --dry-run: every refusal check ran and none fired, so a real run would
|
|
38
|
+
// have posted. Nothing was sent, so this is neither published nor failed.
|
|
39
|
+
dry_run: "dry_run",
|
|
35
40
|
refused: "refused",
|
|
36
41
|
publish_failed: "publish_failed",
|
|
37
42
|
});
|
|
@@ -55,6 +60,7 @@ export const QA_PUBLISH_REFUSALS = Object.freeze({
|
|
|
55
60
|
|
|
56
61
|
export const QA_PUBLISH_EXIT_CODES = Object.freeze({
|
|
57
62
|
[QA_PUBLISH_STATUSES.published]: 0,
|
|
63
|
+
[QA_PUBLISH_STATUSES.dry_run]: 0,
|
|
58
64
|
[QA_PUBLISH_STATUSES.refused]: 2,
|
|
59
65
|
[QA_PUBLISH_STATUSES.publish_failed]: 1,
|
|
60
66
|
});
|
|
@@ -165,6 +171,25 @@ export function findRunRecordForVerdict({ records, packet, verdictRunId, verdict
|
|
|
165
171
|
* the record stamping are assertable without a receiver or a filesystem.
|
|
166
172
|
*/
|
|
167
173
|
export async function publishStoredVerdict(args, operations = {}) {
|
|
174
|
+
// `--dry-run` is a bare flag; `--dry-run true` must fail rather than quietly
|
|
175
|
+
// become a real POST.
|
|
176
|
+
// An up-front flag refusal, tagged like every other so the lifecycle
|
|
177
|
+
// journal records nothing for it (a plain Error here would be journaled as
|
|
178
|
+
// a handler failure).
|
|
179
|
+
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
180
|
+
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
181
|
+
}
|
|
182
|
+
const dryRun = args["dry-run"] === true;
|
|
183
|
+
const result = await attemptPublish(args, operations, dryRun);
|
|
184
|
+
if (!dryRun) return result;
|
|
185
|
+
// The envelope says what the real command would have done. A refusal is a
|
|
186
|
+
// refusal either way — same code, same exit — and so is a destination the
|
|
187
|
+
// transport gate turns down before any request; `would_publish` is true only
|
|
188
|
+
// when every check passed and the post is all that is left.
|
|
189
|
+
return { ...result, dry_run: true, would_publish: result.status === QA_PUBLISH_STATUSES.dry_run };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function attemptPublish(args, operations, dryRun) {
|
|
168
193
|
const ops = {
|
|
169
194
|
readJsonFile: readJson,
|
|
170
195
|
exists: existsSync,
|
|
@@ -268,6 +293,70 @@ export async function publishStoredVerdict(args, operations = {}) {
|
|
|
268
293
|
);
|
|
269
294
|
}
|
|
270
295
|
|
|
296
|
+
// Every refusal check above has run and none fired, so a real run would post
|
|
297
|
+
// now. Under --dry-run this is where it stops: nothing is sent, and the Run
|
|
298
|
+
// Record below is left exactly as it stands.
|
|
299
|
+
if (dryRun) {
|
|
300
|
+
// First, the checks the transport itself makes before it opens a socket.
|
|
301
|
+
// `publishQaVerdict` -> `remit` demands a fetch to send with
|
|
302
|
+
// (`assertFetchAvailable`) and then puts every destination through
|
|
303
|
+
// `assertSecureProxyBase`; a runtime with no global fetch, or a base that
|
|
304
|
+
// is not https (nor a loopback host for local testing), fails there,
|
|
305
|
+
// locally, with nothing sent: the real command reports publish_failed and
|
|
306
|
+
// exits 1. A preview may not approve a send the transport refuses, so both
|
|
307
|
+
// gates run here, in the transport's own order — the same functions, with
|
|
308
|
+
// the label and the (absent) credential the publish rail passes them — and
|
|
309
|
+
// the refusal is classified by the same classifier the real outcome goes
|
|
310
|
+
// through, so the message and the exit code are the real command's.
|
|
311
|
+
// `attempted: false` is the one difference: nothing was sent.
|
|
312
|
+
let destinationRefusal = null;
|
|
313
|
+
try {
|
|
314
|
+
assertFetchAvailable(globalThis.fetch);
|
|
315
|
+
assertSecureProxyBase(proxyBase, { label: "QA verdict publish", credential: null });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
destinationRefusal = classifyRemitOutcome(error);
|
|
318
|
+
}
|
|
319
|
+
if (destinationRefusal) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
action: "qa-publish",
|
|
323
|
+
status: QA_PUBLISH_STATUSES.publish_failed,
|
|
324
|
+
...identity,
|
|
325
|
+
republished: republish && priorBlock?.state === "ok",
|
|
326
|
+
publish: {
|
|
327
|
+
attempted: false,
|
|
328
|
+
ok: destinationRefusal.ok,
|
|
329
|
+
error: destinationRefusal.error,
|
|
330
|
+
endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
|
|
331
|
+
result: destinationRefusal.result,
|
|
332
|
+
http_status: destinationRefusal.http_status,
|
|
333
|
+
base_kind: describeRemitBaseKind(proxyBase),
|
|
334
|
+
published_at: null,
|
|
335
|
+
},
|
|
336
|
+
dashboard_url: null,
|
|
337
|
+
run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
|
|
338
|
+
orders_placed: 0,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
action: "qa-publish",
|
|
344
|
+
status: QA_PUBLISH_STATUSES.dry_run,
|
|
345
|
+
...identity,
|
|
346
|
+
republished: republish && priorBlock?.state === "ok",
|
|
347
|
+
would_post: {
|
|
348
|
+
endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
|
|
349
|
+
base_kind: describeRemitBaseKind(proxyBase),
|
|
350
|
+
verdict_run_id: verdictRunId,
|
|
351
|
+
payload_bytes: Buffer.byteLength(JSON.stringify(verdict), "utf8"),
|
|
352
|
+
},
|
|
353
|
+
publish: { attempted: false, ok: null, error: null, endpoint: QA_VERDICT_PUBLISH_ENDPOINT, result: null, http_status: null, base_kind: describeRemitBaseKind(proxyBase), published_at: null },
|
|
354
|
+
dashboard_url: qaPortalUrl(proxyBase, mapId, verdictRunId),
|
|
355
|
+
run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
|
|
356
|
+
orders_placed: 0,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
271
360
|
const outcome = await ops.post(verdict, proxyBase);
|
|
272
361
|
const publishedAt = ops.now();
|
|
273
362
|
const block = qaVerdictPublishBlock(outcome, { verdictRunId, publisher: QA_VERDICT_PUBLISHERS.publish, publishedAt });
|
|
@@ -332,6 +421,21 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
|
|
|
332
421
|
if (result.verdict_path) lines.push(`Verdict: ${result.verdict_path}${result.source_kind ? ` (${result.source_kind})` : ""}`);
|
|
333
422
|
if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
|
|
334
423
|
lines.push("No order was placed and nothing was sent.");
|
|
424
|
+
if (result.dry_run) lines.push("Dry run (--dry-run): the real command refuses this the same way.");
|
|
425
|
+
return lines;
|
|
426
|
+
}
|
|
427
|
+
if (result.status === QA_PUBLISH_STATUSES.dry_run) {
|
|
428
|
+
const post = result.would_post || {};
|
|
429
|
+
lines.push("QA publish dry run (--dry-run): every refusal check passed and nothing was sent.");
|
|
430
|
+
lines.push(`Map ID: ${result.map_id}`);
|
|
431
|
+
lines.push(`Run ID: ${result.run_id}`);
|
|
432
|
+
lines.push(`Disposition: ${result.disposition}`);
|
|
433
|
+
lines.push(`Verdict: ${result.verdict_path} (${result.source_kind})`);
|
|
434
|
+
lines.push(`Would POST: ${post.endpoint} at ${result.proxy_base} [base: ${post.base_kind}] — verdict ${post.verdict_run_id}, ${post.payload_bytes} bytes`);
|
|
435
|
+
lines.push(result.run_record
|
|
436
|
+
? `Run Record: ${result.run_record.run_id} would take the publish outcome (${result.run_record.path}); it was not touched.`
|
|
437
|
+
: "Run Record: none references this verdict under the packet's campaign, so a real publish would not be recorded on one.");
|
|
438
|
+
lines.push("Orders placed: 0 (qa publish never places orders).");
|
|
335
439
|
return lines;
|
|
336
440
|
}
|
|
337
441
|
lines.push(result.status === QA_PUBLISH_STATUSES.published
|
|
@@ -356,7 +460,9 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
|
|
|
356
460
|
}
|
|
357
461
|
lines.push("Orders placed: 0 (qa publish never places orders).");
|
|
358
462
|
if (result.status === QA_PUBLISH_STATUSES.publish_failed) {
|
|
359
|
-
lines.push(
|
|
463
|
+
lines.push(result.dry_run
|
|
464
|
+
? `Dry run (--dry-run): nothing was sent, and the real command fails here the same way — the destination is refused before any request. ${result.publish?.error || ""}`.trimEnd()
|
|
465
|
+
: `Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
|
|
360
466
|
}
|
|
361
467
|
return lines;
|
|
362
468
|
}
|