@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.
- package/gui/dist/assets/{index-CZwbOse7.js → index-DOKr6RBR.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/export-command.ts +19 -7
- package/src/cli/help.ts +1 -1
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +165 -3
- package/src/generated/compatibility-version.json +17 -13
- package/src/integrations/omp-yaml-source.ts +232 -104
- package/src/integrations/registry.ts +14 -0
- package/src/integrations/state.ts +4 -3
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +141 -16
- package/src/lib/shadow-call.ts +6 -14
- package/src/server/management/integration-routes.ts +21 -14
- package/src/server/responses/core.ts +0 -1
- package/src/types.ts +5 -5
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source-preserving mutation for
|
|
2
|
+
* Source-preserving mutation for one plain block-map YAML fragment.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
90
|
-
//
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
152
|
+
return cursor;
|
|
153
|
+
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
156
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
157
|
+
}
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
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 (
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
140
|
-
*
|
|
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
|
|
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
|
+
}
|