@chrysb/alphaclaw 0.4.5 → 0.4.6-beta.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/README.md +21 -18
- package/lib/public/css/theme.css +29 -0
- package/lib/public/js/app.js +41 -2
- package/lib/public/js/components/badge.js +4 -0
- package/lib/public/js/components/doctor/findings-list.js +191 -0
- package/lib/public/js/components/doctor/fix-card-modal.js +144 -0
- package/lib/public/js/components/doctor/general-warning.js +37 -0
- package/lib/public/js/components/doctor/helpers.js +169 -0
- package/lib/public/js/components/doctor/index.js +536 -0
- package/lib/public/js/components/doctor/summary-cards.js +24 -0
- package/lib/public/js/lib/api.js +79 -0
- package/lib/server/commands.js +8 -4
- package/lib/server/db/doctor/index.js +529 -0
- package/lib/server/db/doctor/schema.js +69 -0
- package/lib/server/doctor/constants.js +43 -0
- package/lib/server/doctor/normalize.js +214 -0
- package/lib/server/doctor/prompt.js +89 -0
- package/lib/server/doctor/service.js +392 -0
- package/lib/server/doctor/workspace-fingerprint.js +126 -0
- package/lib/server/routes/doctor.js +123 -0
- package/lib/server/routes/system.js +7 -1
- package/lib/server/utils/json.js +56 -10
- package/lib/server.js +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
const { buildDoctorPrompt } = require("./prompt");
|
|
2
|
+
const { normalizeDoctorResult } = require("./normalize");
|
|
3
|
+
const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
|
|
4
|
+
const {
|
|
5
|
+
kDoctorEngine,
|
|
6
|
+
kDoctorMeaningfulChangeScoreThreshold,
|
|
7
|
+
kDoctorPromptVersion,
|
|
8
|
+
kDoctorRunStatus,
|
|
9
|
+
kDoctorRunTimeoutMs,
|
|
10
|
+
kDoctorStaleThresholdMs,
|
|
11
|
+
} = require("./constants");
|
|
12
|
+
|
|
13
|
+
const shellEscapeArg = (value) => {
|
|
14
|
+
const safeValue = String(value || "");
|
|
15
|
+
return `'${safeValue.replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const hasValidIsoTime = (value) => {
|
|
19
|
+
const timestamp = Date.parse(String(value || ""));
|
|
20
|
+
return Number.isFinite(timestamp);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const formatElapsedSince = (isoTime) => {
|
|
24
|
+
if (!hasValidIsoTime(isoTime)) return "the last scan";
|
|
25
|
+
const elapsedMs = Math.max(0, Date.now() - Date.parse(isoTime));
|
|
26
|
+
const elapsedMinutes = Math.max(1, Math.round(elapsedMs / 60000));
|
|
27
|
+
if (elapsedMinutes < 60) {
|
|
28
|
+
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ago`;
|
|
29
|
+
}
|
|
30
|
+
const elapsedHours = Math.round(elapsedMinutes / 60);
|
|
31
|
+
if (elapsedHours < 24) {
|
|
32
|
+
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ago`;
|
|
33
|
+
}
|
|
34
|
+
const elapsedDays = Math.round(elapsedHours / 24);
|
|
35
|
+
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildDoctorSessionKey = (runId) => `agent:main:doctor:${Number(runId || 0)}`;
|
|
39
|
+
const buildDoctorSessionId = (runId) => buildDoctorSessionKey(runId);
|
|
40
|
+
const buildDoctorIdempotencyKey = (runId) => `doctor-run-${Number(runId || 0)}`;
|
|
41
|
+
|
|
42
|
+
const createDoctorService = ({
|
|
43
|
+
clawCmd,
|
|
44
|
+
listDoctorRuns,
|
|
45
|
+
listDoctorCards,
|
|
46
|
+
getInitialWorkspaceBaseline,
|
|
47
|
+
setInitialWorkspaceBaseline,
|
|
48
|
+
createDoctorRun,
|
|
49
|
+
completeDoctorRun,
|
|
50
|
+
insertDoctorCards,
|
|
51
|
+
getDoctorRun,
|
|
52
|
+
getDoctorCardsByRunId,
|
|
53
|
+
getDoctorCard,
|
|
54
|
+
updateDoctorCardStatus,
|
|
55
|
+
workspaceRoot,
|
|
56
|
+
managedRoot,
|
|
57
|
+
protectedPaths = [],
|
|
58
|
+
lockedPaths = [],
|
|
59
|
+
}) => {
|
|
60
|
+
const state = {
|
|
61
|
+
activeRunId: 0,
|
|
62
|
+
activeRunPromise: null,
|
|
63
|
+
snapshotCache: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getLatestCompletedRun = () =>
|
|
67
|
+
listDoctorRuns({ limit: 25 }).find((run) => run.status === kDoctorRunStatus.completed) || null;
|
|
68
|
+
|
|
69
|
+
const getCurrentWorkspaceSnapshot = () => {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (state.snapshotCache && now - state.snapshotCache.computedAt < 5000) {
|
|
72
|
+
return state.snapshotCache.snapshot;
|
|
73
|
+
}
|
|
74
|
+
const snapshot = computeWorkspaceSnapshot(workspaceRoot);
|
|
75
|
+
state.snapshotCache = {
|
|
76
|
+
computedAt: now,
|
|
77
|
+
snapshot,
|
|
78
|
+
};
|
|
79
|
+
return snapshot;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const getOrCreateInitialBaseline = () => {
|
|
83
|
+
const existingBaseline = getInitialWorkspaceBaseline?.();
|
|
84
|
+
if (existingBaseline?.fingerprint && existingBaseline?.manifest) {
|
|
85
|
+
return existingBaseline;
|
|
86
|
+
}
|
|
87
|
+
const snapshot = getCurrentWorkspaceSnapshot();
|
|
88
|
+
const nextBaseline = {
|
|
89
|
+
fingerprint: snapshot.fingerprint,
|
|
90
|
+
manifest: snapshot.manifest,
|
|
91
|
+
capturedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
return setInitialWorkspaceBaseline?.(nextBaseline) || nextBaseline;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const cloneRunCards = ({ sourceRunId, targetRunId }) => {
|
|
97
|
+
const sourceCards = getDoctorCardsByRunId(sourceRunId);
|
|
98
|
+
insertDoctorCards({
|
|
99
|
+
runId: targetRunId,
|
|
100
|
+
cards: sourceCards,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const buildStatus = () => {
|
|
105
|
+
const recentRuns = listDoctorRuns({ limit: 10 });
|
|
106
|
+
const latestRun = recentRuns[0] || null;
|
|
107
|
+
const latestCompletedRun =
|
|
108
|
+
recentRuns.find((run) => run.status === kDoctorRunStatus.completed) || null;
|
|
109
|
+
const lastRunAt =
|
|
110
|
+
latestCompletedRun?.completedAt || latestCompletedRun?.startedAt || null;
|
|
111
|
+
const lastRunAgeMs = hasValidIsoTime(lastRunAt) ? Date.now() - Date.parse(lastRunAt) : null;
|
|
112
|
+
const stale = lastRunAgeMs == null || lastRunAgeMs >= kDoctorStaleThresholdMs;
|
|
113
|
+
const baselineRun = latestCompletedRun;
|
|
114
|
+
const initialBaseline = !baselineRun ? getOrCreateInitialBaseline() : null;
|
|
115
|
+
const currentSnapshot = baselineRun || initialBaseline ? getCurrentWorkspaceSnapshot() : null;
|
|
116
|
+
const baselineManifest =
|
|
117
|
+
baselineRun?.workspaceManifest && typeof baselineRun.workspaceManifest === "object"
|
|
118
|
+
? baselineRun.workspaceManifest
|
|
119
|
+
: initialBaseline?.manifest && typeof initialBaseline.manifest === "object"
|
|
120
|
+
? initialBaseline.manifest
|
|
121
|
+
: null;
|
|
122
|
+
const hasManifestBaseline = !!baselineManifest;
|
|
123
|
+
const delta =
|
|
124
|
+
hasManifestBaseline && currentSnapshot
|
|
125
|
+
? calculateWorkspaceDelta({
|
|
126
|
+
previousManifest: baselineManifest,
|
|
127
|
+
currentManifest: currentSnapshot.manifest,
|
|
128
|
+
})
|
|
129
|
+
: {
|
|
130
|
+
addedFilesCount: 0,
|
|
131
|
+
removedFilesCount: 0,
|
|
132
|
+
modifiedFilesCount: 0,
|
|
133
|
+
changedFilesCount: 0,
|
|
134
|
+
deltaScore: 0,
|
|
135
|
+
changedPaths: [],
|
|
136
|
+
};
|
|
137
|
+
const hasMeaningfulChanges =
|
|
138
|
+
!!latestCompletedRun &&
|
|
139
|
+
delta.deltaScore >= kDoctorMeaningfulChangeScoreThreshold;
|
|
140
|
+
return {
|
|
141
|
+
activeRunId: state.activeRunId || 0,
|
|
142
|
+
runInProgress: !!state.activeRunPromise,
|
|
143
|
+
lastRunAt,
|
|
144
|
+
lastRunAgeMs,
|
|
145
|
+
needsInitialRun: !latestCompletedRun,
|
|
146
|
+
stale,
|
|
147
|
+
changeSummary: {
|
|
148
|
+
...delta,
|
|
149
|
+
hasBaseline: hasManifestBaseline,
|
|
150
|
+
baselineSource: baselineRun ? "last_run" : initialBaseline ? "initial_install" : "none",
|
|
151
|
+
hasMeaningfulChanges,
|
|
152
|
+
},
|
|
153
|
+
latestRun,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const executeDoctorRun = async (runId) => {
|
|
158
|
+
try {
|
|
159
|
+
const prompt = buildDoctorPrompt({
|
|
160
|
+
workspaceRoot,
|
|
161
|
+
managedRoot,
|
|
162
|
+
protectedPaths,
|
|
163
|
+
lockedPaths,
|
|
164
|
+
promptVersion: kDoctorPromptVersion,
|
|
165
|
+
});
|
|
166
|
+
const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;
|
|
167
|
+
const gatewayParams = {
|
|
168
|
+
agentId: "main",
|
|
169
|
+
idempotencyKey: buildDoctorIdempotencyKey(runId),
|
|
170
|
+
message: prompt,
|
|
171
|
+
sessionKey: buildDoctorSessionKey(runId),
|
|
172
|
+
thinking: "medium",
|
|
173
|
+
timeout: Math.round(kDoctorRunTimeoutMs / 1000),
|
|
174
|
+
};
|
|
175
|
+
const result = await clawCmd(
|
|
176
|
+
`gateway call agent --expect-final --json --timeout ${gatewayTimeoutMs} --params ${shellEscapeArg(
|
|
177
|
+
JSON.stringify(gatewayParams),
|
|
178
|
+
)}`,
|
|
179
|
+
{
|
|
180
|
+
quiet: true,
|
|
181
|
+
timeoutMs: gatewayTimeoutMs,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
if (!result?.ok) {
|
|
185
|
+
throw new Error(result?.stderr || "Doctor analysis command failed");
|
|
186
|
+
}
|
|
187
|
+
const stdoutText = String(result.stdout || "");
|
|
188
|
+
const stderrText = String(result.stderr || "");
|
|
189
|
+
console.log(
|
|
190
|
+
`[doctor] run ${runId} command result ok=${result.ok} code=${result.code ?? 0} stdout_chars=${stdoutText.length} stderr_chars=${stderrText.length}`,
|
|
191
|
+
);
|
|
192
|
+
let normalizedResult = null;
|
|
193
|
+
try {
|
|
194
|
+
normalizedResult = normalizeDoctorResult(stdoutText);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(
|
|
197
|
+
`[doctor] run ${runId} normalize failed: ${error.message || "Unknown error"}`,
|
|
198
|
+
);
|
|
199
|
+
console.error(`[doctor] run ${runId} stdout begin`);
|
|
200
|
+
console.error(stdoutText || "(empty)");
|
|
201
|
+
console.error(`[doctor] run ${runId} stdout end`);
|
|
202
|
+
console.error(`[doctor] run ${runId} stderr begin`);
|
|
203
|
+
console.error(stderrText || "(empty)");
|
|
204
|
+
console.error(`[doctor] run ${runId} stderr end`);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
insertDoctorCards({
|
|
208
|
+
runId,
|
|
209
|
+
cards: normalizedResult.cards,
|
|
210
|
+
});
|
|
211
|
+
completeDoctorRun({
|
|
212
|
+
id: runId,
|
|
213
|
+
status: kDoctorRunStatus.completed,
|
|
214
|
+
summary: normalizedResult.summary,
|
|
215
|
+
rawResult: normalizedResult.rawPayload,
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
completeDoctorRun({
|
|
219
|
+
id: runId,
|
|
220
|
+
status: kDoctorRunStatus.failed,
|
|
221
|
+
error: error.message || "Doctor run failed",
|
|
222
|
+
});
|
|
223
|
+
} finally {
|
|
224
|
+
state.activeRunId = 0;
|
|
225
|
+
state.activeRunPromise = null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const runDoctor = () => {
|
|
230
|
+
if (state.activeRunPromise) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
alreadyRunning: true,
|
|
234
|
+
runId: state.activeRunId || 0,
|
|
235
|
+
status: buildStatus(),
|
|
236
|
+
error: "Doctor run already in progress",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
240
|
+
const workspaceFingerprint = workspaceSnapshot.fingerprint;
|
|
241
|
+
const latestCompletedRun = getLatestCompletedRun();
|
|
242
|
+
if (
|
|
243
|
+
latestCompletedRun &&
|
|
244
|
+
latestCompletedRun.workspaceFingerprint &&
|
|
245
|
+
latestCompletedRun.workspaceFingerprint === workspaceFingerprint
|
|
246
|
+
) {
|
|
247
|
+
const runId = createDoctorRun({
|
|
248
|
+
status: kDoctorRunStatus.completed,
|
|
249
|
+
engine: kDoctorEngine.deterministicReuse,
|
|
250
|
+
workspaceRoot,
|
|
251
|
+
workspaceFingerprint,
|
|
252
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
253
|
+
promptVersion: kDoctorPromptVersion,
|
|
254
|
+
reusedFromRunId: latestCompletedRun.id,
|
|
255
|
+
});
|
|
256
|
+
cloneRunCards({
|
|
257
|
+
sourceRunId: latestCompletedRun.id,
|
|
258
|
+
targetRunId: runId,
|
|
259
|
+
});
|
|
260
|
+
const summary = `No workspace changes since last scan (${formatElapsedSince(
|
|
261
|
+
latestCompletedRun.completedAt || latestCompletedRun.startedAt,
|
|
262
|
+
)}). Same findings apply.`;
|
|
263
|
+
completeDoctorRun({
|
|
264
|
+
id: runId,
|
|
265
|
+
status: kDoctorRunStatus.completed,
|
|
266
|
+
summary,
|
|
267
|
+
rawResult: latestCompletedRun.rawResult,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
runId,
|
|
272
|
+
reusedPreviousRun: true,
|
|
273
|
+
sourceRunId: latestCompletedRun.id,
|
|
274
|
+
status: buildStatus(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const runId = createDoctorRun({
|
|
278
|
+
status: kDoctorRunStatus.running,
|
|
279
|
+
engine: kDoctorEngine.gatewayAgent,
|
|
280
|
+
workspaceRoot,
|
|
281
|
+
workspaceFingerprint,
|
|
282
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
283
|
+
promptVersion: kDoctorPromptVersion,
|
|
284
|
+
});
|
|
285
|
+
state.activeRunId = runId;
|
|
286
|
+
state.activeRunPromise = executeDoctorRun(runId);
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
runId,
|
|
290
|
+
status: buildStatus(),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const importDoctorResult = ({
|
|
295
|
+
rawOutput,
|
|
296
|
+
engine = kDoctorEngine.manualImport,
|
|
297
|
+
} = {}) => {
|
|
298
|
+
const normalizedRawOutput = String(rawOutput || "");
|
|
299
|
+
if (!normalizedRawOutput.trim()) {
|
|
300
|
+
throw new Error("Doctor import requires raw output");
|
|
301
|
+
}
|
|
302
|
+
const normalizedResult = normalizeDoctorResult(normalizedRawOutput);
|
|
303
|
+
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
304
|
+
const runId = createDoctorRun({
|
|
305
|
+
status: kDoctorRunStatus.completed,
|
|
306
|
+
engine,
|
|
307
|
+
workspaceRoot,
|
|
308
|
+
workspaceFingerprint: workspaceSnapshot.fingerprint,
|
|
309
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
310
|
+
promptVersion: kDoctorPromptVersion,
|
|
311
|
+
});
|
|
312
|
+
insertDoctorCards({
|
|
313
|
+
runId,
|
|
314
|
+
cards: normalizedResult.cards,
|
|
315
|
+
});
|
|
316
|
+
completeDoctorRun({
|
|
317
|
+
id: runId,
|
|
318
|
+
status: kDoctorRunStatus.completed,
|
|
319
|
+
summary: normalizedResult.summary,
|
|
320
|
+
rawResult: normalizedResult.rawPayload,
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
runId,
|
|
325
|
+
run: getDoctorRun(runId),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const requestCardFix = async ({
|
|
330
|
+
cardId,
|
|
331
|
+
sessionId = "",
|
|
332
|
+
replyChannel = "",
|
|
333
|
+
replyTo = "",
|
|
334
|
+
} = {}) => {
|
|
335
|
+
const card = getDoctorCard(cardId);
|
|
336
|
+
if (!card) throw new Error("Doctor card not found");
|
|
337
|
+
const prompt = String(card.fixPrompt || "").trim();
|
|
338
|
+
if (!prompt) throw new Error("Doctor card does not include a fix prompt");
|
|
339
|
+
let command = `agent --agent main --message ${shellEscapeArg(prompt)}`;
|
|
340
|
+
const trimmedSessionId = String(sessionId || "").trim();
|
|
341
|
+
const trimmedReplyChannel = String(replyChannel || "").trim();
|
|
342
|
+
const trimmedReplyTo = String(replyTo || "").trim();
|
|
343
|
+
if (trimmedReplyChannel && trimmedReplyTo) {
|
|
344
|
+
command +=
|
|
345
|
+
` --deliver --reply-channel ${shellEscapeArg(trimmedReplyChannel)}` +
|
|
346
|
+
` --reply-to ${shellEscapeArg(trimmedReplyTo)}`;
|
|
347
|
+
} else if (trimmedSessionId) {
|
|
348
|
+
command += ` --session-id ${shellEscapeArg(trimmedSessionId)}`;
|
|
349
|
+
}
|
|
350
|
+
const result = await clawCmd(command, {
|
|
351
|
+
quiet: true,
|
|
352
|
+
timeoutMs: kDoctorRunTimeoutMs,
|
|
353
|
+
});
|
|
354
|
+
if (!result?.ok) {
|
|
355
|
+
throw new Error(result?.stderr || "Could not send Doctor fix request");
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
stdout: result.stdout || "",
|
|
360
|
+
card,
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const setCardStatus = ({ cardId, status }) => {
|
|
365
|
+
const updatedCard = updateDoctorCardStatus({
|
|
366
|
+
id: cardId,
|
|
367
|
+
status,
|
|
368
|
+
});
|
|
369
|
+
if (!updatedCard) throw new Error("Doctor card not found");
|
|
370
|
+
return updatedCard;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
buildStatus,
|
|
375
|
+
runDoctor,
|
|
376
|
+
importDoctorResult,
|
|
377
|
+
listDoctorRuns,
|
|
378
|
+
listDoctorCards,
|
|
379
|
+
getDoctorRun,
|
|
380
|
+
getDoctorCardsByRunId,
|
|
381
|
+
requestCardFix,
|
|
382
|
+
setCardStatus,
|
|
383
|
+
getDoctorCard,
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
buildDoctorIdempotencyKey,
|
|
389
|
+
buildDoctorSessionKey,
|
|
390
|
+
buildDoctorSessionId,
|
|
391
|
+
createDoctorService,
|
|
392
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
const kIgnoredDirectoryNames = new Set([".git", "node_modules"]);
|
|
6
|
+
|
|
7
|
+
const hashFile = (filePath) => {
|
|
8
|
+
const buffer = fs.readFileSync(filePath);
|
|
9
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const normalizeRelativePath = (rootDir, filePath) =>
|
|
13
|
+
path.relative(rootDir, filePath).split(path.sep).join("/");
|
|
14
|
+
|
|
15
|
+
const walkFiles = (rootDir, currentDir = rootDir) => {
|
|
16
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
17
|
+
const sortedEntries = [...entries].sort((left, right) => left.name.localeCompare(right.name));
|
|
18
|
+
const files = [];
|
|
19
|
+
|
|
20
|
+
for (const entry of sortedEntries) {
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if (kIgnoredDirectoryNames.has(entry.name)) continue;
|
|
23
|
+
files.push(...walkFiles(rootDir, path.join(currentDir, entry.name)));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!entry.isFile()) continue;
|
|
27
|
+
files.push(path.join(currentDir, entry.name));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return files;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const buildWorkspaceManifest = (rootDir) => {
|
|
34
|
+
const normalizedRootDir = path.resolve(String(rootDir || ""));
|
|
35
|
+
const files = walkFiles(normalizedRootDir);
|
|
36
|
+
return files.reduce((manifest, filePath) => {
|
|
37
|
+
manifest[normalizeRelativePath(normalizedRootDir, filePath)] = hashFile(filePath);
|
|
38
|
+
return manifest;
|
|
39
|
+
}, {});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const computeWorkspaceFingerprintFromManifest = (manifest = {}) => {
|
|
43
|
+
const hash = crypto.createHash("sha256");
|
|
44
|
+
const entries = Object.entries(manifest).sort(([leftPath], [rightPath]) =>
|
|
45
|
+
leftPath.localeCompare(rightPath),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
hash.update("workspace-fingerprint-v1");
|
|
49
|
+
for (const [relativePath, fileHash] of entries) {
|
|
50
|
+
hash.update(relativePath);
|
|
51
|
+
hash.update("\0");
|
|
52
|
+
hash.update(fileHash);
|
|
53
|
+
hash.update("\0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return hash.digest("hex");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const computeWorkspaceSnapshot = (rootDir) => {
|
|
60
|
+
const manifest = buildWorkspaceManifest(rootDir);
|
|
61
|
+
return {
|
|
62
|
+
fingerprint: computeWorkspaceFingerprintFromManifest(manifest),
|
|
63
|
+
manifest,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getPathChangeWeight = (relativePath = "") => {
|
|
68
|
+
const normalizedPath = String(relativePath || "").trim().toLowerCase();
|
|
69
|
+
if (!normalizedPath) return 1;
|
|
70
|
+
if (
|
|
71
|
+
normalizedPath === "agents.md" ||
|
|
72
|
+
normalizedPath === "tools.md" ||
|
|
73
|
+
normalizedPath === "readme.md" ||
|
|
74
|
+
normalizedPath === "bootstrap.md" ||
|
|
75
|
+
normalizedPath === "memory.md" ||
|
|
76
|
+
normalizedPath === "user.md" ||
|
|
77
|
+
normalizedPath === "identity.md"
|
|
78
|
+
) {
|
|
79
|
+
return 4;
|
|
80
|
+
}
|
|
81
|
+
if (normalizedPath.startsWith("hooks/bootstrap/")) return 4;
|
|
82
|
+
if (normalizedPath.startsWith("skills/")) return 3;
|
|
83
|
+
if (normalizedPath.endsWith(".md")) return 2;
|
|
84
|
+
return 1;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} } = {}) => {
|
|
88
|
+
const previousPaths = Object.keys(previousManifest);
|
|
89
|
+
const currentPaths = Object.keys(currentManifest);
|
|
90
|
+
const allPaths = Array.from(new Set([...previousPaths, ...currentPaths])).sort((left, right) =>
|
|
91
|
+
left.localeCompare(right),
|
|
92
|
+
);
|
|
93
|
+
const changeSummary = {
|
|
94
|
+
addedFilesCount: 0,
|
|
95
|
+
removedFilesCount: 0,
|
|
96
|
+
modifiedFilesCount: 0,
|
|
97
|
+
changedFilesCount: 0,
|
|
98
|
+
deltaScore: 0,
|
|
99
|
+
changedPaths: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const relativePath of allPaths) {
|
|
103
|
+
const previousHash = previousManifest[relativePath] || "";
|
|
104
|
+
const currentHash = currentManifest[relativePath] || "";
|
|
105
|
+
if (!previousHash && currentHash) {
|
|
106
|
+
changeSummary.addedFilesCount += 1;
|
|
107
|
+
} else if (previousHash && !currentHash) {
|
|
108
|
+
changeSummary.removedFilesCount += 1;
|
|
109
|
+
} else if (previousHash !== currentHash) {
|
|
110
|
+
changeSummary.modifiedFilesCount += 1;
|
|
111
|
+
} else {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
changeSummary.changedFilesCount += 1;
|
|
115
|
+
changeSummary.deltaScore += getPathChangeWeight(relativePath);
|
|
116
|
+
changeSummary.changedPaths.push(relativePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return changeSummary;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
calculateWorkspaceDelta,
|
|
124
|
+
computeWorkspaceFingerprintFromManifest,
|
|
125
|
+
computeWorkspaceSnapshot,
|
|
126
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const { kDoctorCardStatus, kDoctorDefaultRunsLimit } = require("../doctor/constants");
|
|
2
|
+
|
|
3
|
+
const registerDoctorRoutes = ({ app, requireAuth, doctorService }) => {
|
|
4
|
+
app.get("/api/doctor/status", requireAuth, (req, res) => {
|
|
5
|
+
try {
|
|
6
|
+
res.json({ ok: true, status: doctorService.buildStatus() });
|
|
7
|
+
} catch (error) {
|
|
8
|
+
res.status(500).json({ ok: false, error: error.message });
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
app.post("/api/doctor/run", requireAuth, (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const result = doctorService.runDoctor();
|
|
15
|
+
if (!result.ok && result.alreadyRunning) {
|
|
16
|
+
return res.status(409).json(result);
|
|
17
|
+
}
|
|
18
|
+
return res.status(result.reusedPreviousRun ? 200 : 202).json(result);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return res.status(500).json({ ok: false, error: error.message });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.post("/api/doctor/import", requireAuth, (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = doctorService.importDoctorResult({
|
|
27
|
+
rawOutput: req.body?.rawOutput,
|
|
28
|
+
});
|
|
29
|
+
return res.status(201).json(result);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return res.status(400).json({ ok: false, error: error.message });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.get("/api/doctor/runs", requireAuth, (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const limit = Number.parseInt(String(req.query.limit || kDoctorDefaultRunsLimit), 10);
|
|
38
|
+
const runs = doctorService.listDoctorRuns({ limit });
|
|
39
|
+
res.json({ ok: true, runs });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
res.status(500).json({ ok: false, error: error.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get("/api/doctor/cards", requireAuth, (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const runId = String(req.query.runId || "").trim();
|
|
48
|
+
const cards = doctorService.listDoctorCards({
|
|
49
|
+
runId: runId || "all",
|
|
50
|
+
});
|
|
51
|
+
return res.json({ ok: true, cards });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return res.status(500).json({ ok: false, error: error.message });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.get("/api/doctor/runs/:id", requireAuth, (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const run = doctorService.getDoctorRun(req.params.id);
|
|
60
|
+
if (!run) {
|
|
61
|
+
return res.status(404).json({ ok: false, error: "Doctor run not found" });
|
|
62
|
+
}
|
|
63
|
+
return res.json({ ok: true, run });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return res.status(500).json({ ok: false, error: error.message });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get("/api/doctor/runs/:id/cards", requireAuth, (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const run = doctorService.getDoctorRun(req.params.id);
|
|
72
|
+
if (!run) {
|
|
73
|
+
return res.status(404).json({ ok: false, error: "Doctor run not found" });
|
|
74
|
+
}
|
|
75
|
+
const cards = doctorService.getDoctorCardsByRunId(req.params.id);
|
|
76
|
+
return res.json({ ok: true, cards });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return res.status(500).json({ ok: false, error: error.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
app.post("/api/doctor/cards/:id/status", requireAuth, (req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
const requestedStatus = String(req.body?.status || "").trim().toLowerCase();
|
|
85
|
+
if (
|
|
86
|
+
requestedStatus !== kDoctorCardStatus.open &&
|
|
87
|
+
requestedStatus !== kDoctorCardStatus.dismissed &&
|
|
88
|
+
requestedStatus !== kDoctorCardStatus.fixed
|
|
89
|
+
) {
|
|
90
|
+
return res.status(400).json({ ok: false, error: "Invalid Doctor card status" });
|
|
91
|
+
}
|
|
92
|
+
const card = doctorService.setCardStatus({
|
|
93
|
+
cardId: req.params.id,
|
|
94
|
+
status: requestedStatus,
|
|
95
|
+
});
|
|
96
|
+
return res.json({ ok: true, card });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (/not found/i.test(error.message || "")) {
|
|
99
|
+
return res.status(404).json({ ok: false, error: error.message });
|
|
100
|
+
}
|
|
101
|
+
return res.status(400).json({ ok: false, error: error.message });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.post("/api/doctor/findings/:id/fix", requireAuth, async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
const result = await doctorService.requestCardFix({
|
|
108
|
+
cardId: req.params.id,
|
|
109
|
+
sessionId: req.body?.sessionId,
|
|
110
|
+
replyChannel: req.body?.replyChannel,
|
|
111
|
+
replyTo: req.body?.replyTo,
|
|
112
|
+
});
|
|
113
|
+
return res.json(result);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (/not found/i.test(error.message || "")) {
|
|
116
|
+
return res.status(404).json({ ok: false, error: error.message });
|
|
117
|
+
}
|
|
118
|
+
return res.status(400).json({ ok: false, error: error.message });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
module.exports = { registerDoctorRoutes };
|
|
@@ -90,7 +90,13 @@ const registerSystemRoutes = ({
|
|
|
90
90
|
.filter((sessionRow) => {
|
|
91
91
|
const key = String(sessionRow?.key || "").toLowerCase();
|
|
92
92
|
if (!key) return false;
|
|
93
|
-
if (
|
|
93
|
+
if (
|
|
94
|
+
key.includes(":hook:") ||
|
|
95
|
+
key.includes(":cron:") ||
|
|
96
|
+
key.includes(":doctor:")
|
|
97
|
+
) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
94
100
|
return true;
|
|
95
101
|
})
|
|
96
102
|
.map((sessionRow) => {
|