@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
package/src/deviation.mjs
CHANGED
|
@@ -17,6 +17,17 @@ export const DEVIATION_JOURNAL_REL_PATH = ".campaign-runtime/agent-deviations.js
|
|
|
17
17
|
// (doctor, next, findings, telemetry, run, validate-*) never deviate.
|
|
18
18
|
export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["start", "prepare-build", "theme", "polish", "qa", "run-record"]));
|
|
19
19
|
|
|
20
|
+
// Setup and metadata subcommands of a tracked command. They produce no stage
|
|
21
|
+
// output, so running one outside the recommendation is not a detour:
|
|
22
|
+
// qa install-browser — installs the package-owned QA browser binary;
|
|
23
|
+
// qa policy set — edits the packet's QA policy fields (and the
|
|
24
|
+
// assembly report's mirror of them);
|
|
25
|
+
// qa resolve — read-only diagnostic: prints the derived QA targets
|
|
26
|
+
// and, unless --no-probe, probes their reachability.
|
|
27
|
+
// Every other subcommand of a tracked command (`qa run`, `polish capture`,
|
|
28
|
+
// `theme generate`, ...) is still compared against the recommendation.
|
|
29
|
+
export const UNTRACKED_SUBCOMMANDS = Object.freeze(new Set(["qa install-browser", "qa policy", "qa resolve"]));
|
|
30
|
+
|
|
20
31
|
// Commands every recommendation implicitly allows for its stage. Stage work is
|
|
21
32
|
// agent/skill work, so the expected command set is small and explicit.
|
|
22
33
|
const EXPECTED_COMMANDS_BY_STAGE = Object.freeze({
|
|
@@ -65,8 +76,9 @@ export function buildRecommendation({ stage, status, expectedCommands, now = new
|
|
|
65
76
|
* Compare a pipeline-advancing command against the session's last
|
|
66
77
|
* recommendation. Returns a deviation entry or null.
|
|
67
78
|
*/
|
|
68
|
-
export function detectDeviation({ lastRecommendation, command, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
|
|
79
|
+
export function detectDeviation({ lastRecommendation, command, subcommand = null, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
|
|
69
80
|
if (!TRACKED_STAGE_COMMANDS.has(command)) return null;
|
|
81
|
+
if (subcommand && UNTRACKED_SUBCOMMANDS.has(`${command} ${subcommand}`)) return null;
|
|
70
82
|
if (!lastRecommendation || !Array.isArray(lastRecommendation.expected_commands)) return null;
|
|
71
83
|
if (lastRecommendation.expected_commands.includes(command)) return null;
|
|
72
84
|
return {
|
package/src/diagnostic.mjs
CHANGED
|
@@ -19,7 +19,11 @@ 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", "source_html.producer_provenance",
|
|
24
|
+
"source_html.producer_provenance.source_type", "source_html.producer_provenance.screenshot_fallback_used",
|
|
25
|
+
"source_html.producer_provenance.semantic_section_count", "source_html.producer_provenance.material_fingerprint",
|
|
26
|
+
"source_html.producer_provenance.section_exports", "source_html.producer_provenance.waiver_inert",
|
|
23
27
|
]);
|
|
24
28
|
const ACTIONS = new Set([
|
|
25
29
|
"repair_target", "align_store_profile", "align_sdk_version", "repair_waiver", "waive_checkpoint",
|
|
@@ -30,7 +34,7 @@ const ACTIONS = new Set([
|
|
|
30
34
|
]);
|
|
31
35
|
|
|
32
36
|
const RECOVERY = Object.freeze({
|
|
33
|
-
skills: { owner: "operator", action_id: "install-skills", input_needed: "Selected agent profile", instruction: "Refresh the bundled skills for that profile
|
|
37
|
+
skills: { owner: "operator", action_id: "install-skills", input_needed: "Selected agent profile", instruction: "Refresh the bundled skills for that profile, then read the SKILL.md files install-skills lists under Read now in the running session; restart the agent only if it cannot read them." },
|
|
34
38
|
pin: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed toolkit version and lockfile", instruction: "Compare the installed package with the reviewed version; registry currency is not checked." },
|
|
35
39
|
update: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed upstream revision", instruction: "Review and update the toolkit checkout using the existing worktree workflow." },
|
|
36
40
|
doctor: { owner: "workflow_owner", action_id: "doctor", input_needed: "Local packet inputs and detailed doctor findings", instruction: "Inspect doctor locally and follow its existing owner, required inputs, and recovery actions." },
|
|
@@ -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
|
+
}
|