@nextcommerce/campaigns-os 1.41.2 → 1.43.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 +4 -2
- package/CHANGELOG.md +629 -0
- package/README.md +8 -6
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +118 -25
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1424 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +120 -8
- package/docs/campaigns-os-build-flow.md +3 -2
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +83 -2
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +16 -6
- package/docs/qa-and-test-orders.md +157 -17
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- 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 +10 -9
- package/skills.json +11 -11
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +796 -6963
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/finding-cause.mjs +14 -10
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -37
- package/src/progress.mjs +5 -3
- 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 +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +276 -39
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +32 -1
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/tooling-setup.mjs +160 -0
package/src/diagnostic.mjs
CHANGED
|
@@ -19,7 +19,8 @@ const REASONS = new Set([
|
|
|
19
19
|
"theme_gate.starter_palette_only", "built_output.campaign_identity",
|
|
20
20
|
"built_output.upsell_selector_scope", "built_output.sdk_markup.swap_with_add_to_cart",
|
|
21
21
|
"built_output.sdk_markup.checkout_not_form", "built_output.sdk_markup.wrong_field_name",
|
|
22
|
-
"built_output.sdk_markup.missing_selector_id_match",
|
|
22
|
+
"built_output.sdk_markup.missing_selector_id_match", "built_output.script_syntax.parse_failure",
|
|
23
|
+
"built_output.script_syntax.missing_script",
|
|
23
24
|
]);
|
|
24
25
|
const ACTIONS = new Set([
|
|
25
26
|
"repair_target", "align_store_profile", "align_sdk_version", "repair_waiver", "waive_checkpoint",
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// A cross-process exclusive lock held as a directory.
|
|
2
|
+
//
|
|
3
|
+
// The lock directory and its owner record appear together: a holder builds
|
|
4
|
+
// `<lock>.staging-<token>/owner.json` beside the lock and renames the staging
|
|
5
|
+
// directory onto the lock path. The rename never replaces an existing entry,
|
|
6
|
+
// so of several processes exactly one publishes, and there is no moment at
|
|
7
|
+
// which the lock exists without the owner that holds it. Before entering its
|
|
8
|
+
// critical section the holder re-reads owner.json and requires its own token
|
|
9
|
+
// (fencing), and on release it only ever removes a directory that still
|
|
10
|
+
// carries its token.
|
|
11
|
+
//
|
|
12
|
+
// A lock left behind by a process that died is recovered: the owner's pid no
|
|
13
|
+
// longer exists, and one waiter claims recovery exclusively (the claim is
|
|
14
|
+
// published the same staged way) before renaming the abandoned lock away. An
|
|
15
|
+
// interrupted recovery claim fails closed rather than being stolen, which
|
|
16
|
+
// would reintroduce a check/rename race. A lock directory WITHOUT an owner
|
|
17
|
+
// record is never taken over: this module cannot produce one, so it belongs
|
|
18
|
+
// to an older writer that may still be alive between its mkdir and its owner
|
|
19
|
+
// write (#501). A waiter refuses it after a short grace, leaving it for the
|
|
20
|
+
// documented offline procedure. (See publishStagedDirectory for the one
|
|
21
|
+
// mixed-version race this cannot close, tracked in #514.)
|
|
22
|
+
//
|
|
23
|
+
// The lock is reentrant for its holder: code running inside `fn` (in the
|
|
24
|
+
// same async context) that asks for the same lock enters directly instead of
|
|
25
|
+
// waiting on itself. prepare-build holds the per-target lock and can reach
|
|
26
|
+
// stage writers that take it too.
|
|
27
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
|
+
import { randomBytes } from "node:crypto";
|
|
29
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
31
|
+
|
|
32
|
+
const heldLocks = new AsyncLocalStorage();
|
|
33
|
+
|
|
34
|
+
const readOwner = (path) => {
|
|
35
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const exists = (path) => {
|
|
39
|
+
try { lstatSync(path); return true; } catch { return false; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The reentrancy key names the lock directory through its real parent, so a
|
|
43
|
+
// holder that reached the target through a symlink still recognizes itself.
|
|
44
|
+
function lockKey(path) {
|
|
45
|
+
const absolute = resolve(path);
|
|
46
|
+
try { return join(realpathSync(dirname(absolute)), basename(absolute)); } catch { return absolute; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stageOwnedDirectory(stagingPath, owner) {
|
|
50
|
+
rmSync(stagingPath, { recursive: true, force: true });
|
|
51
|
+
mkdirSync(stagingPath);
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(join(stagingPath, "owner.json"), `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
rmSync(stagingPath, { recursive: true, force: true });
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// rename(2) onto a non-empty directory fails with one of these; the holder
|
|
61
|
+
// may already have released by the time the error is seen, so they are
|
|
62
|
+
// contention whether or not the destination still exists.
|
|
63
|
+
const CONTENTION_CODES = new Set(["EEXIST", "ENOTEMPTY"]);
|
|
64
|
+
// Codes some platforms use for an existing destination (EPERM on Windows)
|
|
65
|
+
// that are also genuine failures: contention only while the destination
|
|
66
|
+
// exists.
|
|
67
|
+
const MAYBE_CONTENTION_CODES = new Set(["EPERM", "ENOTDIR", "EISDIR"]);
|
|
68
|
+
|
|
69
|
+
// Returns false when `dest` is held by someone else; throws on any other
|
|
70
|
+
// failure. The staging directory is gone either way.
|
|
71
|
+
function publishStagedDirectory(stagingPath, dest) {
|
|
72
|
+
try {
|
|
73
|
+
// rename(2) replaces an EMPTY destination directory, so never rename over
|
|
74
|
+
// an existing entry. This module never leaves an empty directory at a
|
|
75
|
+
// lock path; only an older release does, for the instant between its
|
|
76
|
+
// mkdir and its owner write. A new writer's check-then-rename can land in
|
|
77
|
+
// that instant and replace it, so running an older release and this one
|
|
78
|
+
// on the same target at the same moment is not safe (#501; tracked in #514).
|
|
79
|
+
if (exists(dest)) return false;
|
|
80
|
+
try {
|
|
81
|
+
renameSync(stagingPath, dest);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (CONTENTION_CODES.has(error?.code)) return false;
|
|
84
|
+
if (MAYBE_CONTENTION_CODES.has(error?.code) && exists(dest)) return false;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(stagingPath, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// An ownerless lock is never taken over, so waiting out the whole budget on
|
|
94
|
+
// one only delays the refusal. A live older writer fills its owner within
|
|
95
|
+
// microseconds; one still ownerless after this grace is refused at once.
|
|
96
|
+
const OWNERLESS_GRACE_MS = 1000;
|
|
97
|
+
|
|
98
|
+
function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGraceMs = OWNERLESS_GRACE_MS }) {
|
|
99
|
+
const token = randomBytes(16).toString("hex");
|
|
100
|
+
const start = now();
|
|
101
|
+
const owner = { pid: process.pid, token };
|
|
102
|
+
const ownerPath = join(path, "owner.json");
|
|
103
|
+
const stagingPath = `${path}.staging-${token}`;
|
|
104
|
+
|
|
105
|
+
const abandoned = () => {
|
|
106
|
+
try {
|
|
107
|
+
const stat = lstatSync(path);
|
|
108
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
109
|
+
const current = readOwner(ownerPath);
|
|
110
|
+
if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
|
|
111
|
+
try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const recover = () => {
|
|
118
|
+
if (!abandoned()) return;
|
|
119
|
+
const deadToken = readOwner(ownerPath)?.token;
|
|
120
|
+
const claim = join(path, ".recovery");
|
|
121
|
+
try {
|
|
122
|
+
const claimStaging = `${path}.recovery-staging-${token}`;
|
|
123
|
+
stageOwnedDirectory(claimStaging, owner);
|
|
124
|
+
if (!publishStagedDirectory(claimStaging, claim)) return;
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let moved = false;
|
|
129
|
+
try {
|
|
130
|
+
// Re-check under the claim: the owner must still be the same dead one.
|
|
131
|
+
if (!abandoned() || readOwner(ownerPath)?.token !== deadToken) return;
|
|
132
|
+
const tomb = `${path}.abandoned-${token}`;
|
|
133
|
+
renameSync(path, tomb);
|
|
134
|
+
moved = true;
|
|
135
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
136
|
+
} catch {
|
|
137
|
+
// Leave the lock for the next waiter or the offline procedure.
|
|
138
|
+
} finally {
|
|
139
|
+
if (!moved && readOwner(join(claim, "owner.json"))?.token === token) {
|
|
140
|
+
try { rmSync(claim, { recursive: true, force: true }); } catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const stage = () => stageOwnedDirectory(stagingPath, owner);
|
|
146
|
+
// Publish, then fence on the token actually on disk.
|
|
147
|
+
const publish = () => publishStagedDirectory(stagingPath, path) && readOwner(ownerPath)?.token === token;
|
|
148
|
+
const heldBySelfProcess = () => readOwner(ownerPath)?.pid === process.pid;
|
|
149
|
+
// The identity of the lock directory if it is ownerless, else null. Two
|
|
150
|
+
// stats cannot see the lock atomically: a holder can release between the
|
|
151
|
+
// stat of the directory and the stat of its owner, which reads as a missing
|
|
152
|
+
// owner. So the directory must still be the same one after the owner was
|
|
153
|
+
// found missing, and the grace runs only while the same directory stays
|
|
154
|
+
// ownerless: holders coming and going never add up to one ownerless lock.
|
|
155
|
+
let ownerlessSince = null;
|
|
156
|
+
let ownerlessIdentity = null;
|
|
157
|
+
const ownerless = () => {
|
|
158
|
+
try {
|
|
159
|
+
const before = lstatSync(path);
|
|
160
|
+
if (!before.isDirectory() || exists(ownerPath)) return null;
|
|
161
|
+
const after = lstatSync(path);
|
|
162
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.birthtimeMs !== before.birthtimeMs) return null;
|
|
163
|
+
return `${before.dev}:${before.ino}:${before.birthtimeMs}`;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
// True once the budget is spent, or once one lock directory has stayed
|
|
169
|
+
// ownerless past the grace period.
|
|
170
|
+
const expired = () => {
|
|
171
|
+
const current = now();
|
|
172
|
+
const identity = ownerless();
|
|
173
|
+
if (identity && identity === ownerlessIdentity) {
|
|
174
|
+
if (current - ownerlessSince >= ownerlessGraceMs) return true;
|
|
175
|
+
} else {
|
|
176
|
+
ownerlessIdentity = identity;
|
|
177
|
+
ownerlessSince = identity ? current : null;
|
|
178
|
+
}
|
|
179
|
+
return current - start >= budgetMs;
|
|
180
|
+
};
|
|
181
|
+
const fail = (error) => unavailable(error);
|
|
182
|
+
const contended = () => Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
|
|
183
|
+
|
|
184
|
+
const release = () => {
|
|
185
|
+
if (readOwner(ownerPath)?.token !== token) return;
|
|
186
|
+
const tomb = `${path}.released-${token}`;
|
|
187
|
+
try { renameSync(path, tomb); } catch { return; }
|
|
188
|
+
if (readOwner(join(tomb, "owner.json"))?.token === token) {
|
|
189
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
190
|
+
} else {
|
|
191
|
+
// Not ours after all: put it back rather than delete another holder's lock.
|
|
192
|
+
try { renameSync(tomb, path); } catch {}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function reentrantKey(path) {
|
|
200
|
+
const key = lockKey(path);
|
|
201
|
+
const token = heldLocks.getStore()?.get(key);
|
|
202
|
+
const held = Boolean(token) && readOwner(join(path, "owner.json"))?.token === token;
|
|
203
|
+
return { key, held };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function runHolding(key, token, fn) {
|
|
207
|
+
const held = new Map(heldLocks.getStore() ?? []);
|
|
208
|
+
held.set(key, token);
|
|
209
|
+
return heldLocks.run(held, fn);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Options: budgetMs, unavailable(error) -> Error; test seams: now() for the
|
|
213
|
+
// budget clock, sleep(ms) between attempts, hooks.beforePublish() awaited
|
|
214
|
+
// between staging the owner and publishing the lock.
|
|
215
|
+
export async function withDirectoryLock(path, fn, options) {
|
|
216
|
+
const { key, held } = reentrantKey(path);
|
|
217
|
+
if (held) return fn();
|
|
218
|
+
const lock = createLock(path, options);
|
|
219
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
220
|
+
while (true) {
|
|
221
|
+
let acquired;
|
|
222
|
+
try {
|
|
223
|
+
lock.stage();
|
|
224
|
+
if (options.hooks?.beforePublish) await options.hooks.beforePublish();
|
|
225
|
+
acquired = lock.publish();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw lock.fail(error);
|
|
228
|
+
}
|
|
229
|
+
if (acquired) break;
|
|
230
|
+
if (lock.expired()) throw lock.fail(lock.contended());
|
|
231
|
+
lock.recover();
|
|
232
|
+
await sleep(20);
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return await runHolding(key, lock.token, fn);
|
|
236
|
+
} finally {
|
|
237
|
+
lock.release();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sleepSync = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
242
|
+
|
|
243
|
+
// The synchronous form, for writers whose callers are synchronous. It blocks
|
|
244
|
+
// the event loop while it waits, so when the lock is held by this same
|
|
245
|
+
// process outside the caller's async context it refuses at once instead of
|
|
246
|
+
// waiting out its budget: that holder cannot run until this call returns.
|
|
247
|
+
export function withDirectoryLockSync(path, fn, options) {
|
|
248
|
+
const { key, held } = reentrantKey(path);
|
|
249
|
+
if (held) return fn();
|
|
250
|
+
const lock = createLock(path, options);
|
|
251
|
+
while (true) {
|
|
252
|
+
let acquired;
|
|
253
|
+
try {
|
|
254
|
+
lock.stage();
|
|
255
|
+
options.hooks?.beforePublish?.();
|
|
256
|
+
acquired = lock.publish();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw lock.fail(error);
|
|
259
|
+
}
|
|
260
|
+
if (acquired) break;
|
|
261
|
+
if (lock.expired() || lock.heldBySelfProcess()) throw lock.fail(lock.contended());
|
|
262
|
+
lock.recover();
|
|
263
|
+
(options.sleep ?? sleepSync)(20);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return runHolding(key, lock.token, fn);
|
|
267
|
+
} finally {
|
|
268
|
+
lock.release();
|
|
269
|
+
}
|
|
270
|
+
}
|