ast-mcp-server 0.12.0 → 0.13.1

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.
@@ -0,0 +1,335 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PUBLIC_ERROR_CODES, sanitizePublicText } from "./public-errors.js";
5
+ import { parseH03FixtureDescriptor, parseH05FixtureDescriptor, } from "./runtime-policy.js";
6
+ const FIXTURE_ID = /^[A-Za-z0-9_-]{1,64}$/;
7
+ export class H03TimeoutFixtureError extends Error {
8
+ name = "H03TimeoutFixtureError";
9
+ code = "STALE_GENERATION";
10
+ }
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+ /** Closed test-only lifecycle controller used by H-05 through ast_get_project_status. */
13
+ export class H05LifecycleFixtureController {
14
+ #descriptor;
15
+ #generationAbort = new AbortController();
16
+ #gates = new Map();
17
+ #events = [];
18
+ #active = 0;
19
+ #abortListeners = 0;
20
+ #staleSettlements = 0;
21
+ // prettier-ignore
22
+ constructor(descriptor) { if (!statSync(descriptor.controlDirectory, { throwIfNoEntry: false })?.isDirectory())
23
+ throw new Error("H-05 fixture control path must be an existing directory."); this.#descriptor = Object.freeze({ ...descriptor }); }
24
+ // prettier-ignore
25
+ capture(command) { if (Object.keys(command).sort().join(",") !== "callId,correlationId,fixtureId" || !FIXTURE_ID.test(command.callId) || !FIXTURE_ID.test(command.fixtureId) || !UUID.test(command.correlationId))
26
+ throw new Error("Invalid H-05 fixture command."); return Object.freeze({ ...command, generation: this.#descriptor.generation, ownerToken: this.#descriptor.ownerToken }); }
27
+ // prettier-ignore
28
+ hold(identity) { this.#assertCurrent(identity); const key = this.#key(identity); if (this.#gates.has(key))
29
+ throw new Error("Invalid or duplicate H-05 fixture id."); let release; const promise = new Promise((resolve) => (release = resolve)); this.#gates.set(key, { promise, release }); }
30
+ // prettier-ignore
31
+ advance(descriptor) { if (descriptor.generation <= this.#descriptor.generation)
32
+ throw new Error("H-05 fixture generation must increase."); this.#descriptor = Object.freeze({ ...descriptor }); this.#generationAbort.abort(); this.#generationAbort = new AbortController(); for (const gate of this.#gates.values())
33
+ gate.release(); this.#gates.clear(); }
34
+ // prettier-ignore
35
+ async run(identity, context, operation) {
36
+ this.#assertIdentity(identity);
37
+ const eventIdentity = { callId: identity.callId, correlationId: identity.correlationId, fixtureId: identity.fixtureId, generation: identity.generation, ownerToken: identity.ownerToken };
38
+ const generationSignal = this.#generationAbort.signal, signal = AbortSignal.any([context.signal, generationSignal]);
39
+ const checkpoint = () => { if (!this.accepts(identity) || generationSignal.aborted)
40
+ throw new H03TimeoutFixtureError(); context.checkpoint(); };
41
+ const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
42
+ this.#active += 1;
43
+ this.#emit({ ...eventIdentity, phase: "started" });
44
+ try {
45
+ const gate = this.#gates.get(this.#key(identity));
46
+ if (gate)
47
+ await this.#awaitGate(identity, gate, hooked);
48
+ checkpoint();
49
+ const result = await operation(hooked);
50
+ checkpoint();
51
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: "succeeded" });
52
+ return result;
53
+ }
54
+ catch (error) {
55
+ const code = error?.code, stale = !this.accepts(identity) || generationSignal.aborted, deadline = code === "OPERATION_DEADLINE_EXCEEDED", cancelled = code === "REQUEST_CANCELLED" || context.signal.aborted;
56
+ if (stale)
57
+ this.#staleSettlements += 1;
58
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: stale ? "STALE_GENERATION" : deadline ? code : cancelled ? "REQUEST_CANCELLED" : "failed" });
59
+ if (stale)
60
+ this.#staleSettlements -= 1;
61
+ throw stale ? new H03TimeoutFixtureError() : error;
62
+ }
63
+ finally {
64
+ this.#active -= 1;
65
+ }
66
+ }
67
+ // prettier-ignore
68
+ drainEvents() { const events = Object.freeze(this.#events); this.#events = []; return events; }
69
+ // prettier-ignore
70
+ snapshot() { return Object.freeze({ active: this.#active, held: this.#gates.size, abortListeners: this.#abortListeners, timers: 0, staleSettlements: this.#staleSettlements }); }
71
+ // prettier-ignore
72
+ async #awaitGate(identity, gate, context) { let rejectAbort; const aborted = new Promise((_, reject) => (rejectAbort = reject)); const abort = () => { this.#gates.delete(this.#key(identity)); try {
73
+ context.checkpoint();
74
+ }
75
+ catch (error) {
76
+ rejectAbort(error);
77
+ } }; context.signal.addEventListener("abort", abort, { once: true }); this.#abortListeners += 1; try {
78
+ if (context.signal.aborted)
79
+ abort();
80
+ await Promise.race([gate.promise, aborted]);
81
+ }
82
+ finally {
83
+ context.signal.removeEventListener("abort", abort);
84
+ this.#abortListeners -= 1;
85
+ } }
86
+ // prettier-ignore
87
+ #emit(event) { const normalized = Object.freeze(Object.fromEntries(Object.entries(event).filter(([, value]) => value !== undefined))); this.#events.push(normalized); appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(normalized)}\n`, { encoding: "utf8", flag: "a" }); }
88
+ // prettier-ignore
89
+ accepts(identity) { return identity.generation === this.#descriptor.generation && identity.ownerToken === this.#descriptor.ownerToken; }
90
+ // prettier-ignore
91
+ #assertCurrent(identity) { this.#assertIdentity(identity); if (!this.accepts(identity))
92
+ throw new H03TimeoutFixtureError(); }
93
+ // prettier-ignore
94
+ #assertIdentity(identity) { if (!Object.isFrozen(identity) || !FIXTURE_ID.test(identity.callId) || !FIXTURE_ID.test(identity.fixtureId) || !UUID.test(identity.correlationId))
95
+ throw new Error("Invalid H-05 lifecycle identity."); }
96
+ // prettier-ignore
97
+ #key(identity) { return `${identity.generation}:${identity.fixtureId}`; }
98
+ }
99
+ export class H03TimeoutFixtureController {
100
+ #descriptor;
101
+ #gates = new Map();
102
+ #events = [];
103
+ #generation;
104
+ #generationAbort = new AbortController();
105
+ #active = 0;
106
+ #abortListeners = 0;
107
+ #staleSettlements = 0;
108
+ constructor(descriptor) {
109
+ const directory = statSync(descriptor.controlDirectory, { throwIfNoEntry: false });
110
+ if (!directory?.isDirectory())
111
+ throw new Error("H-03 fixture control path must be an existing directory.");
112
+ this.#descriptor = descriptor;
113
+ this.#generation = descriptor.generation;
114
+ }
115
+ hold(fixtureId) {
116
+ this.#assertId(fixtureId);
117
+ const key = this.#key(fixtureId, this.#generation);
118
+ if (this.#gates.has(key))
119
+ throw new Error("Invalid or duplicate H-03 fixture id.");
120
+ let release;
121
+ const promise = new Promise((resolve) => (release = resolve));
122
+ this.#gates.set(key, { promise, release });
123
+ }
124
+ release(fixtureId, generation) {
125
+ if (!FIXTURE_ID.test(fixtureId) || generation !== this.#generation)
126
+ return false;
127
+ const key = this.#key(fixtureId, generation);
128
+ const gate = this.#gates.get(key);
129
+ if (!gate)
130
+ return false;
131
+ this.#gates.delete(key);
132
+ gate.release();
133
+ return true;
134
+ }
135
+ advanceGeneration(generation) {
136
+ if (!Number.isSafeInteger(generation) || generation <= this.#generation)
137
+ throw new Error("H-03 fixture generation must increase.");
138
+ this.#generation = generation;
139
+ this.#generationAbort.abort();
140
+ this.#generationAbort = new AbortController();
141
+ for (const gate of this.#gates.values())
142
+ gate.release();
143
+ this.#gates.clear();
144
+ }
145
+ async run(fixtureId, generation, context, operation, callId = fixtureId) {
146
+ this.#assertId(fixtureId);
147
+ this.#assertId(callId);
148
+ if (generation !== this.#generation)
149
+ return this.#stale(fixtureId, generation, callId);
150
+ const generationSignal = this.#generationAbort.signal;
151
+ const signal = AbortSignal.any([context.signal, generationSignal]);
152
+ const hooked = this.#context(context, signal, generationSignal, fixtureId, generation, callId);
153
+ this.#active += 1;
154
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "started" });
155
+ try {
156
+ const gate = this.#gates.get(this.#key(fixtureId, generation));
157
+ if (gate)
158
+ await this.#awaitGate(this.#key(fixtureId, generation), gate, hooked);
159
+ hooked.checkpoint();
160
+ const result = await operation(hooked);
161
+ hooked.checkpoint();
162
+ return result;
163
+ }
164
+ finally {
165
+ this.#active -= 1;
166
+ this.#emit({
167
+ callId,
168
+ fixtureId,
169
+ generation,
170
+ nonce: this.#descriptor.nonce,
171
+ phase: "terminal",
172
+ });
173
+ }
174
+ }
175
+ drainEvents() {
176
+ const events = Object.freeze(this.#events);
177
+ this.#events = [];
178
+ return events;
179
+ }
180
+ snapshot() {
181
+ return Object.freeze({
182
+ active: this.#active,
183
+ held: this.#gates.size,
184
+ abortListeners: this.#abortListeners,
185
+ staleSettlements: this.#staleSettlements,
186
+ });
187
+ }
188
+ #context(context, signal, generationSignal, fixtureId, generation, callId) {
189
+ const checkpoint = () => {
190
+ if (generationSignal.aborted || generation !== this.#generation)
191
+ this.#stale(fixtureId, generation, callId);
192
+ context.checkpoint();
193
+ };
194
+ return {
195
+ get sequence() {
196
+ return context.sequence;
197
+ },
198
+ get phase() {
199
+ return context.phase;
200
+ },
201
+ signal,
202
+ checkpoint,
203
+ markExecuting: () => context.markExecuting(),
204
+ enterCompletionCritical: () => {
205
+ checkpoint();
206
+ context.enterCompletionCritical();
207
+ },
208
+ };
209
+ }
210
+ async #awaitGate(key, gate, context) {
211
+ let rejectAbort;
212
+ const aborted = new Promise((_, reject) => (rejectAbort = reject));
213
+ const abort = () => {
214
+ if (this.#gates.get(key) === gate)
215
+ this.#gates.delete(key);
216
+ try {
217
+ context.checkpoint();
218
+ }
219
+ catch (error) {
220
+ rejectAbort(error);
221
+ }
222
+ };
223
+ context.signal.addEventListener("abort", abort, { once: true });
224
+ this.#abortListeners += 1;
225
+ try {
226
+ if (context.signal.aborted)
227
+ abort();
228
+ await Promise.race([gate.promise, aborted]);
229
+ }
230
+ finally {
231
+ context.signal.removeEventListener("abort", abort);
232
+ this.#abortListeners -= 1;
233
+ }
234
+ }
235
+ #stale(fixtureId, generation, callId) {
236
+ this.#staleSettlements += 1;
237
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "stale" });
238
+ throw new H03TimeoutFixtureError();
239
+ }
240
+ #emit(event) {
241
+ this.#events.push(Object.freeze(event));
242
+ appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
243
+ }
244
+ #assertId(fixtureId) {
245
+ if (!FIXTURE_ID.test(fixtureId))
246
+ throw new Error("Invalid H-03 fixture id.");
247
+ }
248
+ #key(fixtureId, generation) {
249
+ return `${generation}:${fixtureId}`;
250
+ }
251
+ }
252
+ let configuredH05Controller;
253
+ let configuredH05Descriptor;
254
+ // Capture the closed command once before scheduler enqueue; mutable files cannot relabel it.
255
+ // prettier-ignore
256
+ export function captureConfiguredH05Command() { const raw = process.env.AST_H05_FIXTURE; if (raw === undefined)
257
+ return undefined; const descriptor = parseH05FixtureDescriptor(raw); if (!descriptor)
258
+ throw new Error("Invalid H-05 fixture descriptor."); if (configuredH05Descriptor) {
259
+ const sameGeneration = descriptor.generation === configuredH05Descriptor.generation, sameIdentity = descriptor.controlDirectory === configuredH05Descriptor.controlDirectory && descriptor.nonce === configuredH05Descriptor.nonce && descriptor.ownerToken === configuredH05Descriptor.ownerToken;
260
+ if (sameGeneration && !sameIdentity)
261
+ throw new Error("H-05 fixture descriptor drift.");
262
+ if (descriptor.generation < configuredH05Descriptor.generation)
263
+ throw new Error("H-05 fixture generation regressed.");
264
+ if (descriptor.generation > configuredH05Descriptor.generation) {
265
+ configuredH05Controller?.advance({ ...configuredH05Descriptor, generation: descriptor.generation });
266
+ configuredH05Controller = undefined;
267
+ configuredH05Descriptor = descriptor;
268
+ const replacement = new H05LifecycleFixtureController(descriptor);
269
+ configuredH05Controller = replacement;
270
+ }
271
+ }
272
+ else {
273
+ configuredH05Controller = new H05LifecycleFixtureController(descriptor);
274
+ configuredH05Descriptor = descriptor;
275
+ } if (!configuredH05Controller)
276
+ throw new Error("H-05 fixture generation is retired."); const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,correlationId,fixtureId,mode,nonce,ownerToken" || value.nonce !== descriptor.nonce || value.ownerToken !== descriptor.ownerToken || !["hold", "pass", "readback"].includes(value.mode))
277
+ throw new Error("Invalid H-05 fixture command."); const command = Object.freeze({ ...value }); const captured = configuredH05Controller.capture({ callId: command.callId, correlationId: command.correlationId, fixtureId: command.fixtureId }); return Object.freeze({ controller: configuredH05Controller, descriptor, command, captured }); }
278
+ // prettier-ignore
279
+ export async function runConfiguredH05Fixture(context, operation, configured) { const { controller, descriptor, command, captured } = configured; if (!controller.accepts(captured))
280
+ throw new H03TimeoutFixtureError(); if (command.mode === "readback") {
281
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
282
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: captured.callId, correlationId: captured.correlationId, eventsDrained, fixtureId: captured.fixtureId, generation: captured.generation, phase: "readback" })}\n`);
283
+ const result = await operation(context);
284
+ if (!controller.accepts(captured))
285
+ throw new H03TimeoutFixtureError();
286
+ context.checkpoint();
287
+ return result;
288
+ } if (command.mode === "hold")
289
+ controller.hold(captured); return controller.run(captured, context, operation); }
290
+ let configuredController;
291
+ let configuredDescriptor;
292
+ const errorCommands = new WeakMap();
293
+ // Compact closed test-only wiring preserves PR3's hard review budget.
294
+ // prettier-ignore
295
+ function configuredFixture() { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
296
+ return undefined; if (!configuredController || configuredDescriptor?.generation !== descriptor.generation) {
297
+ configuredDescriptor = descriptor;
298
+ configuredController = new H03TimeoutFixtureController(descriptor);
299
+ } return { controller: configuredController, descriptor }; }
300
+ // prettier-ignore
301
+ function readCommand(descriptor) { const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,fixtureId,mode,nonce" || typeof value.callId !== "string" || !FIXTURE_ID.test(value.callId) || typeof value.fixtureId !== "string" || !FIXTURE_ID.test(value.fixtureId) || value.nonce !== descriptor.nonce || !["hold", "pass", "readback"].includes(value.mode))
302
+ throw new Error("Invalid H-03 fixture command."); return value; }
303
+ // Capture once before scheduler enqueue; later command.json writes cannot relabel this request.
304
+ // prettier-ignore
305
+ export function captureConfiguredH03Command() { const fixture = configuredFixture(); return fixture && Object.freeze({ ...fixture, command: readCommand(fixture.descriptor) }); }
306
+ // prettier-ignore
307
+ export function bindConfiguredH03Error(error, captured) { if (captured && typeof error === "object" && error !== null)
308
+ errorCommands.set(error, captured); }
309
+ export function configuredH05CorrelationId(error) {
310
+ const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined;
311
+ return configured && "captured" in configured ? configured.captured.correlationId : undefined;
312
+ }
313
+ // prettier-ignore
314
+ export async function runConfiguredH03Fixture(context, operation, captured) { const { controller, descriptor, command } = captured; if (command.mode === "readback") {
315
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
316
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: command.callId, eventsDrained, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "readback" })}\n`);
317
+ return operation(context);
318
+ } if (command.mode === "hold")
319
+ controller.hold(command.fixtureId); return controller.run(command.fixtureId, descriptor.generation, context, operation, command.callId); }
320
+ // prettier-ignore
321
+ export function emitConfiguredH03HostEvent(phase, generation) { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
322
+ return; appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ fixtureId: "host", generation, nonce: descriptor.nonce, phase })}\n`, { encoding: "utf8", flag: "a" }); }
323
+ // prettier-ignore
324
+ export function emitConfiguredH03ErrorEvidence(text, error) { const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined; if (!configured)
325
+ return; const { descriptor, command } = configured; if (!("captured" in configured)) {
326
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ callId: command.callId, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "error", result: { isError: true, error: { info: { name: error instanceof Error ? error.name : "Error" }, message: text } } })}\n`);
327
+ return;
328
+ } const { controller, captured } = configured; if (!controller.accepts(captured))
329
+ return; let publicError; try {
330
+ publicError = JSON.parse(text);
331
+ }
332
+ catch {
333
+ publicError = undefined;
334
+ } const source = publicError?.error, code = typeof source?.code === "string" && PUBLIC_ERROR_CODES.some((value) => value === source.code) ? source.code : "INTERNAL_ERROR", correlation_id = typeof source?.correlation_id === "string" && UUID.test(source.correlation_id) ? source.correlation_id : captured.correlationId, name = error instanceof Error && /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(error.name) ? error.name : "Error", message = typeof source?.message === "string" ? sanitizePublicText(source.message) : "An internal error occurred."; const identity = { callId: captured.callId, correlationId: captured.correlationId, fixtureId: captured.fixtureId, generation: captured.generation, ownerToken: captured.ownerToken, phase: "error", resources: controller.snapshot(), result: { isError: true, error: { code, message, correlation_id, info: { name } } } }; let serialized = JSON.stringify(identity); if (Buffer.byteLength(serialized) > 4096)
335
+ serialized = JSON.stringify({ ...identity, result: { ...identity.result, error: { ...identity.result.error, message: "Evidence message omitted: bounded record exceeded." } } }); appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${serialized}\n`); }
@@ -3,6 +3,49 @@ import { link, mkdir, mkdtemp, open, readFile, rename, rm, stat, writeFile, } fr
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { applyEdits, modify, parse } from "jsonc-parser";
6
+ export class OpenCodeConfigRecoveryError extends Error {
7
+ constructor(message, options) {
8
+ super(message, options);
9
+ this.name = "OpenCodeConfigRecoveryError";
10
+ }
11
+ }
12
+ function desiredAst(nodeExecutable, serverEntryPath) {
13
+ return {
14
+ type: "local",
15
+ command: [nodeExecutable, serverEntryPath],
16
+ enabled: true,
17
+ environment: { AST_MCP_APPLY_GUARD: "allow" },
18
+ };
19
+ }
20
+ export function classifyOpenCodeAstRegistration(value, nodeExecutable, serverEntryPath) {
21
+ if (value === undefined)
22
+ return "missing";
23
+ if (value === null || typeof value !== "object" || Array.isArray(value))
24
+ return "conflict";
25
+ const registration = value;
26
+ const command = registration.command;
27
+ const baseMatches = registration.type === "local" &&
28
+ registration.enabled === true &&
29
+ Array.isArray(command) &&
30
+ command.length === 2 &&
31
+ command[0] === nodeExecutable &&
32
+ command[1] === serverEntryPath;
33
+ if (!baseMatches)
34
+ return "conflict";
35
+ const keys = Object.keys(registration).sort().join(",");
36
+ if (keys === "command,enabled,type")
37
+ return "repairable";
38
+ if (keys !== "command,enabled,environment,type")
39
+ return "conflict";
40
+ const environment = registration.environment;
41
+ return environment !== null &&
42
+ typeof environment === "object" &&
43
+ !Array.isArray(environment) &&
44
+ Object.keys(environment).length === 1 &&
45
+ environment.AST_MCP_APPLY_GUARD === "allow"
46
+ ? "current"
47
+ : "conflict";
48
+ }
6
49
  function hash(value) {
7
50
  return createHash("sha256").update(value).digest("hex");
8
51
  }
@@ -80,14 +123,11 @@ export async function planOpenCodeConfig(options) {
80
123
  }
81
124
  if (!parsed || typeof parsed !== "object")
82
125
  throw new Error("OpenCode configuration must contain an object.");
83
- const desired = {
84
- type: "local",
85
- command: [options.nodeExecutable, options.serverEntryPath],
86
- enabled: true,
87
- };
88
- if (parsed.mcp?.ast !== undefined && JSON.stringify(parsed.mcp.ast) !== JSON.stringify(desired))
126
+ const desired = desiredAst(options.nodeExecutable, options.serverEntryPath);
127
+ const registrationStatus = classifyOpenCodeAstRegistration(parsed.mcp?.ast, options.nodeExecutable, options.serverEntryPath);
128
+ if (registrationStatus === "conflict")
89
129
  throw new Error("OpenCode configuration conflict at mcp.ast.");
90
- const content = parsed.mcp?.ast === undefined
130
+ const content = registrationStatus === "missing" || registrationStatus === "repairable"
91
131
  ? applyEdits(before, modify(before, ["mcp", "ast"], desired, {
92
132
  formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
93
133
  }))
@@ -96,6 +136,7 @@ export async function planOpenCodeConfig(options) {
96
136
  filePath: options.filePath,
97
137
  beforeHash: hash(beforeExists ? before : ""),
98
138
  beforeExists,
139
+ beforeContent: beforeExists ? before : "",
99
140
  mode,
100
141
  content,
101
142
  status: content === before ? "unchanged" : "installed",
@@ -146,3 +187,39 @@ export async function applyOpenCodeConfigPlan(plan) {
146
187
  await rm(temporary, { force: true });
147
188
  }
148
189
  }
190
+ async function inspectOpenCodeConfigPlanState(plan) {
191
+ let content = "";
192
+ let exists = true;
193
+ let mode;
194
+ try {
195
+ content = await readFile(plan.filePath, "utf8");
196
+ mode = (await stat(plan.filePath)).mode & 0o777;
197
+ }
198
+ catch (error) {
199
+ if (error.code === "ENOENT")
200
+ exists = false;
201
+ else
202
+ throw error;
203
+ }
204
+ if (!plan.beforeExists && !exists)
205
+ return "preimage";
206
+ if (plan.beforeExists && exists && content === plan.beforeContent && mode === plan.mode)
207
+ return "preimage";
208
+ if (exists && content === plan.content && mode === plan.mode)
209
+ return "postimage";
210
+ return "conflict";
211
+ }
212
+ export async function runOpenCodeConfigTransaction(plan, operation) {
213
+ try {
214
+ return await operation();
215
+ }
216
+ catch (error) {
217
+ const state = await inspectOpenCodeConfigPlanState(plan);
218
+ if (state === "postimage")
219
+ return undefined;
220
+ if (state === "conflict") {
221
+ throw new OpenCodeConfigRecoveryError("OpenCode configuration changed concurrently after an ambiguous write outcome; the concurrent bytes were preserved.", { cause: error });
222
+ }
223
+ throw error;
224
+ }
225
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Project } from "ts-morph";
4
+ // prettier-ignore
5
+ import { bindConfiguredH03Error, captureConfiguredH03Command, captureConfiguredH05Command, runConfiguredH03Fixture, runConfiguredH05Fixture } from "./h03-timeout-fixture.js";
4
6
  import { InMemorySymbolIndex, SYMBOL_INDEX_SCHEMA_VERSION, } from "./symbol-index.js";
5
7
  import { addSymbolIndexRuntimeCount, createInitialSymbolIndexRuntimeObservability, readSymbolIndexPersistencePolicy, symbolIndexCachePath, symbolIndexPolicyKey, } from "./symbol-index-policy.js";
6
8
  import { openSQLiteSymbolIndexStore, quarantineSQLiteSymbolIndexFile, } from "./symbol-index-sqlite.js";
@@ -650,22 +652,33 @@ async function synchronizeSession(session, requestContext) {
650
652
  throw error;
651
653
  }
652
654
  }
653
- async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure, requestContext) {
655
+ async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure, requestContext, h03Fixture = false) {
654
656
  requestContext.checkpoint();
655
- return session.scheduler.run(async (operationContext) => {
656
- operationContext.checkpoint();
657
- try {
658
- await synchronizeSession(session, operationContext);
659
- }
660
- catch (error) {
661
- if (!allowSynchronizationFailure || isCooperativeInterruption(error))
662
- throw error;
663
- }
664
- operationContext.checkpoint();
665
- operationContext.markExecuting();
666
- session.lastAccessSequence = nextProjectSessionAccessSequence();
667
- return operation(session.context, operationContext);
668
- }, { signal: requestContext.signal });
657
+ const h05Command = h03Fixture ? captureConfiguredH05Command() : undefined;
658
+ // prettier-ignore
659
+ const h03Command = h05Command ? undefined : h03Fixture ? captureConfiguredH03Command() : undefined;
660
+ // prettier-ignore
661
+ try {
662
+ return await session.scheduler.run(async (operationContext) => {
663
+ operationContext.checkpoint();
664
+ try {
665
+ await synchronizeSession(session, operationContext);
666
+ }
667
+ catch (error) {
668
+ if (!allowSynchronizationFailure || isCooperativeInterruption(error))
669
+ throw error;
670
+ }
671
+ operationContext.checkpoint();
672
+ operationContext.markExecuting();
673
+ session.lastAccessSequence = nextProjectSessionAccessSequence();
674
+ const execute = (context) => operation(session.context, context);
675
+ return h05Command ? runConfiguredH05Fixture(operationContext, execute, h05Command) : h03Command ? runConfiguredH03Fixture(operationContext, execute, h03Command) : execute(operationContext);
676
+ }, { signal: requestContext.signal });
677
+ }
678
+ catch (error) {
679
+ bindConfiguredH03Error(error, h05Command ?? h03Command);
680
+ throw error;
681
+ }
669
682
  }
670
683
  export async function withProject(projectRoot, operation, requestContext = NO_REQUEST_CONTEXT) {
671
684
  requestContext.checkpoint();
@@ -707,7 +720,7 @@ function readProjectStatus(session, requestContext) {
707
720
  index_observability: { ...effectiveContext.symbolIndexObservability },
708
721
  operation_queue: getQueueStatus(session),
709
722
  };
710
- }, true, requestContext);
723
+ }, true, requestContext, true);
711
724
  }
712
725
  function peekSessionStatus(session) {
713
726
  const context = session.context;
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { types as utilTypes } from "node:util";
4
+ import { configuredH05CorrelationId, emitConfiguredH03ErrorEvidence, } from "./h03-timeout-fixture.js";
4
5
  export const PUBLIC_ERROR_CODES = Object.freeze([
5
6
  "INVALID_INPUT",
6
7
  "PROJECT_NOT_FOUND",
@@ -240,12 +241,13 @@ function serializePublicError(code, message, correlationId) {
240
241
  };
241
242
  return { envelope, text: JSON.stringify(envelope) };
242
243
  }
243
- export function renderPublicError(error, correlationId = randomUUID()) {
244
+ export function renderPublicError(error, correlationId = configuredH05CorrelationId(error) ?? randomUUID()) {
244
245
  const classified = classifyPublicError(error);
246
+ const emitted = (value) => (emitConfiguredH03ErrorEvidence(value.text, error),
247
+ value);
245
248
  const initial = serializePublicError(classified.code, classified.message, correlationId);
246
- if (Buffer.byteLength(initial.text, "utf8") <= MAX_PUBLIC_ERROR_RESPONSE_BYTES) {
247
- return initial;
248
- }
249
+ if (Buffer.byteLength(initial.text, "utf8") <= MAX_PUBLIC_ERROR_RESPONSE_BYTES)
250
+ return emitted(initial);
249
251
  let lowerBudget = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
250
252
  let upperBudget = Buffer.byteLength(classified.message, "utf8") - 1;
251
253
  let best = serializePublicError(classified.code, TRUNCATION_SUFFIX, correlationId);
@@ -260,5 +262,5 @@ export function renderPublicError(error, correlationId = randomUUID()) {
260
262
  upperBudget = candidateBudget - 1;
261
263
  }
262
264
  }
263
- return best;
265
+ return emitted(best);
264
266
  }