@bitkyc08/opencodex 2.16.0 → 2.17.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.
@@ -1,14 +1,11 @@
1
1
  /**
2
- * Source-preserving mutation for the one YAML fragment managed by OMP.
2
+ * Source-preserving mutation for one plain block-map YAML fragment.
3
3
  *
4
- * The general integration writer operates on parsed documents. Re-rendering a
5
- * shared YAML file would preserve values but destroy comments and formatting
6
- * outside `providers.opencodex`. OMP is the only client whose ownership model
7
- * deliberately permits those unrelated source edits, so its writer replaces
8
- * or removes only the exact block-style mapping entry it owns.
9
- *
10
- * Unsupported or ambiguous YAML returns `null`. The caller treats that as an
11
- * unsafe refusal instead of falling back to whole-document serialization.
4
+ * Shared client settings are not ours to re-render. This scanner accepts only
5
+ * the small, unambiguous source shape we can patch byte-for-byte around the
6
+ * owned leaf. Every candidate is parsed again and compared with the complete
7
+ * expected document; unsupported YAML fails closed and never falls back to a
8
+ * whole-document serializer.
12
9
  */
13
10
  import { renderYaml } from "./serialize";
14
11
 
@@ -18,6 +15,26 @@ interface SourceLine {
18
15
  body: string;
19
16
  }
20
17
 
18
+ interface LocatedEntry {
19
+ lines: readonly SourceLine[];
20
+ index: number;
21
+ indent: number;
22
+ endIndex: number;
23
+ }
24
+
25
+ interface MissingEntry {
26
+ lines: readonly SourceLine[];
27
+ missingDepth: number;
28
+ indent: number;
29
+ insertAt: number;
30
+ }
31
+
32
+ type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry };
33
+
34
+ export type YamlFragmentMutation =
35
+ | { kind: "upsert"; value: unknown }
36
+ | { kind: "remove"; createdContainers: readonly string[] };
37
+
21
38
  export type OmpYamlMutation =
22
39
  | { kind: "upsert"; value: unknown }
23
40
  | { kind: "remove"; removeEmptyProviders: boolean };
@@ -54,11 +71,15 @@ function hasInlineComment(line: string): boolean {
54
71
  return line.includes("#");
55
72
  }
56
73
 
74
+ function regexpEscape(value: string): string {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
76
+ }
77
+
57
78
  function isPlainBlockKey(line: string, indent: number, key: string): boolean {
58
79
  const spaces = leadingSpaces(line);
59
80
  if (spaces !== indent) return false;
60
81
  const rest = line.slice(indent);
61
- return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest);
82
+ return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest);
62
83
  }
63
84
 
64
85
  function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
@@ -86,9 +107,8 @@ function childEnd(
86
107
  const body = lines[index]!.body;
87
108
  const spaces = leadingSpaces(body);
88
109
  if (spaces === null) return null;
89
- // Blank lines and same-level comments are conservatively outside the
90
- // managed entry. Deeper comments would be destroyed by replacement, so
91
- // refuse rather than guessing whether the user meant to keep them.
110
+ // Blank lines and same-level comments remain outside our replacement.
111
+ // Deeper comments belong to the leaf and would be destroyed, so refuse.
92
112
  if (isBlank(body)) return index;
93
113
  if (isComment(body)) return spaces <= indent ? index : null;
94
114
  if (spaces <= indent) return index;
@@ -112,8 +132,7 @@ function canonicalValue(value: unknown): unknown {
112
132
  function semanticallyMatches(text: string, expected: unknown): boolean {
113
133
  try {
114
134
  const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
115
- return JSON.stringify(canonicalValue(parsed))
116
- === JSON.stringify(canonicalValue(expected));
135
+ return JSON.stringify(canonicalValue(parsed)) === JSON.stringify(canonicalValue(expected));
117
136
  } catch {
118
137
  return false;
119
138
  }
@@ -123,108 +142,217 @@ function lineEnding(text: string): "\n" | "\r\n" {
123
142
  return text.includes("\r\n") ? "\r\n" : "\n";
124
143
  }
125
144
 
126
- function renderedEntry(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
127
- return renderYaml({ opencodex: value }, indent).replaceAll("\n", eol);
128
- }
129
-
130
- /**
131
- * Patch `providers.opencodex` while preserving every byte outside that entry.
132
- * The returned text is parsed and compared with `expected` before it is
133
- * accepted, so a scanner mistake fails closed rather than writing bad YAML.
134
- */
135
- export function patchOmpYamlSource(
136
- text: string,
137
- mutation: OmpYamlMutation,
138
- expected: unknown,
139
- ): string | null {
140
- const lines = sourceLines(text);
141
- const eol = lineEnding(text);
142
- const providerIndexes = lines
143
- .map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1)
144
- .filter(index => index >= 0);
145
-
146
- if (providerIndexes.length === 0) {
147
- if (mutation.kind === "remove") return null;
148
- const separator = text.length === 0 || text.endsWith("\n") ? "" : eol;
149
- const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`;
150
- return semanticallyMatches(patched, expected) ? patched : null;
145
+ function readPath(doc: unknown, path: readonly string[]): unknown {
146
+ let cursor = doc;
147
+ for (const key of path) {
148
+ if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) return undefined;
149
+ cursor = (cursor as Record<string, unknown>)[key];
150
+ if (cursor === undefined) return undefined;
151
151
  }
152
- if (providerIndexes.length !== 1) return null;
152
+ return cursor;
153
+ }
153
154
 
154
- const providersIndex = providerIndexes[0]!;
155
- const providersEnd = containerEnd(lines, providersIndex, 0);
156
- if (providersEnd === null) return null;
155
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
156
+ return value !== null && typeof value === "object" && !Array.isArray(value);
157
+ }
157
158
 
158
- let inferredIndent: number | null = null;
159
- for (let index = providersIndex + 1; index < providersEnd; index += 1) {
159
+ function immediateIndent(
160
+ lines: readonly SourceLine[],
161
+ start: number,
162
+ end: number,
163
+ parentIndent: number,
164
+ ): number | null {
165
+ let indent: number | null = null;
166
+ for (let index = start; index < end; index += 1) {
160
167
  const body = lines[index]!.body;
161
168
  const spaces = leadingSpaces(body);
162
169
  if (spaces === null) return null;
163
- if (!isBlank(body) && !isComment(body) && spaces > 0) {
164
- inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces);
170
+ if (isBlank(body) || isComment(body) || spaces <= parentIndent) continue;
171
+ indent = indent === null ? spaces : Math.min(indent, spaces);
172
+ }
173
+ return indent ?? parentIndent + 2;
174
+ }
175
+
176
+ /** Locate a path only when every present segment is a plain block-map key. */
177
+ function locatePath(text: string, parsed: unknown, path: readonly string[]): LocatedPath | null {
178
+ if (path.length === 0 || !isPlainRecord(parsed)) return null;
179
+ const lines = sourceLines(text);
180
+ if (lines.some(line => leadingSpaces(line.body) === null)) return null;
181
+
182
+ let rangeStart = 0;
183
+ let rangeEnd = lines.length;
184
+ let parentIndent = -2;
185
+ const prefix: string[] = [];
186
+ for (let depth = 0; depth < path.length; depth += 1) {
187
+ const indent = depth === 0 ? 0 : immediateIndent(lines, rangeStart, rangeEnd, parentIndent);
188
+ if (indent === null) return null;
189
+ const matches: number[] = [];
190
+ for (let index = rangeStart; index < rangeEnd; index += 1) {
191
+ if (isPlainBlockKey(lines[index]!.body, indent, path[depth]!)) matches.push(index);
192
+ }
193
+ if (matches.length > 1) return null;
194
+ prefix.push(path[depth]!);
195
+ if (matches.length === 0) {
196
+ // The parser saw this key through syntax we do not patch (quoted/flow,
197
+ // merge aliases, or an ambiguous indentation shape).
198
+ if (readPath(parsed, prefix) !== undefined) return null;
199
+ const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length;
200
+ return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } };
165
201
  }
202
+
203
+ const index = matches[0]!;
204
+ const end = containerEnd(lines, index, indent);
205
+ if (end === null) return null;
206
+ if (depth === path.length - 1) {
207
+ if (hasInlineComment(lines[index]!.body)) return null;
208
+ const leafEnd = childEnd(lines, index, end, indent);
209
+ if (leafEnd === null) return null;
210
+ return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } };
211
+ }
212
+ if (!isPlainRecord(readPath(parsed, prefix))) return null;
213
+ rangeStart = index + 1;
214
+ rangeEnd = end;
215
+ parentIndent = indent;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function nestedValue(path: readonly string[], value: unknown): Record<string, unknown> {
221
+ let nested: unknown = value;
222
+ for (let index = path.length - 1; index >= 0; index -= 1) nested = { [path[index]!]: nested };
223
+ return nested as Record<string, unknown>;
224
+ }
225
+
226
+ function rendered(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
227
+ return renderYaml(value as Record<string, unknown>, indent).replaceAll("\n", eol);
228
+ }
229
+
230
+ function preserveFinalNewline(candidate: string, original: string, eol: "\n" | "\r\n"): string {
231
+ if (original.length > 0 && !original.endsWith("\n") && candidate.endsWith(eol)) {
232
+ return candidate.slice(0, -eol.length);
166
233
  }
167
- const childIndexes = inferredIndent === null
168
- ? []
169
- : lines.slice(providersIndex + 1, providersEnd)
170
- .map((line, offset) => (
171
- isPlainBlockKey(line.body, inferredIndent!, "opencodex")
172
- ? providersIndex + 1 + offset
173
- : -1
174
- ))
175
- .filter(index => index >= 0);
176
- if (childIndexes.length > 1) return null;
177
-
178
- const childIndex = childIndexes[0];
179
- if (childIndex === undefined) {
180
- if (mutation.kind === "remove") return null;
181
- const indent = inferredIndent ?? 2;
182
- const insertAt = providersEnd < lines.length ? lines[providersEnd]!.start : text.length;
183
- const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
184
- const patched = `${text.slice(0, insertAt)}${prefix}${renderedEntry(mutation.value, indent, eol)}${text.slice(insertAt)}`;
185
- return semanticallyMatches(patched, expected) ? patched : null;
234
+ return candidate;
235
+ }
236
+
237
+ function upsertSource(
238
+ text: string,
239
+ parsed: unknown,
240
+ path: readonly string[],
241
+ value: unknown,
242
+ ): string | null {
243
+ const located = locatePath(text, parsed, path);
244
+ if (located === null) return null;
245
+ const eol = lineEnding(text);
246
+ if (located.kind === "existing") {
247
+ const { lines, index, indent, endIndex } = located.entry;
248
+ const startOffset = lines[index]!.start;
249
+ const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
250
+ const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`;
251
+ return preserveFinalNewline(candidate, text, eol);
186
252
  }
187
253
 
188
- const childIndent = leadingSpaces(lines[childIndex]!.body);
189
- if (childIndent === null || childIndent <= 0) return null;
190
- if (hasInlineComment(lines[childIndex]!.body)) return null;
191
- const endIndex = childEnd(lines, childIndex, providersEnd, childIndent);
192
- if (endIndex === null) return null;
193
- const startOffset = lines[childIndex]!.start;
254
+ const { missingDepth, indent, insertAt } = located.entry;
255
+ const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
256
+ const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol);
257
+ const candidate = `${text.slice(0, insertAt)}${prefix}${insertion}${text.slice(insertAt)}`;
258
+ return preserveFinalNewline(candidate, text, eol);
259
+ }
260
+
261
+ function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null {
262
+ let parsed: unknown;
263
+ try {
264
+ parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
265
+ } catch {
266
+ return null;
267
+ }
268
+ const located = locatePath(text, parsed, path);
269
+ if (located === null || located.kind !== "existing") return null;
270
+ const { lines, index, endIndex } = located.entry;
271
+ const startOffset = lines[index]!.start;
194
272
  const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
273
+ if (requireEmpty) {
274
+ const sourceBody = text.slice(lines[index]!.end, endOffset);
275
+ if (sourceBody.trim().length > 0) return null;
276
+ }
277
+ return `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
278
+ }
195
279
 
196
- if (mutation.kind === "upsert") {
197
- const patched = `${text.slice(0, startOffset)}${renderedEntry(mutation.value, childIndent, eol)}${text.slice(endOffset)}`;
198
- return semanticallyMatches(patched, expected) ? patched : null;
280
+ function planSourceRemoval(
281
+ text: string,
282
+ path: readonly string[],
283
+ createdContainers: readonly string[],
284
+ ): { text: string; prunedContainers: string[] } | null {
285
+ let next = removeExactPath(text, path, false);
286
+ if (next === null) return null;
287
+ const created = new Set(createdContainers);
288
+ const prunedContainers: string[] = [];
289
+ for (let depth = path.length - 1; depth >= 1; depth -= 1) {
290
+ const containerPath = path.slice(0, depth);
291
+ const encoded = containerPath.join("\u0000");
292
+ if (!created.has(encoded)) continue;
293
+ const pruned = removeExactPath(next, containerPath, true);
294
+ // A user-added sibling/comment makes this ancestor source-owned now. Keep
295
+ // it (and every parent) while still removing our leaf.
296
+ if (pruned === null) break;
297
+ next = pruned;
298
+ prunedContainers.push(encoded);
199
299
  }
300
+ return { text: next, prunedContainers };
301
+ }
200
302
 
201
- let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
202
- if (mutation.removeEmptyProviders) {
203
- let remaining: { providers?: unknown } | null;
204
- try {
205
- remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null;
206
- } catch {
207
- return null;
208
- }
209
- if (remaining && Object.hasOwn(remaining, "providers")) {
210
- const provider = remaining.providers;
211
- const empty = provider === null || (
212
- provider && typeof provider === "object" && !Array.isArray(provider)
213
- && Object.keys(provider as Record<string, unknown>).length === 0
214
- );
215
- if (!empty) return semanticallyMatches(patched, expected) ? patched : null;
216
- if (hasInlineComment(lines[providersIndex]!.body)) return null;
217
-
218
- const providerStart = lines[providersIndex]!.start;
219
- // Removing a container we created is safe only when nothing but our
220
- // entry occupied its source range. Comments or blank formatting make
221
- // that range user-owned and therefore ambiguous.
222
- for (let index = providersIndex + 1; index < providersEnd; index += 1) {
223
- if (index >= childIndex && index < endIndex) continue;
224
- if (lines[index]!.body.length > 0) return null;
225
- }
226
- patched = `${text.slice(0, providerStart)}${text.slice(endOffset)}`;
227
- }
303
+ /** Containers whose source ranges are still empty and safe to prune. */
304
+ export function sourcePrunableYamlContainers(
305
+ text: string,
306
+ path: readonly string[],
307
+ createdContainers: readonly string[],
308
+ ): readonly string[] | null {
309
+ return planSourceRemoval(text, path, createdContainers)?.prunedContainers ?? null;
310
+ }
311
+
312
+ function removeSource(
313
+ text: string,
314
+ path: readonly string[],
315
+ createdContainers: readonly string[],
316
+ ): string | null {
317
+ return planSourceRemoval(text, path, createdContainers)?.text ?? null;
318
+ }
319
+
320
+ /**
321
+ * Patch one arbitrary plain block-map path, preserving every other byte.
322
+ */
323
+ export function patchYamlFragmentSource(
324
+ text: string,
325
+ path: readonly string[],
326
+ mutation: YamlFragmentMutation,
327
+ expected: unknown,
328
+ ): string | null {
329
+ let parsed: unknown;
330
+ try {
331
+ parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
332
+ } catch {
333
+ return null;
228
334
  }
229
- return semanticallyMatches(patched, expected) ? patched : null;
335
+ const patched = mutation.kind === "upsert"
336
+ ? upsertSource(text, parsed, path, mutation.value)
337
+ : removeSource(text, path, mutation.createdContainers);
338
+ return patched !== null && semanticallyMatches(patched, expected) ? patched : null;
339
+ }
340
+
341
+ /** Backward-compatible OMP wrapper around the generic path patcher. */
342
+ export function patchOmpYamlSource(
343
+ text: string,
344
+ mutation: OmpYamlMutation,
345
+ expected: unknown,
346
+ ): string | null {
347
+ return patchYamlFragmentSource(
348
+ text,
349
+ ["providers", "opencodex"],
350
+ mutation.kind === "upsert"
351
+ ? mutation
352
+ : {
353
+ kind: "remove",
354
+ createdContainers: mutation.removeEmptyProviders ? ["providers"] : [],
355
+ },
356
+ expected,
357
+ );
230
358
  }
@@ -12,6 +12,8 @@ import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
14
14
  EXPORT_CLIENTS,
15
+ dshConfigPath,
16
+ dshHomeDir,
15
17
  gajaeConfigPath,
16
18
  gajaeHomeDir,
17
19
  hermesConfigPath,
@@ -38,6 +40,10 @@ export interface IntegrationClientSpec {
38
40
  configPath: (env?: NodeJS.ProcessEnv, home?: string) => string;
39
41
  /** Directory whose existence is the cheap "is it installed?" signal. */
40
42
  detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string;
43
+ /** Patch only this block-map YAML leaf; never re-render the shared file. */
44
+ sourcePreservingYaml?: { path: readonly string[] };
45
+ /** Coordinate the complete mutation through a sibling config lock. */
46
+ writerLock?: { suffix: ".lock" };
41
47
  }
42
48
 
43
49
  /**
@@ -74,6 +80,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
74
80
  id: "omp",
75
81
  configPath: (env = process.env, home = homedir()) => ompModelsConfigPath(env, home),
76
82
  detectDir: (env = process.env, home = homedir()) => ompAgentDir(env, home),
83
+ sourcePreservingYaml: { path: ["providers", "opencodex"] },
77
84
  },
78
85
  hermes: {
79
86
  id: "hermes",
@@ -98,6 +105,13 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
98
105
  configPath: (env = process.env, home = homedir()) => gajaeConfigPath(env, home),
99
106
  detectDir: (env = process.env, home = homedir()) => gajaeHomeDir(env, home),
100
107
  },
108
+ dsh: {
109
+ id: "dsh",
110
+ configPath: (env = process.env, home = homedir()) => dshConfigPath(env, home),
111
+ detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home),
112
+ sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] },
113
+ writerLock: { suffix: ".lock" },
114
+ },
101
115
  };
102
116
 
103
117
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -136,8 +136,8 @@ function recordedFragmentFingerprint(
136
136
  * what we may rewrite, and the contribution hash proves our catalog has not
137
137
  * moved on. Three classes of client (revising the unconditional whole-file
138
138
  * rule of devlog 260802_client_toggle_api/021 §3 for json — #1631):
139
- * OMP is fragment-scoped because its writer patches only
140
- * `providers.opencodex`, so the whole-file check is skipped entirely;
139
+ * registry-declared source-preserving YAML clients are fragment-scoped because
140
+ * their writers patch only the owned leaf, so the whole-file check is skipped;
141
141
  * strict-json clients keep the whole-file check but downgrade a drift with
142
142
  * intact owned fragments to `stale`, because a rewrite there can lose only
143
143
  * formatting (comments cannot parse, non-round-tripping numbers are refused
@@ -194,7 +194,8 @@ export function classifyIntegration(input: {
194
194
  if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
195
195
  return { state: "conflict", reason: "foreign-edit" };
196
196
  }
197
- if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
197
+ if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
198
+ && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
198
199
  /*
199
200
  * The file changed since we wrote it, but every fragment we own is still
200
201
  * byte-for-byte what we put there — a sibling edit, not tampering. Apply
@@ -0,0 +1,98 @@
1
+ import { rm, writeFile } from "node:fs/promises";
2
+
3
+ const LOCK_DEADLINE_MS = 2_000;
4
+ const LOCK_DELAYS_MS = [20, 40, 80, 160, 200] as const;
5
+
6
+ export interface IntegrationWriterLockSeams {
7
+ writeFile: (
8
+ path: string,
9
+ payload: string,
10
+ options: { flag: "wx"; mode: 0o600 },
11
+ ) => Promise<void>;
12
+ removeFile: (path: string) => Promise<void>;
13
+ now: () => number;
14
+ delay: (milliseconds: number) => Promise<void>;
15
+ pid: number;
16
+ }
17
+
18
+ export class IntegrationWriterLockBusyError extends Error {
19
+ constructor(readonly lockPath: string) {
20
+ super("integration_mutation_busy");
21
+ this.name = "IntegrationWriterLockBusyError";
22
+ }
23
+ }
24
+
25
+ export class IntegrationWriterLockIOError extends Error {
26
+ constructor(readonly lockPath: string, readonly operation: "acquire" | "release", cause: unknown) {
27
+ // The management route returns Error.message to its caller. Keep the
28
+ // private config path and OS diagnostic on typed fields/cause, not the wire.
29
+ super(`integration writer lock ${operation} failed`, { cause });
30
+ this.name = "IntegrationWriterLockIOError";
31
+ }
32
+ }
33
+
34
+ const defaultSeams: IntegrationWriterLockSeams = {
35
+ writeFile: async (path, payload, options) => { await writeFile(path, payload, options); },
36
+ // Match DSH rc.6: an already-absent lock is a successful release.
37
+ removeFile: async path => { await rm(path, { force: true }); },
38
+ now: () => Date.now(),
39
+ delay: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
40
+ pid: process.pid,
41
+ };
42
+
43
+ function errorCode(error: unknown): string | undefined {
44
+ return typeof error === "object" && error !== null && "code" in error
45
+ ? String((error as { code?: unknown }).code)
46
+ : undefined;
47
+ }
48
+
49
+ /**
50
+ * Hold the exact sibling `<settings>.lock` around one complete transaction.
51
+ * A contender is never deleted: release runs only after our exclusive create
52
+ * succeeded.
53
+ */
54
+ export async function withIntegrationWriterLock<T>(
55
+ configPath: string,
56
+ operation: () => Promise<T>,
57
+ seams: IntegrationWriterLockSeams = defaultSeams,
58
+ suffix: ".lock" = ".lock",
59
+ ): Promise<T> {
60
+ const lockPath = `${configPath}${suffix}`;
61
+ const startedAt = seams.now();
62
+ let delayIndex = 0;
63
+ for (;;) {
64
+ try {
65
+ await seams.writeFile(lockPath, `${seams.pid}\n`, { flag: "wx", mode: 0o600 });
66
+ break;
67
+ } catch (error) {
68
+ if (errorCode(error) !== "EEXIST") {
69
+ throw new IntegrationWriterLockIOError(lockPath, "acquire", error);
70
+ }
71
+ const elapsedMs = seams.now() - startedAt;
72
+ if (elapsedMs >= LOCK_DEADLINE_MS) {
73
+ throw new IntegrationWriterLockBusyError(lockPath);
74
+ }
75
+ const backoffMs = LOCK_DELAYS_MS[Math.min(delayIndex, LOCK_DELAYS_MS.length - 1)]!;
76
+ // Keep the final retry inside the advertised two-second deadline.
77
+ const delayMs = Math.min(backoffMs, LOCK_DEADLINE_MS - elapsedMs);
78
+ delayIndex += 1;
79
+ await seams.delay(delayMs);
80
+ }
81
+ }
82
+
83
+ let outcome: { ok: true; value: T } | { ok: false; error: unknown };
84
+ try {
85
+ outcome = { ok: true, value: await operation() };
86
+ } catch (error) {
87
+ outcome = { ok: false, error };
88
+ }
89
+ try {
90
+ await seams.removeFile(lockPath);
91
+ } catch (error) {
92
+ // Cleanup cannot replace the protected operation's actual failure.
93
+ if (!outcome.ok) throw outcome.error;
94
+ throw new IntegrationWriterLockIOError(lockPath, "release", error);
95
+ }
96
+ if (!outcome.ok) throw outcome.error;
97
+ return outcome.value;
98
+ }