watchfix 0.1.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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/agents/base.d.ts +19 -0
  4. package/dist/agents/base.js +140 -0
  5. package/dist/agents/claude.d.ts +11 -0
  6. package/dist/agents/claude.js +6 -0
  7. package/dist/agents/codex.d.ts +11 -0
  8. package/dist/agents/codex.js +6 -0
  9. package/dist/agents/defaults.d.ts +21 -0
  10. package/dist/agents/defaults.js +21 -0
  11. package/dist/agents/gemini.d.ts +11 -0
  12. package/dist/agents/gemini.js +6 -0
  13. package/dist/agents/index.d.ts +19 -0
  14. package/dist/agents/index.js +63 -0
  15. package/dist/agents/types.d.ts +22 -0
  16. package/dist/agents/types.js +1 -0
  17. package/dist/cli/commands/clean.d.ts +9 -0
  18. package/dist/cli/commands/clean.js +173 -0
  19. package/dist/cli/commands/config.d.ts +7 -0
  20. package/dist/cli/commands/config.js +134 -0
  21. package/dist/cli/commands/fix.d.ts +12 -0
  22. package/dist/cli/commands/fix.js +391 -0
  23. package/dist/cli/commands/ignore.d.ts +7 -0
  24. package/dist/cli/commands/ignore.js +74 -0
  25. package/dist/cli/commands/init.d.ts +5 -0
  26. package/dist/cli/commands/init.js +115 -0
  27. package/dist/cli/commands/logs.d.ts +9 -0
  28. package/dist/cli/commands/logs.js +106 -0
  29. package/dist/cli/commands/show.d.ts +8 -0
  30. package/dist/cli/commands/show.js +165 -0
  31. package/dist/cli/commands/status.d.ts +7 -0
  32. package/dist/cli/commands/status.js +110 -0
  33. package/dist/cli/commands/stop.d.ts +7 -0
  34. package/dist/cli/commands/stop.js +106 -0
  35. package/dist/cli/commands/version.d.ts +7 -0
  36. package/dist/cli/commands/version.js +36 -0
  37. package/dist/cli/commands/watch.d.ts +10 -0
  38. package/dist/cli/commands/watch.js +204 -0
  39. package/dist/cli/index.d.ts +2 -0
  40. package/dist/cli/index.js +152 -0
  41. package/dist/config/loader.d.ts +4 -0
  42. package/dist/config/loader.js +96 -0
  43. package/dist/config/schema.d.ts +375 -0
  44. package/dist/config/schema.js +99 -0
  45. package/dist/db/index.d.ts +15 -0
  46. package/dist/db/index.js +71 -0
  47. package/dist/db/queries.d.ts +45 -0
  48. package/dist/db/queries.js +111 -0
  49. package/dist/db/schema.d.ts +4 -0
  50. package/dist/db/schema.js +84 -0
  51. package/dist/fixer/context.d.ts +9 -0
  52. package/dist/fixer/context.js +361 -0
  53. package/dist/fixer/index.d.ts +37 -0
  54. package/dist/fixer/index.js +398 -0
  55. package/dist/fixer/lock.d.ts +7 -0
  56. package/dist/fixer/lock.js +49 -0
  57. package/dist/fixer/output.d.ts +21 -0
  58. package/dist/fixer/output.js +108 -0
  59. package/dist/fixer/queue.d.ts +15 -0
  60. package/dist/fixer/queue.js +53 -0
  61. package/dist/fixer/verifier.d.ts +44 -0
  62. package/dist/fixer/verifier.js +133 -0
  63. package/dist/utils/daemon.d.ts +22 -0
  64. package/dist/utils/daemon.js +143 -0
  65. package/dist/utils/duration.d.ts +2 -0
  66. package/dist/utils/duration.js +31 -0
  67. package/dist/utils/errors.d.ts +17 -0
  68. package/dist/utils/errors.js +20 -0
  69. package/dist/utils/hash.d.ts +2 -0
  70. package/dist/utils/hash.js +18 -0
  71. package/dist/utils/http.d.ts +6 -0
  72. package/dist/utils/http.js +61 -0
  73. package/dist/utils/logger.d.ts +25 -0
  74. package/dist/utils/logger.js +85 -0
  75. package/dist/utils/process.d.ts +16 -0
  76. package/dist/utils/process.js +146 -0
  77. package/dist/watcher/index.d.ts +55 -0
  78. package/dist/watcher/index.js +234 -0
  79. package/dist/watcher/parser.d.ts +42 -0
  80. package/dist/watcher/parser.js +162 -0
  81. package/dist/watcher/patterns.d.ts +5 -0
  82. package/dist/watcher/patterns.js +92 -0
  83. package/dist/watcher/sources/command.d.ts +27 -0
  84. package/dist/watcher/sources/command.js +143 -0
  85. package/dist/watcher/sources/docker.d.ts +28 -0
  86. package/dist/watcher/sources/docker.js +183 -0
  87. package/dist/watcher/sources/file.d.ts +30 -0
  88. package/dist/watcher/sources/file.js +177 -0
  89. package/dist/watcher/sources/types.d.ts +27 -0
  90. package/dist/watcher/sources/types.js +1 -0
  91. package/package.json +38 -0
@@ -0,0 +1,398 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import yaml from 'yaml';
4
+ import { createAgent } from '../agents/index.js';
5
+ import { getError, logActivity } from '../db/queries.js';
6
+ import { UserError } from '../utils/errors.js';
7
+ import { parseDuration } from '../utils/duration.js';
8
+ import { Logger } from '../utils/logger.js';
9
+ import { generateAnalyzeContext, generateFixContext } from './context.js';
10
+ import { parseAnalysisOutput, parseFixOutput } from './output.js';
11
+ import { runVerification } from './verifier.js';
12
+ import { acquireLock, generateLockId, releaseLock, transitionStatus } from './lock.js';
13
+ const FIXABLE_STATUSES = new Set(['pending', 'suggested']);
14
+ const outputPathForContext = (contextPath) => {
15
+ if (contextPath.endsWith('-analyze.md')) {
16
+ return contextPath.replace(/-analyze\.md$/, '-analysis.yaml');
17
+ }
18
+ if (contextPath.endsWith('-fix.md')) {
19
+ return contextPath.replace(/-fix\.md$/, '-result.yaml');
20
+ }
21
+ return null;
22
+ };
23
+ const formatAgentDiagnostic = (result, expectedOutput) => {
24
+ const lines = [
25
+ `Agent did not produce valid output for ${expectedOutput}`,
26
+ typeof result.exitCode === 'number' ? `Exit code: ${result.exitCode}` : '',
27
+ result.timedOut ? 'Agent timed out' : '',
28
+ result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : '',
29
+ result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : '',
30
+ ];
31
+ return lines.filter(Boolean).join('\n');
32
+ };
33
+ const formatParseDiagnostic = (error, rawContent) => {
34
+ const message = error instanceof Error ? error.message : `Unknown error: ${String(error)}`;
35
+ return `Failed to parse output: ${message}\nRaw content:\n${rawContent}`;
36
+ };
37
+ const analysisToYaml = (analysis) => yaml.stringify(analysis).trimEnd();
38
+ const parseSuggestion = (value) => {
39
+ const parsed = JSON.parse(value);
40
+ if (!parsed ||
41
+ typeof parsed.summary !== 'string' ||
42
+ typeof parsed.root_cause !== 'string' ||
43
+ typeof parsed.suggested_fix !== 'string' ||
44
+ !Array.isArray(parsed.files_to_modify) ||
45
+ typeof parsed.confidence !== 'string') {
46
+ throw new UserError('Stored analysis output is invalid');
47
+ }
48
+ return parsed;
49
+ };
50
+ export class FixOrchestrator {
51
+ db;
52
+ config;
53
+ logger;
54
+ agent;
55
+ terminalEnabled;
56
+ constructor(db, config, options) {
57
+ this.db = db;
58
+ this.config = config;
59
+ this.terminalEnabled = options?.terminalEnabled ?? true;
60
+ this.logger =
61
+ options?.logger ??
62
+ new Logger({
63
+ rootDir: config.project.root,
64
+ terminalEnabled: this.terminalEnabled,
65
+ });
66
+ this.agent =
67
+ options?.agent ??
68
+ createAgent({
69
+ provider: config.agent.provider,
70
+ command: config.agent.command,
71
+ args: config.agent.args,
72
+ stderrIsProgress: config.agent.stderr_is_progress,
73
+ timeout: parseDuration(config.agent.timeout),
74
+ retries: config.agent.retries,
75
+ }, {
76
+ projectRoot: config.project.root,
77
+ logger: this.logger,
78
+ terminalEnabled: this.terminalEnabled,
79
+ });
80
+ }
81
+ async fixError(errorId, options) {
82
+ const initial = getError(this.db, errorId);
83
+ if (!initial) {
84
+ throw new UserError(`Error ${errorId} not found`);
85
+ }
86
+ const lockId = generateLockId();
87
+ const lockAcquired = await acquireLock(this.db, errorId, lockId);
88
+ if (!lockAcquired) {
89
+ return {
90
+ errorId,
91
+ status: initial.status,
92
+ lockAcquired: false,
93
+ attempts: initial.fixAttempts,
94
+ message: 'Lock already held',
95
+ };
96
+ }
97
+ this.logger.info(`Starting fix orchestrator for error ${errorId}`);
98
+ let lockHeld = true;
99
+ let attempts = initial.fixAttempts;
100
+ let analysisOutput;
101
+ let fixOutput;
102
+ let verificationResult;
103
+ try {
104
+ const error = getError(this.db, errorId);
105
+ if (!error) {
106
+ throw new UserError(`Error ${errorId} not found after locking`);
107
+ }
108
+ if (!FIXABLE_STATUSES.has(error.status)) {
109
+ await releaseLock(this.db, errorId, lockId);
110
+ lockHeld = false;
111
+ throw new UserError(`Error ${errorId} is not in a fixable state (status=${error.status})`);
112
+ }
113
+ const analyzeOnly = options?.analyzeOnly ?? false;
114
+ const reanalyze = options?.reanalyze ?? false;
115
+ const maxAttempts = this.config.limits.max_attempts_per_error;
116
+ const existingSuggestion = error.suggestion;
117
+ const shouldReanalyze = reanalyze && error.status === 'suggested' && existingSuggestion;
118
+ const suggestionMissing = error.status === 'suggested' && !existingSuggestion;
119
+ const needsAnalysis = error.status === 'pending' || reanalyze || suggestionMissing;
120
+ if (needsAnalysis) {
121
+ const expectedStatuses = [];
122
+ if (error.status === 'pending') {
123
+ expectedStatuses.push('pending');
124
+ }
125
+ if (error.status === 'suggested') {
126
+ expectedStatuses.push('suggested');
127
+ }
128
+ if (expectedStatuses.length === 0 ||
129
+ !transitionStatus(this.db, errorId, expectedStatuses, 'analyzing', lockId)) {
130
+ throw new UserError(`Failed to transition error ${errorId} into analyzing status`);
131
+ }
132
+ logActivity(this.db, 'analysis_start', errorId, JSON.stringify({ attempt: attempts, lockId }));
133
+ this.logger.info(`Analyzing error ${errorId} (attempt ${attempts})...`);
134
+ const context = generateAnalyzeContext(error, this.config, attempts);
135
+ const contextPath = path.resolve(this.config.project.root, context.path);
136
+ await fs.mkdir(path.dirname(contextPath), { recursive: true });
137
+ await fs.writeFile(contextPath, context.content, 'utf8');
138
+ const agentResult = await this.agent.analyze(context.path);
139
+ const outputPath = outputPathForContext(contextPath);
140
+ const analysisResult = await this.processAgentOutput(agentResult, outputPath, parseAnalysisOutput);
141
+ if (analysisResult.success) {
142
+ analysisOutput = analysisResult.data;
143
+ if (!transitionStatus(this.db, errorId, 'analyzing', 'suggested', lockId)) {
144
+ throw new UserError(`Failed to transition error ${errorId} into suggested status`);
145
+ }
146
+ this.db.run('UPDATE errors SET suggestion = ?, updated_at = ? WHERE id = ?', [
147
+ JSON.stringify(analysisOutput),
148
+ new Date().toISOString(),
149
+ errorId,
150
+ ]);
151
+ logActivity(this.db, 'analysis_complete', errorId, JSON.stringify({
152
+ attempt: attempts,
153
+ confidence: analysisOutput.confidence,
154
+ }));
155
+ if (analyzeOnly) {
156
+ await releaseLock(this.db, errorId, lockId);
157
+ lockHeld = false;
158
+ return {
159
+ errorId,
160
+ status: 'suggested',
161
+ lockAcquired: true,
162
+ attempts,
163
+ analysis: analysisOutput,
164
+ message: 'Analysis complete (analyze-only)',
165
+ };
166
+ }
167
+ }
168
+ else {
169
+ logActivity(this.db, agentResult.timedOut ? 'analysis_timeout' : 'analysis_failed', errorId, JSON.stringify({ attempt: attempts, diagnostic: analysisResult.diagnostic }));
170
+ if (shouldReanalyze) {
171
+ if (!transitionStatus(this.db, errorId, 'analyzing', 'suggested', lockId)) {
172
+ throw new UserError(`Failed to transition error ${errorId} back to suggested status`);
173
+ }
174
+ await releaseLock(this.db, errorId, lockId);
175
+ lockHeld = false;
176
+ return {
177
+ errorId,
178
+ status: 'suggested',
179
+ lockAcquired: true,
180
+ attempts,
181
+ message: 'Re-analysis failed; existing analysis preserved',
182
+ };
183
+ }
184
+ const newAttempts = attempts + 1;
185
+ attempts = newAttempts;
186
+ const nextStatus = newAttempts >= maxAttempts ? 'failed' : 'pending';
187
+ if (!transitionStatus(this.db, errorId, 'analyzing', nextStatus, lockId)) {
188
+ throw new UserError(`Failed to transition error ${errorId} after analysis failure`);
189
+ }
190
+ this.db.run('UPDATE errors SET suggestion = ?, fix_attempts = ?, updated_at = ? WHERE id = ?', [
191
+ JSON.stringify({ error: true, diagnostic: analysisResult.diagnostic }),
192
+ newAttempts,
193
+ new Date().toISOString(),
194
+ errorId,
195
+ ]);
196
+ await releaseLock(this.db, errorId, lockId);
197
+ lockHeld = false;
198
+ return {
199
+ errorId,
200
+ status: nextStatus,
201
+ lockAcquired: true,
202
+ attempts,
203
+ message: 'Analysis failed',
204
+ };
205
+ }
206
+ }
207
+ else if (analyzeOnly) {
208
+ if (!error.suggestion) {
209
+ throw new UserError(`Error ${errorId} has no stored analysis`);
210
+ }
211
+ analysisOutput = parseSuggestion(error.suggestion);
212
+ await releaseLock(this.db, errorId, lockId);
213
+ lockHeld = false;
214
+ return {
215
+ errorId,
216
+ status: 'suggested',
217
+ lockAcquired: true,
218
+ attempts,
219
+ analysis: analysisOutput,
220
+ message: 'Already analyzed (analyze-only)',
221
+ };
222
+ }
223
+ else {
224
+ if (!error.suggestion) {
225
+ throw new UserError(`Error ${errorId} has no stored analysis`);
226
+ }
227
+ analysisOutput = parseSuggestion(error.suggestion);
228
+ }
229
+ if (!analysisOutput) {
230
+ throw new UserError('Missing analysis output for fix phase');
231
+ }
232
+ if (!transitionStatus(this.db, errorId, 'suggested', 'fixing', lockId)) {
233
+ throw new UserError(`Failed to transition error ${errorId} into fixing status`);
234
+ }
235
+ logActivity(this.db, 'fix_start', errorId, JSON.stringify({ attempt: attempts, lockId }));
236
+ this.logger.info(`Applying fix for error ${errorId} (attempt ${attempts})...`);
237
+ const analysisYaml = analysisToYaml(analysisOutput);
238
+ const fixContext = generateFixContext(error, analysisYaml, this.config, attempts);
239
+ const fixContextPath = path.resolve(this.config.project.root, fixContext.path);
240
+ await fs.mkdir(path.dirname(fixContextPath), { recursive: true });
241
+ await fs.writeFile(fixContextPath, fixContext.content, 'utf8');
242
+ const fixAgentResult = await this.agent.fix(fixContext.path);
243
+ const fixOutputPath = outputPathForContext(fixContextPath);
244
+ const fixResult = await this.processAgentOutput(fixAgentResult, fixOutputPath, parseFixOutput);
245
+ if (fixResult.success) {
246
+ fixOutput = fixResult.data;
247
+ this.db.run('UPDATE errors SET fix_result = ?, updated_at = ? WHERE id = ?', [
248
+ JSON.stringify(fixOutput),
249
+ new Date().toISOString(),
250
+ errorId,
251
+ ]);
252
+ if (fixOutput.success) {
253
+ logActivity(this.db, 'fix_complete', errorId, JSON.stringify({ attempt: attempts }));
254
+ }
255
+ else {
256
+ logActivity(this.db, 'fix_failed', errorId, JSON.stringify({
257
+ attempt: attempts,
258
+ summary: fixOutput.summary,
259
+ notes: fixOutput.notes ?? '',
260
+ }));
261
+ }
262
+ if (!fixOutput.success) {
263
+ const newAttempts = attempts + 1;
264
+ attempts = newAttempts;
265
+ const nextStatus = newAttempts >= maxAttempts ? 'failed' : 'pending';
266
+ if (!transitionStatus(this.db, errorId, 'fixing', nextStatus, lockId)) {
267
+ throw new UserError(`Failed to transition error ${errorId} after fix failure`);
268
+ }
269
+ this.db.run('UPDATE errors SET fix_attempts = ?, updated_at = ? WHERE id = ?', [newAttempts, new Date().toISOString(), errorId]);
270
+ await releaseLock(this.db, errorId, lockId);
271
+ lockHeld = false;
272
+ return {
273
+ errorId,
274
+ status: nextStatus,
275
+ lockAcquired: true,
276
+ attempts,
277
+ analysis: analysisOutput,
278
+ fix: fixOutput,
279
+ message: 'Fix reported failure',
280
+ };
281
+ }
282
+ }
283
+ else {
284
+ logActivity(this.db, fixAgentResult.timedOut ? 'fix_timeout' : 'fix_failed', errorId, JSON.stringify({ attempt: attempts, diagnostic: fixResult.diagnostic }));
285
+ const newAttempts = attempts + 1;
286
+ attempts = newAttempts;
287
+ const nextStatus = newAttempts >= maxAttempts ? 'failed' : 'pending';
288
+ if (!transitionStatus(this.db, errorId, 'fixing', nextStatus, lockId)) {
289
+ throw new UserError(`Failed to transition error ${errorId} after fix failure`);
290
+ }
291
+ this.db.run('UPDATE errors SET fix_result = ?, fix_attempts = ?, updated_at = ? WHERE id = ?', [
292
+ JSON.stringify({ error: true, diagnostic: fixResult.diagnostic }),
293
+ newAttempts,
294
+ new Date().toISOString(),
295
+ errorId,
296
+ ]);
297
+ await releaseLock(this.db, errorId, lockId);
298
+ lockHeld = false;
299
+ return {
300
+ errorId,
301
+ status: nextStatus,
302
+ lockAcquired: true,
303
+ attempts,
304
+ analysis: analysisOutput,
305
+ message: 'Fix failed',
306
+ };
307
+ }
308
+ logActivity(this.db, 'verification_start', errorId, JSON.stringify({ attempt: attempts }));
309
+ this.logger.info(`Verifying fix for error ${errorId}...`);
310
+ verificationResult = await runVerification(this.config, {
311
+ logger: this.logger,
312
+ terminalEnabled: this.terminalEnabled,
313
+ });
314
+ if (verificationResult.success) {
315
+ if (!transitionStatus(this.db, errorId, 'fixing', 'fixed', lockId)) {
316
+ throw new UserError(`Failed to transition error ${errorId} into fixed status`);
317
+ }
318
+ logActivity(this.db, 'verification_pass', errorId, JSON.stringify({ attempt: attempts }));
319
+ await releaseLock(this.db, errorId, lockId);
320
+ lockHeld = false;
321
+ return {
322
+ errorId,
323
+ status: 'fixed',
324
+ lockAcquired: true,
325
+ attempts,
326
+ analysis: analysisOutput,
327
+ fix: fixOutput,
328
+ verification: verificationResult,
329
+ message: 'Fix verified',
330
+ };
331
+ }
332
+ const newAttempts = attempts + 1;
333
+ attempts = newAttempts;
334
+ const nextStatus = newAttempts >= maxAttempts ? 'failed' : 'pending';
335
+ if (!transitionStatus(this.db, errorId, 'fixing', nextStatus, lockId)) {
336
+ throw new UserError(`Failed to transition error ${errorId} after verification failure`);
337
+ }
338
+ this.db.run('UPDATE errors SET fix_attempts = ?, updated_at = ? WHERE id = ?', [newAttempts, new Date().toISOString(), errorId]);
339
+ logActivity(this.db, 'verification_fail', errorId, JSON.stringify({ attempt: attempts, failure: verificationResult.failure }));
340
+ await releaseLock(this.db, errorId, lockId);
341
+ lockHeld = false;
342
+ return {
343
+ errorId,
344
+ status: nextStatus,
345
+ lockAcquired: true,
346
+ attempts,
347
+ analysis: analysisOutput,
348
+ fix: fixOutput,
349
+ verification: verificationResult,
350
+ message: 'Verification failed',
351
+ };
352
+ }
353
+ finally {
354
+ if (lockHeld) {
355
+ await releaseLock(this.db, errorId, lockId);
356
+ }
357
+ }
358
+ }
359
+ async processAgentOutput(result, outputPath, parser) {
360
+ if (!result.success) {
361
+ const diagnostic = formatAgentDiagnostic(result, outputPath ?? 'output file');
362
+ return { success: false, diagnostic };
363
+ }
364
+ if (!outputPath) {
365
+ return {
366
+ success: false,
367
+ diagnostic: 'Unable to determine expected output path',
368
+ };
369
+ }
370
+ if (!result.outputFileExists) {
371
+ return {
372
+ success: false,
373
+ diagnostic: formatAgentDiagnostic(result, outputPath),
374
+ };
375
+ }
376
+ let content = '';
377
+ try {
378
+ content = await fs.readFile(outputPath, 'utf8');
379
+ }
380
+ catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ return {
383
+ success: false,
384
+ diagnostic: `Failed to read output file ${outputPath}: ${message}`,
385
+ };
386
+ }
387
+ try {
388
+ const data = parser(content);
389
+ return { success: true, data, rawContent: content };
390
+ }
391
+ catch (error) {
392
+ return {
393
+ success: false,
394
+ diagnostic: formatParseDiagnostic(error, content),
395
+ };
396
+ }
397
+ }
398
+ }
@@ -0,0 +1,7 @@
1
+ import type { ErrorStatus } from '../utils/errors.js';
2
+ import type { Database } from '../db/index.js';
3
+ export declare const LOCK_TIMEOUT_MS: number;
4
+ export declare function generateLockId(): string;
5
+ export declare function acquireLock(db: Database, errorId: number, lockId: string): Promise<boolean>;
6
+ export declare function releaseLock(db: Database, errorId: number, lockId: string): Promise<void>;
7
+ export declare function transitionStatus(db: Database, errorId: number, expected: ErrorStatus | ErrorStatus[], newStatus: ErrorStatus, lockId: string): boolean;
@@ -0,0 +1,49 @@
1
+ import os from 'node:os';
2
+ import { logActivity } from '../db/queries.js';
3
+ export const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
4
+ export function generateLockId() {
5
+ return `${os.hostname()}:${process.pid}:${Date.now()}`;
6
+ }
7
+ export async function acquireLock(db, errorId, lockId) {
8
+ const now = new Date().toISOString();
9
+ const expiryThreshold = new Date(Date.now() - LOCK_TIMEOUT_MS).toISOString();
10
+ const existing = db.get('SELECT locked_by, locked_at FROM errors WHERE id = ?', [errorId]);
11
+ const result = db.run(`UPDATE errors
12
+ SET locked_by = ?, locked_at = ?, updated_at = ?
13
+ WHERE id = ?
14
+ AND (
15
+ locked_by IS NULL
16
+ OR locked_at < ?
17
+ )`, [lockId, now, now, errorId, expiryThreshold]);
18
+ if (result.changes === 0) {
19
+ return false;
20
+ }
21
+ if (existing?.locked_by &&
22
+ existing.locked_at &&
23
+ existing.locked_at < expiryThreshold) {
24
+ logActivity(db, 'lock_expired', errorId, JSON.stringify({
25
+ previousLockId: existing.locked_by,
26
+ acquiredBy: lockId,
27
+ }));
28
+ }
29
+ logActivity(db, 'lock_acquired', errorId, JSON.stringify({ lockId }));
30
+ return true;
31
+ }
32
+ export async function releaseLock(db, errorId, lockId) {
33
+ const result = db.run(`UPDATE errors
34
+ SET locked_by = NULL, locked_at = NULL, updated_at = ?
35
+ WHERE id = ? AND locked_by = ?`, [new Date().toISOString(), errorId, lockId]);
36
+ if (result.changes > 0) {
37
+ logActivity(db, 'lock_released', errorId, JSON.stringify({ lockId }));
38
+ }
39
+ }
40
+ export function transitionStatus(db, errorId, expected, newStatus, lockId) {
41
+ const expectedStatuses = Array.isArray(expected) ? expected : [expected];
42
+ const placeholders = expectedStatuses.map(() => '?').join(', ');
43
+ const result = db.run(`UPDATE errors
44
+ SET status = ?, updated_at = ?
45
+ WHERE id = ?
46
+ AND locked_by = ?
47
+ AND status IN (${placeholders})`, [newStatus, new Date().toISOString(), errorId, lockId, ...expectedStatuses]);
48
+ return result.changes > 0;
49
+ }
@@ -0,0 +1,21 @@
1
+ type ConfidenceLevel = 'high' | 'medium' | 'low';
2
+ type AnalysisOutput = {
3
+ summary: string;
4
+ root_cause: string;
5
+ suggested_fix: string;
6
+ files_to_modify: string[];
7
+ confidence: ConfidenceLevel;
8
+ };
9
+ type FixOutput = {
10
+ success: boolean;
11
+ summary: string;
12
+ files_changed?: Array<{
13
+ path: string;
14
+ change: string;
15
+ }>;
16
+ notes?: string;
17
+ };
18
+ declare const parseAnalysisOutput: (content: string) => AnalysisOutput;
19
+ declare const parseFixOutput: (content: string) => FixOutput;
20
+ export type { AnalysisOutput, FixOutput, ConfidenceLevel };
21
+ export { parseAnalysisOutput, parseFixOutput };
@@ -0,0 +1,108 @@
1
+ import yaml from 'yaml';
2
+ import { UserError } from '../utils/errors.js';
3
+ const parseYaml = (content) => {
4
+ try {
5
+ return yaml.parse(content);
6
+ }
7
+ catch (error) {
8
+ const err = error;
9
+ throw new UserError(`Failed to parse YAML output: ${err.message ?? String(err)}`);
10
+ }
11
+ };
12
+ const assertRecord = (value) => {
13
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
14
+ throw new UserError('Invalid agent output: expected a YAML object');
15
+ }
16
+ return value;
17
+ };
18
+ const requireStringField = (data, field) => {
19
+ const value = data[field];
20
+ if (typeof value !== 'string' || value.trim() === '') {
21
+ throw new UserError(`Missing required field: ${field}`);
22
+ }
23
+ return value;
24
+ };
25
+ const requireBooleanField = (data, field) => {
26
+ const value = data[field];
27
+ if (typeof value !== 'boolean') {
28
+ throw new UserError(`Missing required field: ${field}`);
29
+ }
30
+ return value;
31
+ };
32
+ const requireStringArrayField = (data, field) => {
33
+ const value = data[field];
34
+ if (!Array.isArray(value)) {
35
+ throw new UserError(`Missing required field: ${field}`);
36
+ }
37
+ const strings = value.filter((entry) => typeof entry === 'string');
38
+ if (strings.length !== value.length) {
39
+ throw new UserError(`Invalid field: ${field} must be an array of strings`);
40
+ }
41
+ return strings;
42
+ };
43
+ const validateFilesChanged = (value) => {
44
+ if (!Array.isArray(value)) {
45
+ throw new UserError('Invalid field: files_changed must be an array of { path, change }');
46
+ }
47
+ const mapped = value.map((entry) => {
48
+ if (typeof entry !== 'object' ||
49
+ entry === null ||
50
+ Array.isArray(entry)) {
51
+ throw new UserError('Invalid field: files_changed must be an array of { path, change }');
52
+ }
53
+ const record = entry;
54
+ const path = record.path;
55
+ const change = record.change;
56
+ if (typeof path !== 'string' || typeof change !== 'string') {
57
+ throw new UserError('Invalid field: files_changed must be an array of { path, change }');
58
+ }
59
+ return { path, change };
60
+ });
61
+ return mapped.length > 0 ? mapped : undefined;
62
+ };
63
+ const validateConfidence = (value) => {
64
+ if (value === 'high' || value === 'medium' || value === 'low') {
65
+ return value;
66
+ }
67
+ throw new UserError(`Invalid confidence value: ${value ?? 'undefined'} (expected high|medium|low)`);
68
+ };
69
+ const parseAnalysisOutput = (content) => {
70
+ const raw = parseYaml(content);
71
+ const data = assertRecord(raw);
72
+ const summary = requireStringField(data, 'summary');
73
+ const root_cause = requireStringField(data, 'root_cause');
74
+ const suggested_fix = requireStringField(data, 'suggested_fix');
75
+ const files_to_modify = requireStringArrayField(data, 'files_to_modify');
76
+ const confidence = validateConfidence(data.confidence);
77
+ return {
78
+ summary,
79
+ root_cause,
80
+ suggested_fix,
81
+ files_to_modify,
82
+ confidence,
83
+ };
84
+ };
85
+ const parseFixOutput = (content) => {
86
+ const raw = parseYaml(content);
87
+ const data = assertRecord(raw);
88
+ const success = requireBooleanField(data, 'success');
89
+ const summary = requireStringField(data, 'summary');
90
+ let files_changed;
91
+ if (data.files_changed !== undefined) {
92
+ files_changed = validateFilesChanged(data.files_changed);
93
+ }
94
+ let notes;
95
+ if (data.notes !== undefined) {
96
+ if (typeof data.notes !== 'string') {
97
+ throw new UserError('Invalid field: notes must be a string');
98
+ }
99
+ notes = data.notes;
100
+ }
101
+ return {
102
+ success,
103
+ summary,
104
+ files_changed,
105
+ notes,
106
+ };
107
+ };
108
+ export { parseAnalysisOutput, parseFixOutput };
@@ -0,0 +1,15 @@
1
+ import type { Database } from '../db/index.js';
2
+ import { type ErrorRecord } from '../db/queries.js';
3
+ type FixQueueOptions = {
4
+ onProcess?: (error: ErrorRecord) => Promise<void> | void;
5
+ };
6
+ export declare class FixQueue {
7
+ private readonly db;
8
+ private readonly onProcess?;
9
+ private fixInProgress;
10
+ private processingPromise;
11
+ constructor(db: Database, options?: FixQueueOptions);
12
+ getNext(): ErrorRecord | null;
13
+ processQueueIfReady(): Promise<void>;
14
+ }
15
+ export {};
@@ -0,0 +1,53 @@
1
+ import { getErrorsByStatus } from '../db/queries.js';
2
+ export class FixQueue {
3
+ db;
4
+ onProcess;
5
+ fixInProgress = false;
6
+ processingPromise = null;
7
+ constructor(db, options) {
8
+ this.db = db;
9
+ this.onProcess = options?.onProcess;
10
+ }
11
+ getNext() {
12
+ if (this.fixInProgress) {
13
+ return null;
14
+ }
15
+ const errors = getErrorsByStatus(this.db, ['pending', 'suggested']);
16
+ return errors[0] ?? null;
17
+ }
18
+ async processQueueIfReady() {
19
+ if (!this.onProcess) {
20
+ return;
21
+ }
22
+ if (this.processingPromise) {
23
+ // If already processing, wait for the current loop to finish and then
24
+ // trigger a new check to ensure no items were missed in the window
25
+ // between getNext() returning null and the promise resolving.
26
+ await this.processingPromise;
27
+ return this.processQueueIfReady();
28
+ }
29
+ this.processingPromise = (async () => {
30
+ try {
31
+ for (let next = this.getNext(); next; next = this.getNext()) {
32
+ this.fixInProgress = true;
33
+ try {
34
+ await this.onProcess?.(next);
35
+ }
36
+ finally {
37
+ this.fixInProgress = false;
38
+ }
39
+ }
40
+ }
41
+ catch (err) {
42
+ // We catch here to ensure the promise resolves and processingPromise is cleared
43
+ // The error should ideally be logged by a real logger if available.
44
+ }
45
+ })();
46
+ try {
47
+ await this.processingPromise;
48
+ }
49
+ finally {
50
+ this.processingPromise = null;
51
+ }
52
+ }
53
+ }