@nextcommerce/campaigns-os 1.43.1 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
// Design Source Package publication for prepare-build, start and build: the
|
|
2
|
+
// lock's critical section, collisions, evidence re-checks and publication order.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { accessSync, constants as fsConstants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { writeThemeArtifacts } from "./brand-theme.mjs";
|
|
7
|
+
import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
|
|
8
|
+
import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
|
|
9
|
+
import { stampDoctorProducer } from "./doctor-sidecar.mjs";
|
|
10
|
+
import { ASSEMBLY_REPORT_STAGE_KEYS } from "./orchestration-stage-contract.mjs";
|
|
11
|
+
import { targetLockPath, withTargetLock } from "./target-lock.mjs";
|
|
12
|
+
import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
|
|
13
|
+
|
|
14
|
+
// Stage keys whose seed states prepare-build itself rewrites on every run
|
|
15
|
+
// (createInitialAssemblyReportStages): prepare_build is re-derived from this
|
|
16
|
+
// run's readiness result, and setup from scaffold detection — so their seed
|
|
17
|
+
// states never count as accumulated agent evidence. setup's seed state may
|
|
18
|
+
// legitimately be "skipped" (scaffold already present); every other stage is
|
|
19
|
+
// seeded "pending" with empty ledger arrays.
|
|
20
|
+
function assemblyReportStagesWithEvidence(existingReport) {
|
|
21
|
+
const stages = existingReport?.stages;
|
|
22
|
+
if (!isObject(stages)) return [];
|
|
23
|
+
const withEvidence = [];
|
|
24
|
+
for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
|
|
25
|
+
if (key === "prepare_build") continue;
|
|
26
|
+
const stage = stages[key];
|
|
27
|
+
if (!isObject(stage)) continue;
|
|
28
|
+
const seedStatuses = key === "setup" ? ["pending", "skipped"] : ["pending"];
|
|
29
|
+
const nonSeedStatus = !seedStatuses.includes(optionalString(stage.status, "pending"));
|
|
30
|
+
const recordedContent =
|
|
31
|
+
["inputs", "outputs", "commands", "blockers", "warnings", "evidence"].some(
|
|
32
|
+
(field) => Array.isArray(stage[field]) && stage[field].length > 0,
|
|
33
|
+
)
|
|
34
|
+
|| (isObject(stage.evidence) && Object.keys(stage.evidence).length > 0);
|
|
35
|
+
if (nonSeedStatus || recordedContent) withEvidence.push(key);
|
|
36
|
+
}
|
|
37
|
+
return withEvidence;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// INV-4 guard (packet 02): prepare-build/start regenerate the assembly report
|
|
41
|
+
// from scratch (createAssemblyReport resets every stage), so an unconditional
|
|
42
|
+
// write silently destroys agent-recorded stage evidence. Mirror the
|
|
43
|
+
// runSessionStart pattern: refuse unless --force, and with --force announce
|
|
44
|
+
// exactly which stage keys are cleared. The guard keys on evidence, not file
|
|
45
|
+
// existence — a report whose stages are all still at their seed states (a real
|
|
46
|
+
// re-prepare before any work) regenerates freely with no flag.
|
|
47
|
+
//
|
|
48
|
+
// withDesignSourcePublication runs it twice inside the target lock: once
|
|
49
|
+
// before reading its inputs, and again (with `announced`, the keys the first
|
|
50
|
+
// call returned) right before the report is renamed into place. Stage
|
|
51
|
+
// producers commit the report without that lock, so evidence can land while
|
|
52
|
+
// the run is working; the second call refuses it without --force and, with
|
|
53
|
+
// --force, names any stage it had not already announced.
|
|
54
|
+
function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {}) {
|
|
55
|
+
if (!existsSync(reportPath)) return [];
|
|
56
|
+
let existingReport = null;
|
|
57
|
+
try {
|
|
58
|
+
existingReport = readJson(reportPath);
|
|
59
|
+
} catch {
|
|
60
|
+
return []; // An unreadable report carries no provable evidence; keep today's regeneration path.
|
|
61
|
+
}
|
|
62
|
+
const stageKeys = assemblyReportStagesWithEvidence(existingReport);
|
|
63
|
+
if (stageKeys.length === 0) return [];
|
|
64
|
+
if (args.force !== true) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Assembly report at ${reportPath} ${announced ? "gained stage evidence while prepare-build was running" : "already carries stage evidence"} (${stageKeys.join(", ")}). `
|
|
67
|
+
+ `Rerunning prepare-build/start/build would reset ${stageKeys.length === 1 ? "this stage" : "these stages"} to pending and destroy that evidence. `
|
|
68
|
+
+ `Pass --force to overwrite (destructive).`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
|
|
72
|
+
if (unannounced.length > 0) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return stageKeys;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function artifactRelativePath(artifactPath, targetPath) {
|
|
81
|
+
const rel = relative(dirname(resolve(artifactPath)), resolve(targetPath)).replaceAll("\\", "/");
|
|
82
|
+
return rel || ".";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function canonicalPrepareBuildOutputPath(path, label) {
|
|
86
|
+
const absolute = resolve(path);
|
|
87
|
+
const missingSegments = [];
|
|
88
|
+
let cursor = absolute;
|
|
89
|
+
let realpathRetries = 0;
|
|
90
|
+
while (true) {
|
|
91
|
+
let stats;
|
|
92
|
+
try {
|
|
93
|
+
stats = lstatSync(cursor);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code !== "ENOENT") throw error;
|
|
96
|
+
const parent = dirname(cursor);
|
|
97
|
+
if (parent === cursor) return absolute;
|
|
98
|
+
missingSegments.unshift(basename(cursor));
|
|
99
|
+
cursor = parent;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Even a dangling leaf symlink becomes writable after an earlier output
|
|
104
|
+
// creates its target. Reject the alias itself instead of relying on stat,
|
|
105
|
+
// which follows symlinks and cannot see a dangling one.
|
|
106
|
+
if (missingSegments.length === 0 && stats.isSymbolicLink()) {
|
|
107
|
+
throw new Error(`Prepare-build output path collision: ${label} at ${absolute} is a symbolic-link alias. Choose a regular, distinct output path; the Design Source Package path is fixed.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
return resolve(realpathSync(cursor), ...missingSegments);
|
|
112
|
+
} catch {
|
|
113
|
+
// The entry can be replaced between lstat and realpath: a prepare-build
|
|
114
|
+
// holding the target lock moves a stale DSP aside and links its
|
|
115
|
+
// replacement while this run is still before the lock. Look again from
|
|
116
|
+
// the top a few times; an entry that never resolves, such as a dangling
|
|
117
|
+
// symlink, is an alias.
|
|
118
|
+
if (realpathRetries++ < 3) {
|
|
119
|
+
missingSegments.length = 0;
|
|
120
|
+
cursor = absolute;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`Prepare-build output path collision: ${label} at ${absolute} traverses an unresolved filesystem alias. Choose a regular, distinct output path; the Design Source Package path is fixed.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function prepareBuildPathIdentity(path) {
|
|
129
|
+
return resolve(path).normalize("NFC").toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A reserved tree is a directory no output may be written into: the target
|
|
133
|
+
// lock, whose release removes the directory recursively (#501). The staging,
|
|
134
|
+
// tomb and recovery siblings the lock creates and removes
|
|
135
|
+
// (src/directory-lock.mjs) are reserved with it; other names that merely
|
|
136
|
+
// start with the lock's name are ordinary outputs.
|
|
137
|
+
const PREPARE_BUILD_LOCK_SIBLING_SUFFIXES = [".staging-", ".recovery-staging-", ".released-", ".abandoned-"];
|
|
138
|
+
|
|
139
|
+
function prepareBuildReservedTreeContains(reservedIdentity, pathIdentity) {
|
|
140
|
+
return pathIdentity === reservedIdentity
|
|
141
|
+
|| pathIdentity.startsWith(`${reservedIdentity}${sep}`)
|
|
142
|
+
|| PREPARE_BUILD_LOCK_SIBLING_SUFFIXES.some((suffix) => pathIdentity.startsWith(`${reservedIdentity}${suffix}`));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertOutsidePrepareBuildReservedTrees(label, absolute, canonical, reservedTrees) {
|
|
146
|
+
for (const reserved of reservedTrees) {
|
|
147
|
+
if (
|
|
148
|
+
prepareBuildReservedTreeContains(reserved.identity, prepareBuildPathIdentity(absolute))
|
|
149
|
+
|| prepareBuildReservedTreeContains(reserved.canonicalIdentity, prepareBuildPathIdentity(canonical))
|
|
150
|
+
) {
|
|
151
|
+
throw new Error(`Prepare-build output path collision: ${label} at ${absolute} is inside the ${reserved.label} at ${reserved.path}, which is removed when the lock is released. Choose an output path outside it; the Design Source Package path is fixed.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertDistinctPrepareBuildOutputPaths(entries) {
|
|
157
|
+
const seenPaths = new Map();
|
|
158
|
+
const seenCanonicalPaths = new Map();
|
|
159
|
+
const seenFiles = new Map();
|
|
160
|
+
const reservedTrees = entries
|
|
161
|
+
.filter(([, , kind]) => kind?.reservedTree)
|
|
162
|
+
.map(([label, path]) => {
|
|
163
|
+
const absolute = resolve(path);
|
|
164
|
+
return {
|
|
165
|
+
label,
|
|
166
|
+
path: absolute,
|
|
167
|
+
identity: prepareBuildPathIdentity(absolute),
|
|
168
|
+
canonicalIdentity: prepareBuildPathIdentity(canonicalPrepareBuildOutputPath(absolute, label)),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
const outputs = entries.filter(([, , kind]) => !kind?.reservedTree);
|
|
172
|
+
for (const [label, path] of outputs) {
|
|
173
|
+
const absolute = resolve(path);
|
|
174
|
+
const pathIdentity = prepareBuildPathIdentity(absolute);
|
|
175
|
+
const priorPath = seenPaths.get(pathIdentity);
|
|
176
|
+
if (priorPath) {
|
|
177
|
+
throw new Error(`Prepare-build output path collision: ${priorPath.label} at ${priorPath.path} and ${label} at ${absolute} are identical after conservative case folding. Choose distinct output paths; the Design Source Package path is fixed.`);
|
|
178
|
+
}
|
|
179
|
+
seenPaths.set(pathIdentity, { label, path: absolute });
|
|
180
|
+
|
|
181
|
+
const canonical = canonicalPrepareBuildOutputPath(absolute, label);
|
|
182
|
+
const canonicalIdentity = prepareBuildPathIdentity(canonical);
|
|
183
|
+
assertOutsidePrepareBuildReservedTrees(label, absolute, canonical, reservedTrees);
|
|
184
|
+
const priorCanonical = seenCanonicalPaths.get(canonicalIdentity);
|
|
185
|
+
if (priorCanonical) {
|
|
186
|
+
throw new Error(`Prepare-build output path collision: ${priorCanonical.label} at ${priorCanonical.path} and ${label} at ${absolute} resolve through filesystem aliases to ${canonical}. Choose distinct output paths; the Design Source Package path is fixed.`);
|
|
187
|
+
}
|
|
188
|
+
seenCanonicalPaths.set(canonicalIdentity, { label, path: absolute });
|
|
189
|
+
|
|
190
|
+
// A lexical comparison is insufficient once an output already exists:
|
|
191
|
+
// hard links can name the fixed DSP (or another sidecar) through a
|
|
192
|
+
// different path, and writeFileSync would follow that alias. Symlink
|
|
193
|
+
// aliases are already caught by the canonical-path check above, so
|
|
194
|
+
// device + inode only need to identify hard links — files with
|
|
195
|
+
// nlink > 1. Recording an identity for nlink === 1 files is unsound
|
|
196
|
+
// here: the stat calls are not atomic across outputs, and a concurrent
|
|
197
|
+
// prepare-build against the same target can unlink one output and let
|
|
198
|
+
// the kernel recycle its inode for another, making two distinct files
|
|
199
|
+
// momentarily share dev:ino.
|
|
200
|
+
let identity = null;
|
|
201
|
+
try {
|
|
202
|
+
const stats = statSync(absolute);
|
|
203
|
+
if (stats.nlink > 1) identity = `${stats.dev}:${stats.ino}`;
|
|
204
|
+
} catch {
|
|
205
|
+
// Missing output paths have no filesystem identity yet; the normalized
|
|
206
|
+
// absolute-path check above is authoritative until they are created.
|
|
207
|
+
}
|
|
208
|
+
if (identity) {
|
|
209
|
+
const priorFile = seenFiles.get(identity);
|
|
210
|
+
if (priorFile) {
|
|
211
|
+
throw new Error(`Prepare-build output path collision: ${priorFile.label} at ${priorFile.path} and ${label} at ${absolute} are aliases for the same filesystem object. Choose distinct output paths; the Design Source Package path is fixed.`);
|
|
212
|
+
}
|
|
213
|
+
seenFiles.set(identity, { label, path: absolute });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function preflightPrepareBuildOutputTargets(outputs, collisionOutputs) {
|
|
219
|
+
assertDistinctPrepareBuildOutputPaths(collisionOutputs);
|
|
220
|
+
for (const [label, path] of outputs) {
|
|
221
|
+
const absolute = resolve(path);
|
|
222
|
+
let stats = null;
|
|
223
|
+
try {
|
|
224
|
+
stats = lstatSync(absolute);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error?.code !== "ENOENT") {
|
|
227
|
+
throw new Error(`Prepare-build could not inspect ${label} output target at ${absolute}: ${error.message}`, { cause: error });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (stats && !stats.isFile()) {
|
|
231
|
+
const kind = stats.isDirectory() ? "directory" : stats.isSymbolicLink() ? "symbolic link" : "non-regular filesystem entry";
|
|
232
|
+
throw new Error(`Prepare-build output target is invalid: ${label} at ${absolute} must be absent or a regular file, but found a ${kind}.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Atomic rename needs write + search permission on the destination's
|
|
236
|
+
// directory, while mkdirSync for a missing directory needs those rights
|
|
237
|
+
// on its nearest existing ancestor. Validate that capability before the
|
|
238
|
+
// fixed DSP or theme artifacts can be published.
|
|
239
|
+
let ancestor = dirname(absolute);
|
|
240
|
+
while (true) {
|
|
241
|
+
try {
|
|
242
|
+
const ancestorStats = statSync(ancestor);
|
|
243
|
+
if (!ancestorStats.isDirectory()) {
|
|
244
|
+
throw new Error(`nearest existing ancestor ${ancestor} is not a directory`);
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
accessSync(ancestor, fsConstants.W_OK | fsConstants.X_OK);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const code = error?.code ? ` (${error.code})` : "";
|
|
250
|
+
throw new Error(`Prepare-build output target is not writable: ${label} at ${absolute} has non-writable ancestor ${ancestor}${code}: ${error.message}`, { cause: error });
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error?.code !== "ENOENT") throw error;
|
|
255
|
+
const parent = dirname(ancestor);
|
|
256
|
+
if (parent === ancestor) throw error;
|
|
257
|
+
ancestor = parent;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function publishPrepareBuildJsonOutputs(outputs, collisionOutputs, { beforePublish = null } = {}) {
|
|
264
|
+
preflightPrepareBuildOutputTargets(
|
|
265
|
+
outputs.map(({ label, path }) => [label, path]),
|
|
266
|
+
collisionOutputs,
|
|
267
|
+
);
|
|
268
|
+
const staged = [];
|
|
269
|
+
try {
|
|
270
|
+
for (const output of outputs) {
|
|
271
|
+
const path = resolve(output.path);
|
|
272
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
273
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
274
|
+
writeFileSync(tmp, `${JSON.stringify(output.value, null, 2)}\n`, { flag: "wx" });
|
|
275
|
+
staged.push({ ...output, path, tmp });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Revalidate after all staging writes. A swap before this point is
|
|
279
|
+
// rejected; a symlink or hard-link swap after it is safely replaced as a
|
|
280
|
+
// directory entry by renameSync rather than followed and truncated.
|
|
281
|
+
preflightPrepareBuildOutputTargets(
|
|
282
|
+
outputs.map(({ label, path }) => [label, path]),
|
|
283
|
+
collisionOutputs,
|
|
284
|
+
);
|
|
285
|
+
// Last check before anything is renamed into place; a refusal here leaves
|
|
286
|
+
// every destination as it was.
|
|
287
|
+
beforePublish?.();
|
|
288
|
+
for (const output of staged) renameSync(output.tmp, output.path);
|
|
289
|
+
} finally {
|
|
290
|
+
for (const output of staged) rmSync(output.tmp, { force: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function preparedDesignSourcePackageProblem(value, currentPageScope, currentHtmlFunnelScope) {
|
|
295
|
+
const validation = validateDesignSourcePackage(value, {
|
|
296
|
+
currentPageScope,
|
|
297
|
+
currentHtmlFunnelScope,
|
|
298
|
+
});
|
|
299
|
+
if (validation.ok) return null;
|
|
300
|
+
return validation.errors
|
|
301
|
+
.map((error) => `[${error.code}] ${error.path}: ${error.message}`)
|
|
302
|
+
.join("; ");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function assertValidPreparedDesignSourcePackage(value, path, currentPageScope, currentHtmlFunnelScope) {
|
|
306
|
+
const detail = preparedDesignSourcePackageProblem(value, currentPageScope, currentHtmlFunnelScope);
|
|
307
|
+
if (detail == null) return;
|
|
308
|
+
throw new Error(`Design Source Package at ${path} is invalid, stale, or contradictory: ${detail}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Which prepare-build lineage produced the Design Source Package now on disk.
|
|
312
|
+
// The previous Assembly Report at this run's report path is the only record of
|
|
313
|
+
// it: its design_source_package reference carries the exact-byte sha256 of the
|
|
314
|
+
// package that run bound to, and `origin: "synthesized"` when prepare-build
|
|
315
|
+
// wrote those bytes itself (or reused bytes it had itself written). A package
|
|
316
|
+
// counts as producer-synthesized only when that report says so AND the bytes on
|
|
317
|
+
// disk still hash to what it recorded, so a hand edit, a package dropped in by
|
|
318
|
+
// an operator, a report from another path, and a report written before `origin`
|
|
319
|
+
// existed all fall to "not provably ours" and are never overwritten.
|
|
320
|
+
//
|
|
321
|
+
// The package is published before the report that records it, so a run that
|
|
322
|
+
// fails or dies between the two would leave its own package unrecorded (#501).
|
|
323
|
+
// The pending provenance record beside the package closes that gap: it names
|
|
324
|
+
// the sha256 of the bytes a run is about to publish, plus the stale package's
|
|
325
|
+
// when a --force run is replacing one it proved its own, so a regeneration
|
|
326
|
+
// that fails or dies part way leaves whichever package ends up on disk
|
|
327
|
+
// provable. It is written before the package goes out, loses the candidate
|
|
328
|
+
// hash if the run adopts another writer's package instead of publishing, and
|
|
329
|
+
// is removed once a report has recorded the package. A retry that finds it
|
|
330
|
+
// still accepts only bytes that hash to one of its entries.
|
|
331
|
+
function readPriorDesignSourceProvenance(reportPath, packagePath) {
|
|
332
|
+
const synthesized = [];
|
|
333
|
+
const pending = readJsonIfExistsQuietly(pendingDesignSourceProvenancePath(packagePath));
|
|
334
|
+
// All or nothing: a record with any entry that is not a digest (a torn or
|
|
335
|
+
// hand-edited file) is no proof, never a partial one.
|
|
336
|
+
if (isObject(pending) && pending.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED
|
|
337
|
+
&& Array.isArray(pending.sha256) && pending.sha256.length > 0
|
|
338
|
+
&& pending.sha256.every((entry) => typeof entry === "string" && /^sha256:[0-9a-f]{64}$/.test(entry))) {
|
|
339
|
+
synthesized.push(...pending.sha256);
|
|
340
|
+
}
|
|
341
|
+
let report = null;
|
|
342
|
+
try {
|
|
343
|
+
report = readJson(reportPath);
|
|
344
|
+
} catch {
|
|
345
|
+
// No readable report: only a pending record can vouch for the package.
|
|
346
|
+
}
|
|
347
|
+
const ref = report?.design_source_package;
|
|
348
|
+
if (isObject(ref) && ref.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED && isNonEmptyString(ref.sha256)) {
|
|
349
|
+
const recordedPath = resolveFromFile(reportPath, ref.path);
|
|
350
|
+
if (recordedPath && filesystemPathsMatch(recordedPath, packagePath)) synthesized.push(ref.sha256);
|
|
351
|
+
}
|
|
352
|
+
return synthesized.length ? { synthesized } : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function readJsonIfExistsQuietly(path) {
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function pendingDesignSourceProvenancePath(designSourcePackagePath) {
|
|
364
|
+
return join(dirname(designSourcePackagePath), `.${basename(designSourcePackagePath)}.pending-provenance.json`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Written (staged, then renamed into place) before a synthesized package is
|
|
368
|
+
// published, so the claim survives a crash between that publication and the
|
|
369
|
+
// report's. An empty list removes the record.
|
|
370
|
+
function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
|
|
371
|
+
const pendingPath = pendingDesignSourceProvenancePath(designSourcePackagePath);
|
|
372
|
+
if (!sha256.length) {
|
|
373
|
+
rmSync(pendingPath, { force: true });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const stagedPath = `${pendingPath}.${randomUUID()}.tmp`;
|
|
377
|
+
try {
|
|
378
|
+
writeFileSync(stagedPath, `${JSON.stringify({ origin: DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED, sha256 })}\n`, { flag: "wx" });
|
|
379
|
+
renameSync(stagedPath, pendingPath);
|
|
380
|
+
} finally {
|
|
381
|
+
rmSync(stagedPath, { force: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED = "synthesized";
|
|
386
|
+
const DESIGN_SOURCE_PACKAGE_ORIGIN_ADOPTED = "adopted";
|
|
387
|
+
|
|
388
|
+
function sourceMaterialFile(sourceRoot, path) {
|
|
389
|
+
if (typeof path !== "string" || !path.trim()) return null;
|
|
390
|
+
const root = resolve(sourceRoot);
|
|
391
|
+
const fullPath = resolve(root, path);
|
|
392
|
+
const rel = relative(root, fullPath);
|
|
393
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
394
|
+
try {
|
|
395
|
+
const stats = statSync(fullPath);
|
|
396
|
+
if (!stats.isFile()) return null;
|
|
397
|
+
return { bytes: stats.size, sha256: sha256File(fullPath) };
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function manifestPagesForResolvedMappings(pages, mappings) {
|
|
404
|
+
if (!Array.isArray(pages)) return pages;
|
|
405
|
+
const groups = new Map();
|
|
406
|
+
for (const page of pages) {
|
|
407
|
+
if (!isObject(page) || !isNonEmptyString(page.page_id)) continue;
|
|
408
|
+
const group = groups.get(page.page_id) || [];
|
|
409
|
+
group.push(page);
|
|
410
|
+
groups.set(page.page_id, group);
|
|
411
|
+
}
|
|
412
|
+
const resolvedPathByPageId = new Map(
|
|
413
|
+
mappings
|
|
414
|
+
.filter((mapping) => isObject(mapping) && isNonEmptyString(mapping.page_id) && isNonEmptyString(mapping.path))
|
|
415
|
+
.map((mapping) => [mapping.page_id, mapping.path]),
|
|
416
|
+
);
|
|
417
|
+
const resolvedDuplicate = new Map();
|
|
418
|
+
for (const [pageId, group] of groups) {
|
|
419
|
+
if (group.length < 2) continue;
|
|
420
|
+
const resolvedPath = resolvedPathByPageId.get(pageId);
|
|
421
|
+
const matches = resolvedPath
|
|
422
|
+
? group.filter((page) => page.path === resolvedPath)
|
|
423
|
+
: [];
|
|
424
|
+
resolvedDuplicate.set(pageId, matches.length === 1 ? matches[0] : null);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return pages.filter((page) => {
|
|
428
|
+
if (!isObject(page) || !isNonEmptyString(page.page_id)) return true;
|
|
429
|
+
if (!resolvedDuplicate.has(page.page_id)) return true;
|
|
430
|
+
return resolvedDuplicate.get(page.page_id) === page;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function createCurrentHtmlFunnelScope({
|
|
435
|
+
packagePath,
|
|
436
|
+
activePages,
|
|
437
|
+
mappings,
|
|
438
|
+
manifestResult,
|
|
439
|
+
sourceAssetCrawl,
|
|
440
|
+
templateFamily,
|
|
441
|
+
templateStockPageIds = [],
|
|
442
|
+
commerceCatalog,
|
|
443
|
+
sourceRoot,
|
|
444
|
+
mapId,
|
|
445
|
+
publicRouteSlug,
|
|
446
|
+
}) {
|
|
447
|
+
const manifest = manifestResult.manifest ? cloneJson(manifestResult.manifest) : null;
|
|
448
|
+
if (manifest) {
|
|
449
|
+
// Source intake owns duplicate-page resolution and records its blocker.
|
|
450
|
+
// Feed the strict DSP synthesizer only the unique record that agrees with
|
|
451
|
+
// that resolved mapping; an unmatched duplicate group remains ambiguous
|
|
452
|
+
// and contributes no page record. Raw manifest bytes/provenance are still
|
|
453
|
+
// retained through manifest.sha256 below.
|
|
454
|
+
manifest.pages = manifestPagesForResolvedMappings(manifest.pages, mappings);
|
|
455
|
+
}
|
|
456
|
+
const crawl = isObject(sourceAssetCrawl) ? cloneJson(sourceAssetCrawl) : null;
|
|
457
|
+
const materialPaths = new Set([
|
|
458
|
+
...mappings.map((mapping) => mapping?.path),
|
|
459
|
+
...(manifest?.files || []).map((file) => file?.path),
|
|
460
|
+
...(manifest?.pages || []).map((page) => page?.path),
|
|
461
|
+
...(crawl?.scanned_files || []).map((file) => file?.path),
|
|
462
|
+
...(crawl?.references || []).map((ref) => ref?.source_path),
|
|
463
|
+
].filter((path) => typeof path === "string" && path.trim()));
|
|
464
|
+
const materialFiles = new Map(
|
|
465
|
+
[...materialPaths].map((path) => [path, sourceMaterialFile(sourceRoot, path)]),
|
|
466
|
+
);
|
|
467
|
+
const currentMappings = mappings.map((mapping) => ({
|
|
468
|
+
...mapping,
|
|
469
|
+
...(mapping?.path ? { source_hash: materialFiles.get(mapping.path)?.sha256 || null } : {}),
|
|
470
|
+
}));
|
|
471
|
+
|
|
472
|
+
if (manifest) {
|
|
473
|
+
// The hash of the bytes source intake parsed, not a second read of the
|
|
474
|
+
// file, which an edit mid-run could have changed since (#501).
|
|
475
|
+
manifest.sha256 = manifestResult.sha256 || null;
|
|
476
|
+
manifest.files = (manifest.files || []).map((file) => ({
|
|
477
|
+
...file,
|
|
478
|
+
sha256: materialFiles.get(file.path)?.sha256 || null,
|
|
479
|
+
}));
|
|
480
|
+
manifest.pages = (manifest.pages || []).map((page) => ({
|
|
481
|
+
...page,
|
|
482
|
+
...(page.path ? { source_hash: materialFiles.get(page.path)?.sha256 || null } : {}),
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (crawl) {
|
|
487
|
+
const scannedByPath = new Map((crawl.scanned_files || []).map((file) => [file.path, file]));
|
|
488
|
+
for (const [path, material] of materialFiles) {
|
|
489
|
+
if (!material) continue;
|
|
490
|
+
const existing = scannedByPath.get(path);
|
|
491
|
+
scannedByPath.set(path, {
|
|
492
|
+
...(existing || { path, kind: "asset" }),
|
|
493
|
+
bytes: material.bytes,
|
|
494
|
+
sha256: material.sha256,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
crawl.scanned_files = [...scannedByPath.values()].sort((left, right) =>
|
|
498
|
+
String(left.path).localeCompare(String(right.path)));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
activePages,
|
|
503
|
+
mappings: currentMappings,
|
|
504
|
+
manifest,
|
|
505
|
+
manifestPath: manifest && manifestResult.path
|
|
506
|
+
? artifactRelativePath(packagePath, manifestResult.path)
|
|
507
|
+
: null,
|
|
508
|
+
sourceAssetCrawl: crawl,
|
|
509
|
+
templateFamily: resolveTemplateFamilyDesignSource(commerceCatalog, templateFamily),
|
|
510
|
+
templateStockPageIds: [...templateStockPageIds],
|
|
511
|
+
packageId: `${mapId}:design-source`,
|
|
512
|
+
campaignMapId: mapId,
|
|
513
|
+
campaignSlug: publicRouteSlug,
|
|
514
|
+
sourceRoot: artifactRelativePath(packagePath, sourceRoot),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function prepareDesignSourcePackage({
|
|
519
|
+
path,
|
|
520
|
+
activePages,
|
|
521
|
+
mappings,
|
|
522
|
+
manifestResult,
|
|
523
|
+
sourceAssetCrawl,
|
|
524
|
+
templateFamily,
|
|
525
|
+
templateStockPageIds = [],
|
|
526
|
+
commerceCatalog,
|
|
527
|
+
sourceRoot,
|
|
528
|
+
mapId,
|
|
529
|
+
publicRouteSlug,
|
|
530
|
+
priorProvenance = null,
|
|
531
|
+
force = false,
|
|
532
|
+
}) {
|
|
533
|
+
const currentPageScope = {
|
|
534
|
+
activePages,
|
|
535
|
+
mappings,
|
|
536
|
+
campaignMapId: mapId,
|
|
537
|
+
campaignSlug: publicRouteSlug,
|
|
538
|
+
};
|
|
539
|
+
const currentHtmlFunnelScope = createCurrentHtmlFunnelScope({
|
|
540
|
+
packagePath: path,
|
|
541
|
+
activePages,
|
|
542
|
+
mappings,
|
|
543
|
+
manifestResult,
|
|
544
|
+
sourceAssetCrawl,
|
|
545
|
+
templateFamily,
|
|
546
|
+
templateStockPageIds,
|
|
547
|
+
commerceCatalog,
|
|
548
|
+
sourceRoot,
|
|
549
|
+
mapId,
|
|
550
|
+
publicRouteSlug,
|
|
551
|
+
});
|
|
552
|
+
let value;
|
|
553
|
+
let rawBytes;
|
|
554
|
+
let mode;
|
|
555
|
+
|
|
556
|
+
const readExisting = () => {
|
|
557
|
+
let existingBytes;
|
|
558
|
+
try {
|
|
559
|
+
const stats = lstatSync(path);
|
|
560
|
+
if (!stats.isFile()) {
|
|
561
|
+
const kind = stats.isDirectory()
|
|
562
|
+
? "directory"
|
|
563
|
+
: stats.isSymbolicLink()
|
|
564
|
+
? "symbolic link"
|
|
565
|
+
: "non-regular filesystem entry";
|
|
566
|
+
throw new Error(`expected a regular file, but found a ${kind}`);
|
|
567
|
+
}
|
|
568
|
+
existingBytes = readFileSync(path);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
const code = error?.code ? ` (${error.code})` : "";
|
|
571
|
+
throw new Error(`Could not read Design Source Package artifact at ${path}${code}: ${error.message}`, { cause: error });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let existingValue;
|
|
575
|
+
try {
|
|
576
|
+
existingValue = JSON.parse(existingBytes.toString("utf8"));
|
|
577
|
+
} catch (error) {
|
|
578
|
+
throw new Error(`Design Source Package at ${path} is not valid JSON: ${error.message}`, { cause: error });
|
|
579
|
+
}
|
|
580
|
+
const existingSha256 = hashSerializedDesignSourcePackage(existingBytes);
|
|
581
|
+
const synthesizedHere = Array.isArray(priorProvenance?.synthesized)
|
|
582
|
+
&& priorProvenance.synthesized.includes(existingSha256);
|
|
583
|
+
let problem = preparedDesignSourcePackageProblem(existingValue, currentPageScope, currentHtmlFunnelScope);
|
|
584
|
+
if (problem == null && existingValue.source_kind !== "html_funnel") {
|
|
585
|
+
problem = `declares source_kind ${JSON.stringify(existingValue.source_kind)}; html_funnel prepare-build requires "html_funnel".`;
|
|
586
|
+
}
|
|
587
|
+
if (problem != null) {
|
|
588
|
+
// A package an earlier prepare-build synthesized, unchanged since, is
|
|
589
|
+
// this producer's own stale output: --force regenerates it from the
|
|
590
|
+
// current inputs. Anything else is left for the operator to reconcile.
|
|
591
|
+
if (synthesizedHere && force) return { stale: true, sha256: existingSha256 };
|
|
592
|
+
const recovery = synthesizedHere
|
|
593
|
+
? "It was synthesized by an earlier prepare-build and is unchanged since, so rerun with --force to regenerate it from the current inputs "
|
|
594
|
+
+ "(--force also resets any stage evidence the Assembly Report carries)."
|
|
595
|
+
: "prepare-build cannot show it synthesized these bytes (no Assembly Report records them as its own output, or the package changed since), so it will not overwrite it. "
|
|
596
|
+
+ `Reconcile the package with the current inputs, or, if no downstream stage has consumed it, delete ${path} and rerun prepare-build to synthesize a fresh one.`;
|
|
597
|
+
throw new Error(`Design Source Package at ${path} is invalid, stale, or contradictory: ${problem} ${recovery}`);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
value: existingValue,
|
|
601
|
+
rawBytes: existingBytes,
|
|
602
|
+
origin: synthesizedHere ? DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED : DESIGN_SOURCE_PACKAGE_ORIGIN_ADOPTED,
|
|
603
|
+
};
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const synthesizeCurrent = () => {
|
|
607
|
+
const synthesized = synthesizeHtmlFunnelDesignSourcePackage(currentHtmlFunnelScope);
|
|
608
|
+
assertValidPreparedDesignSourcePackage(synthesized, path, currentPageScope, currentHtmlFunnelScope);
|
|
609
|
+
return { value: synthesized, rawBytes: Buffer.from(serializeDesignSourcePackage(synthesized), "utf8") };
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
let origin = DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED;
|
|
613
|
+
|
|
614
|
+
// Synthesize the current package and fully write it to a same-directory
|
|
615
|
+
// staging file. Nothing at `path` is touched yet, so a failure here (inputs
|
|
616
|
+
// that no longer validate, a read-only target, a full disk) leaves any
|
|
617
|
+
// existing package exactly as it was.
|
|
618
|
+
const stageSynthesized = () => {
|
|
619
|
+
({ value, rawBytes } = synthesizeCurrent());
|
|
620
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
621
|
+
const stagedPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
622
|
+
try {
|
|
623
|
+
writeFileSync(stagedPath, rawBytes, { flag: "wx" });
|
|
624
|
+
} catch (error) {
|
|
625
|
+
rmSync(stagedPath, { force: true });
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
return stagedPath;
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// withDesignSourcePublication holds the target lock around this whole
|
|
632
|
+
// decision, so no other publication (prepare-build, start or build) lands
|
|
633
|
+
// here meanwhile. A package that still appears at
|
|
634
|
+
// `path` (an operator, or a tool that does not take the lock) is
|
|
635
|
+
// authoritative: validate and reuse its exact bytes rather than overwrite.
|
|
636
|
+
const reuseWinner = () => {
|
|
637
|
+
const winner = readExisting();
|
|
638
|
+
if (winner.stale) {
|
|
639
|
+
throw new Error(`Design Source Package at ${path} appeared while prepare-build was publishing and does not match this run's inputs; if it is a stale package an earlier prepare-build synthesized, rerun prepare-build with --force.`);
|
|
640
|
+
}
|
|
641
|
+
({ value, rawBytes, origin } = winner);
|
|
642
|
+
mode = "reused";
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const publishStaged = (stagedPath) => {
|
|
646
|
+
try {
|
|
647
|
+
// Linking a fully written same-directory staging file is the Node
|
|
648
|
+
// primitive that combines atomic visibility with no-replace
|
|
649
|
+
// publication. A plain rename fallback is intentionally unsafe here:
|
|
650
|
+
// it could replace a package that appeared after the absence check.
|
|
651
|
+
linkSync(stagedPath, path);
|
|
652
|
+
mode = "emitted";
|
|
653
|
+
} catch (error) {
|
|
654
|
+
// EEXIST means a package appeared at the publication seam; for any
|
|
655
|
+
// other link failure, prefer validating such a package before failing
|
|
656
|
+
// closed.
|
|
657
|
+
if (error?.code !== "EEXIST" && !existsSync(path)) {
|
|
658
|
+
throw new Error(
|
|
659
|
+
`Could not atomically publish Design Source Package artifact at ${path}${error?.code ? ` (${error.code})` : ""}: `
|
|
660
|
+
+ `exclusive hard-link publication failed; refusing an unsafe rename fallback that could overwrite a concurrent winner: ${error.message}`,
|
|
661
|
+
{ cause: error },
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
// Another writer's package is at the path, not this run's bytes: the
|
|
665
|
+
// pending record stops vouching for the candidate before it is judged.
|
|
666
|
+
recordPendingDesignSourceProvenance(path, pendingCarried);
|
|
667
|
+
reuseWinner();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// --force over this producer's own stale package. The replacement is
|
|
672
|
+
// already staged. The stale bytes are claimed by moving them aside and
|
|
673
|
+
// re-hashed there, so an edit that landed after they were judged stale is
|
|
674
|
+
// caught instead of discarded; the replacement then goes out through the
|
|
675
|
+
// same no-replace link as a fresh emit. The claimed bytes are deleted only
|
|
676
|
+
// once they are proven to be the stale bytes and the replacement is out, or
|
|
677
|
+
// once they are back in place after a failure; otherwise they are kept and
|
|
678
|
+
// named.
|
|
679
|
+
const replaceStale = (stagedPath, staleSha256) => {
|
|
680
|
+
const retiredPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.stale`);
|
|
681
|
+
try {
|
|
682
|
+
renameSync(path, retiredPath);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
// Removed since it was read: there is nothing left to replace.
|
|
685
|
+
if (error?.code === "ENOENT") return publishStaged(stagedPath);
|
|
686
|
+
throw new Error(`Could not claim the stale Design Source Package at ${path} for regeneration${error?.code ? ` (${error.code})` : ""}: ${error.message}`, { cause: error });
|
|
687
|
+
}
|
|
688
|
+
// Put the claimed bytes back without replacing anything that has appeared
|
|
689
|
+
// at `path` since. True only when `path` is now those very bytes (the
|
|
690
|
+
// same file), not merely occupied by another package.
|
|
691
|
+
const putBack = () => {
|
|
692
|
+
try {
|
|
693
|
+
linkSync(retiredPath, path);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (error?.code !== "EEXIST") return false;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const atPath = statSync(path);
|
|
699
|
+
const retired = statSync(retiredPath);
|
|
700
|
+
return atPath.ino === retired.ino && atPath.dev === retired.dev;
|
|
701
|
+
} catch {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
try {
|
|
706
|
+
if (hashSerializedDesignSourcePackage(readFileSync(retiredPath)) !== staleSha256) {
|
|
707
|
+
throw new Error(`Design Source Package at ${path} changed after prepare-build judged it stale, so it was not replaced. Rerun prepare-build to judge the current package.`);
|
|
708
|
+
}
|
|
709
|
+
publishStaged(stagedPath);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (putBack()) {
|
|
712
|
+
rmSync(retiredPath, { force: true });
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
throw new Error(`${error.message} The Design Source Package moved aside for regeneration could not be put back and is kept at ${retiredPath}.`, { cause: error });
|
|
716
|
+
}
|
|
717
|
+
rmSync(retiredPath, { force: true });
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const existing = existsSync(path) ? readExisting() : null;
|
|
721
|
+
// The stale package a --force run replaces is provably the producer's own;
|
|
722
|
+
// its hash stays in the pending record until the replacement is recorded,
|
|
723
|
+
// so a failed or interrupted replacement that leaves it in place (put back,
|
|
724
|
+
// or never moved) can still be regenerated.
|
|
725
|
+
const pendingCarried = existing?.stale ? [existing.sha256] : [];
|
|
726
|
+
if (existing && !existing.stale) {
|
|
727
|
+
({ value, rawBytes, origin } = existing);
|
|
728
|
+
mode = "reused";
|
|
729
|
+
} else {
|
|
730
|
+
const stagedPath = stageSynthesized();
|
|
731
|
+
try {
|
|
732
|
+
recordPendingDesignSourceProvenance(path, [hashSerializedDesignSourcePackage(rawBytes), ...pendingCarried]);
|
|
733
|
+
if (existing?.stale) replaceStale(stagedPath, existing.sha256);
|
|
734
|
+
else publishStaged(stagedPath);
|
|
735
|
+
} finally {
|
|
736
|
+
rmSync(stagedPath, { force: true });
|
|
737
|
+
}
|
|
738
|
+
if (existing?.stale && mode === "emitted") {
|
|
739
|
+
mode = "regenerated";
|
|
740
|
+
console.warn(
|
|
741
|
+
`[campaigns-os prepare-build] --force: regenerated the stale Design Source Package at ${path} that an earlier prepare-build synthesized.`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const referenceIdentity = createDesignSourcePackageArtifactReference(value, {
|
|
747
|
+
path: ".",
|
|
748
|
+
serialized: rawBytes,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
path,
|
|
753
|
+
value,
|
|
754
|
+
rawBytes,
|
|
755
|
+
mode,
|
|
756
|
+
origin,
|
|
757
|
+
referenceFor(artifactPath) {
|
|
758
|
+
return {
|
|
759
|
+
...referenceIdentity,
|
|
760
|
+
path: artifactRelativePath(artifactPath, path),
|
|
761
|
+
};
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const PUBLICATION_STEPS = ["package", "writeTheme", "publish", "publishDoctorOutput"];
|
|
767
|
+
|
|
768
|
+
// Runs `fn(publication)` inside the target lock; the handle's steps run in
|
|
769
|
+
// PUBLICATION_STEPS order, and `package` and `publish` run once.
|
|
770
|
+
export async function withDesignSourcePublication({ targetRepo, outputs, force = false, publishSpec = null }, fn) {
|
|
771
|
+
const { packetPath, contextPath, reportPath, doctorOutPath, briefPath } = outputs;
|
|
772
|
+
const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
|
|
773
|
+
const paths = Object.freeze({ packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath });
|
|
774
|
+
const prepareBuildOutputPaths = [
|
|
775
|
+
["Build Packet", packetPath],
|
|
776
|
+
["Build Context", contextPath],
|
|
777
|
+
["Assembly Report", reportPath],
|
|
778
|
+
["Doctor Output", doctorOutPath],
|
|
779
|
+
["Campaign Build Brief", briefPath],
|
|
780
|
+
];
|
|
781
|
+
const prepareBuildThemeOutputPaths = [
|
|
782
|
+
["Theme Report", resolve(targetRepo, ".campaign-runtime/theme/theme-report.json")],
|
|
783
|
+
["Brand Theme CSS", resolve(targetRepo, ".campaign-runtime/theme/brand-theme.css")],
|
|
784
|
+
];
|
|
785
|
+
const prepareBuildCollisionPaths = [
|
|
786
|
+
...prepareBuildOutputPaths,
|
|
787
|
+
...prepareBuildThemeOutputPaths,
|
|
788
|
+
["Design Source Package", designSourcePackagePath],
|
|
789
|
+
["prepare-build target lock directory", targetLockPath(targetRepo), { reservedTree: true }],
|
|
790
|
+
["Design Source Package pending provenance record", pendingDesignSourceProvenancePath(designSourcePackagePath)],
|
|
791
|
+
];
|
|
792
|
+
assertDistinctPrepareBuildOutputPaths(prepareBuildCollisionPaths);
|
|
793
|
+
// One prepare-build at a time per target, from reading its inputs through
|
|
794
|
+
// the packet, context and report that record them. Stage writers take the
|
|
795
|
+
// same lock around their Assembly Report edits (commitAssemblyReport), so
|
|
796
|
+
// no stage evidence lands between the pre-publish re-check and the rename;
|
|
797
|
+
// they enter directly when prepare-build itself reaches them. The lock is taken before
|
|
798
|
+
// the stage-evidence guard and before the CampaignSpec, manifest, mappings
|
|
799
|
+
// and asset crawl are read, so the evidence check, the input snapshot whose
|
|
800
|
+
// hashes the Design Source Package records, and the publication are one
|
|
801
|
+
// critical section: a run that waited here sees exactly what the last
|
|
802
|
+
// completed run left, not what was on disk when it started waiting.
|
|
803
|
+
return withTargetLock(targetRepo, () => {
|
|
804
|
+
// The fetched spec goes into the shared cache file inside the critical
|
|
805
|
+
// section: the spec this run reads, hashes and records is the one it
|
|
806
|
+
// fetched, and a run waiting here cannot replace it meanwhile.
|
|
807
|
+
publishSpec?.();
|
|
808
|
+
let announcedStageEvidence = guardAssemblyReportOverwrite(reportPath, { force });
|
|
809
|
+
// Stage producers commit the report without this lock, so evidence can land
|
|
810
|
+
// while the run works. Re-check before each write that is not rolled back:
|
|
811
|
+
// the theme artifacts, then the JSON outputs. --force names a stage once.
|
|
812
|
+
const recheckStageEvidence = () => {
|
|
813
|
+
const found = guardAssemblyReportOverwrite(reportPath, { force }, { announced: announcedStageEvidence });
|
|
814
|
+
announcedStageEvidence = [...new Set([...announcedStageEvidence, ...found])];
|
|
815
|
+
};
|
|
816
|
+
let completed = 0;
|
|
817
|
+
const enter = (index, { once = false } = {}) => {
|
|
818
|
+
if (completed < index) throw new TypeError(`Design Source Package publication: ${PUBLICATION_STEPS[index]}() cannot run before ${PUBLICATION_STEPS[completed]}().`);
|
|
819
|
+
if (once && completed > index) throw new TypeError(`Design Source Package publication: ${PUBLICATION_STEPS[index]}() already ran.`);
|
|
820
|
+
};
|
|
821
|
+
let designSourcePackage = null;
|
|
822
|
+
return fn(Object.freeze({
|
|
823
|
+
paths,
|
|
824
|
+
package(inputs) {
|
|
825
|
+
enter(0, { once: true });
|
|
826
|
+
// Reject deterministic destination failures before publishing the fixed
|
|
827
|
+
// DSP. The DSP intentionally precedes the theme and JSON artifacts that
|
|
828
|
+
// reference it; a later I/O race may therefore leave a valid DSP for a
|
|
829
|
+
// subsequent run to validate and reuse. Never roll it back here because a
|
|
830
|
+
// concurrent prepare may already have consumed its exact bytes.
|
|
831
|
+
preflightPrepareBuildOutputTargets(
|
|
832
|
+
[...prepareBuildOutputPaths, ...prepareBuildThemeOutputPaths],
|
|
833
|
+
prepareBuildCollisionPaths,
|
|
834
|
+
);
|
|
835
|
+
designSourcePackage = prepareDesignSourcePackage({
|
|
836
|
+
...inputs,
|
|
837
|
+
path: designSourcePackagePath,
|
|
838
|
+
priorProvenance: readPriorDesignSourceProvenance(reportPath, designSourcePackagePath),
|
|
839
|
+
force,
|
|
840
|
+
});
|
|
841
|
+
completed = 1;
|
|
842
|
+
return designSourcePackage;
|
|
843
|
+
},
|
|
844
|
+
writeTheme(inspection, { writeCss }) {
|
|
845
|
+
enter(1);
|
|
846
|
+
// Inspection can take time, so reject aliases or invalid target types again
|
|
847
|
+
// before publication. The shared theme writer then stages each artifact in
|
|
848
|
+
// its destination directory and atomically replaces the final entry, making
|
|
849
|
+
// a post-preflight symlink or hard-link swap safe without rolling back DSP.
|
|
850
|
+
preflightPrepareBuildOutputTargets(
|
|
851
|
+
prepareBuildThemeOutputPaths,
|
|
852
|
+
prepareBuildCollisionPaths,
|
|
853
|
+
);
|
|
854
|
+
// The theme files are replaced in place, not staged with the JSON outputs,
|
|
855
|
+
// so evidence that has landed by now refuses the run before any is written.
|
|
856
|
+
recheckStageEvidence();
|
|
857
|
+
const written = writeThemeArtifacts(inspection, { writeReport: true, writeCss, force, packetPath });
|
|
858
|
+
completed = Math.max(completed, 2);
|
|
859
|
+
return written;
|
|
860
|
+
},
|
|
861
|
+
publish({ packet, brief, context, report }) {
|
|
862
|
+
enter(2, { once: true });
|
|
863
|
+
// Before the report goes out, narrow the pending record to exactly what the
|
|
864
|
+
// report will say: the package's own hash when it is synthesized, nothing
|
|
865
|
+
// when it is adopted. A candidate this run or an earlier failed one never
|
|
866
|
+
// published cannot then outlive the report, even if the run dies between
|
|
867
|
+
// publishing the report and removing the record below.
|
|
868
|
+
recordPendingDesignSourceProvenance(
|
|
869
|
+
designSourcePackagePath,
|
|
870
|
+
designSourcePackage.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED
|
|
871
|
+
? [hashSerializedDesignSourcePackage(designSourcePackage.rawBytes)]
|
|
872
|
+
: [],
|
|
873
|
+
);
|
|
874
|
+
publishPrepareBuildJsonOutputs([
|
|
875
|
+
{ label: "Build Packet", path: packetPath, value: packet },
|
|
876
|
+
{ label: "Campaign Build Brief", path: briefPath, value: brief },
|
|
877
|
+
{ label: "Build Context", path: contextPath, value: context },
|
|
878
|
+
{ label: "Assembly Report", path: reportPath, value: report },
|
|
879
|
+
], prepareBuildCollisionPaths, {
|
|
880
|
+
beforePublish: recheckStageEvidence,
|
|
881
|
+
});
|
|
882
|
+
// The report now records the package and its origin, so a pending
|
|
883
|
+
// provenance record has served its purpose.
|
|
884
|
+
rmSync(pendingDesignSourceProvenancePath(designSourcePackagePath), { force: true });
|
|
885
|
+
completed = 3;
|
|
886
|
+
},
|
|
887
|
+
publishDoctorOutput(doctor, { command }) {
|
|
888
|
+
enter(3);
|
|
889
|
+
// Through the intake's own collision-checked writer, so the sidecar is
|
|
890
|
+
// stamped with the intake command that ran doctor (`start` or `build`,
|
|
891
|
+
// #312) without a second write path for it.
|
|
892
|
+
publishPrepareBuildJsonOutputs([
|
|
893
|
+
{ label: "Doctor Output", path: doctorOutPath, value: stampDoctorProducer(doctor, command) },
|
|
894
|
+
], prepareBuildCollisionPaths);
|
|
895
|
+
},
|
|
896
|
+
}));
|
|
897
|
+
}, { command: "prepare-build" });
|
|
898
|
+
}
|