@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.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,333 @@
1
+ // Corpus service: keeps the project index current.
2
+ //
3
+ // Primary mode is `autoindex --watch --no-graph`, a resident process that gets
4
+ // one filesystem watch per indexed directory. It emits a run document per pass,
5
+ // which is where freshness and counts come from. If it keeps dying, the service
6
+ // falls back to periodic one-shot runs so the index still converges.
7
+
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import type { Config } from "../config.ts";
10
+ import type { KeyValueStorage, Logger } from "../log.ts";
11
+ import {
12
+ autoindexArgs,
13
+ corpusLayout,
14
+ ensureIgnoreFile,
15
+ exitMeaning,
16
+ parseRunDocument,
17
+ type CorpusRun,
18
+ type CorpusLayout,
19
+ } from "./corpus.ts";
20
+
21
+ export type CorpusState = "disabled" | "starting" | "watching" | "idle" | "error";
22
+
23
+ export interface CorpusSnapshot {
24
+ readonly state: CorpusState;
25
+ readonly lastRun: CorpusRun | null;
26
+ readonly restarts: number;
27
+ readonly runs: number;
28
+ readonly failures: number;
29
+ readonly lastError: string | null;
30
+ }
31
+
32
+ export interface CorpusServiceOptions {
33
+ readonly binaryPath: string;
34
+ readonly nodeUrl: string;
35
+ readonly projectRoot: string;
36
+ readonly prefix: string;
37
+ readonly dataRoot: string;
38
+ readonly config: Config;
39
+ readonly storage: KeyValueStorage;
40
+ readonly logger: Logger;
41
+ readonly onChange?: () => void;
42
+ readonly onRun?: (run: CorpusRun) => void;
43
+ }
44
+
45
+ const STORAGE_KEY = "index/lastRun";
46
+ const BACKOFF_MS = [1_000, 2_000, 5_000, 15_000, 30_000] as const;
47
+ const MAX_RESTARTS = 5;
48
+ const STOP_GRACE_MS = 3_000;
49
+ const STDERR_TAIL_LINES = 20;
50
+
51
+ export class CorpusService {
52
+ readonly layout: CorpusLayout;
53
+
54
+ private readonly storageKey: string;
55
+ private state: CorpusState;
56
+ private lastRun: CorpusRun | null = null;
57
+ private restarts = 0;
58
+ private runs = 0;
59
+ private failures = 0;
60
+ private lastError: string | null = null;
61
+ private child: ChildProcess | null = null;
62
+ private timer: ReturnType<typeof setInterval> | null = null;
63
+ private stopped = false;
64
+ private spawning = false;
65
+
66
+ constructor(private readonly options: CorpusServiceOptions) {
67
+ this.layout = corpusLayout(options.projectRoot, options.prefix, options.dataRoot);
68
+ this.storageKey = `${STORAGE_KEY}:${options.prefix}`;
69
+ this.state = options.config.index.auto ? "starting" : "disabled";
70
+ }
71
+
72
+ async start(): Promise<void> {
73
+ if (this.state === "disabled") return;
74
+
75
+ await this.restore();
76
+ await ensureIgnoreFile(this.layout).catch(() => {});
77
+
78
+ if (this.options.config.index.watch) {
79
+ this.spawn(true);
80
+ } else {
81
+ await this.runOnce();
82
+ this.startPeriodic();
83
+ }
84
+ }
85
+
86
+ async stop(): Promise<void> {
87
+ this.stopped = true;
88
+ this.clearTimer();
89
+ const child = this.child;
90
+ this.child = null;
91
+ if (!child) return;
92
+
93
+ child.kill("SIGTERM");
94
+ await new Promise<void>((resolvePromise) => {
95
+ const timer = setTimeout(() => {
96
+ child.kill("SIGKILL");
97
+ resolvePromise();
98
+ }, STOP_GRACE_MS);
99
+ child.once("close", () => {
100
+ clearTimeout(timer);
101
+ resolvePromise();
102
+ });
103
+ });
104
+
105
+ this.state = "idle";
106
+ }
107
+
108
+ /** Forces a pass by restarting the watcher, or running once in periodic mode. */
109
+ async reindex(): Promise<void> {
110
+ if (this.state === "disabled" || this.stopped) return;
111
+
112
+ this.restarts = 0;
113
+ this.lastError = null;
114
+
115
+ if (!this.options.config.index.watch) {
116
+ await this.runOnce();
117
+ return;
118
+ }
119
+
120
+ if (this.child) {
121
+ await this.stopChild();
122
+ }
123
+ this.spawn(true);
124
+ }
125
+
126
+ snapshot(): CorpusSnapshot {
127
+ return {
128
+ state: this.state,
129
+ lastRun: this.lastRun,
130
+ restarts: this.restarts,
131
+ runs: this.runs,
132
+ failures: this.failures,
133
+ lastError: this.lastError,
134
+ };
135
+ }
136
+
137
+ private spawn(watch: boolean): ChildProcess | null {
138
+ if (this.stopped || this.spawning) return null;
139
+ this.spawning = true;
140
+
141
+ const args = autoindexArgs({
142
+ layout: this.layout,
143
+ nodeUrl: this.options.nodeUrl,
144
+ config: this.options.config,
145
+ watch,
146
+ });
147
+
148
+ let child: ChildProcess;
149
+ try {
150
+ child = spawn(this.options.binaryPath, [...args], {
151
+ cwd: this.options.projectRoot,
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ env: { ...process.env, XERJ_DISABLE_FEEDBACK: "true" },
154
+ });
155
+ } catch (error) {
156
+ this.spawning = false;
157
+ this.fail(error);
158
+ return null;
159
+ }
160
+
161
+ this.child = child;
162
+ this.spawning = false;
163
+ if (watch) this.state = "starting";
164
+
165
+ this.attachOutput(child);
166
+ child.once("close", (code, signal) => {
167
+ this.child = null;
168
+ if (this.stopped) return;
169
+
170
+ if (watch) {
171
+ this.handleWatchExit(code, signal);
172
+ } else {
173
+ this.handleOnceExit(code ?? null);
174
+ }
175
+ });
176
+
177
+ return child;
178
+ }
179
+
180
+ private attachOutput(child: ChildProcess): void {
181
+ let buffer = "";
182
+ child.stdout?.on("data", (chunk: Buffer) => {
183
+ buffer += chunk.toString();
184
+ const lines = buffer.split("\n");
185
+ buffer = lines.pop() ?? "";
186
+
187
+ for (const line of lines) {
188
+ const run = parseRunDocument(line);
189
+ if (!run) continue;
190
+ this.lastRun = run;
191
+ this.runs += 1;
192
+ this.restarts = 0;
193
+ this.options.onRun?.(run);
194
+ this.state = "watching";
195
+ void this.persist(run);
196
+ this.options.onChange?.();
197
+ }
198
+ });
199
+
200
+ const stderr: string[] = [];
201
+ child.stderr?.on("data", (chunk: Buffer) => {
202
+ stderr.push(chunk.toString());
203
+ if (stderr.length > STDERR_TAIL_LINES) stderr.shift();
204
+ });
205
+
206
+ child.once("close", () => {
207
+ if (stderr.length > 0) {
208
+ this.options.logger.debug("autoindex stderr", stderr.join("").slice(-2000));
209
+ }
210
+ });
211
+ }
212
+
213
+ private handleWatchExit(code: number | null, signal: NodeJS.Signals | null): void {
214
+ const meaning = exitMeaning(code);
215
+
216
+ // A clean exit from `--watch` is unexpected; it should stay resident.
217
+ if (meaning === "decision") {
218
+ this.state = "idle";
219
+ this.lastError = "autoindex needs a decision: the estimate exceeds the configured limits";
220
+ this.options.logger.warn(this.lastError);
221
+ this.options.onChange?.();
222
+ return;
223
+ }
224
+
225
+ if (signal) {
226
+ this.state = "idle";
227
+ return;
228
+ }
229
+
230
+ this.failures += 1;
231
+ this.lastError = `autoindex watcher exited (${code ?? "signal"})`;
232
+ this.options.logger.warn(this.lastError);
233
+
234
+ if (this.restarts >= MAX_RESTARTS) {
235
+ this.options.logger.warn("falling back to periodic indexing");
236
+ this.startPeriodic();
237
+ void this.runOnce();
238
+ return;
239
+ }
240
+
241
+ const delay = BACKOFF_MS[Math.min(this.restarts, BACKOFF_MS.length - 1)] ?? 30_000;
242
+ this.restarts += 1;
243
+ this.state = "starting";
244
+ this.options.onChange?.();
245
+ setTimeout(() => this.spawn(true), delay);
246
+ }
247
+
248
+ private handleOnceExit(code: number | null): void {
249
+ const meaning = exitMeaning(code);
250
+ if (meaning === "ok" || meaning === "junk") {
251
+ this.state = "idle";
252
+ return;
253
+ }
254
+
255
+ this.failures += 1;
256
+ this.state = "error";
257
+ this.lastError =
258
+ meaning === "decision"
259
+ ? "autoindex needs a decision: the estimate exceeds the configured limits"
260
+ : `autoindex exited ${code ?? "signal"}`;
261
+ this.options.logger.warn(this.lastError);
262
+ this.options.onChange?.();
263
+ }
264
+
265
+ private async runOnce(): Promise<void> {
266
+ if (this.stopped) return;
267
+ const child = this.spawn(false);
268
+ if (!child) return;
269
+
270
+ await new Promise<void>((resolvePromise) => child.once("close", () => resolvePromise()));
271
+ if (this.lastRun) this.state = "idle";
272
+ }
273
+
274
+ private startPeriodic(): void {
275
+ this.clearTimer();
276
+ const period = this.options.config.index.periodicMs;
277
+ this.timer = setInterval(() => void this.runOnce(), period);
278
+ }
279
+
280
+ private clearTimer(): void {
281
+ if (this.timer) {
282
+ clearInterval(this.timer);
283
+ this.timer = null;
284
+ }
285
+ }
286
+
287
+ private async stopChild(): Promise<void> {
288
+ const child = this.child;
289
+ this.child = null;
290
+ if (!child) return;
291
+
292
+ child.kill("SIGTERM");
293
+ await new Promise<void>((resolvePromise) => {
294
+ const timer = setTimeout(() => {
295
+ child.kill("SIGKILL");
296
+ resolvePromise();
297
+ }, STOP_GRACE_MS);
298
+ child.once("close", () => {
299
+ clearTimeout(timer);
300
+ resolvePromise();
301
+ });
302
+ });
303
+ }
304
+
305
+ private async restore(): Promise<void> {
306
+ const stored = await this.options.storage.get(this.storageKey).catch(() => undefined);
307
+ if (typeof stored !== "object" || stored === null) return;
308
+
309
+ const record = stored as Record<string, unknown>;
310
+ if (typeof record.at !== "number") return;
311
+
312
+ this.lastRun = {
313
+ at: record.at,
314
+ generation: typeof record.generation === "number" ? record.generation : null,
315
+ files: typeof record.files === "number" ? record.files : null,
316
+ records: typeof record.records === "number" ? record.records : null,
317
+ junk: typeof record.junk === "number" ? record.junk : null,
318
+ reason: typeof record.reason === "string" ? record.reason : null,
319
+ };
320
+ }
321
+
322
+ private async persist(run: CorpusRun): Promise<void> {
323
+ await this.options.storage.set(this.storageKey, run as never).catch(() => {});
324
+ }
325
+
326
+ private fail(error: unknown): void {
327
+ this.failures += 1;
328
+ this.state = "error";
329
+ this.lastError = error instanceof Error ? error.message : String(error);
330
+ this.options.logger.error("could not start autoindex", this.lastError);
331
+ this.options.onChange?.();
332
+ }
333
+ }