co-maintainer 0.5.1 → 0.5.3

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 (36) hide show
  1. package/dist/package.json +2 -4
  2. package/dist/src/ai/anthropic.js +6 -1
  3. package/dist/src/ai/openai.js +18 -19
  4. package/dist/src/ai/openrouter.js +13 -19
  5. package/dist/src/ai/provider.d.ts +9 -0
  6. package/dist/src/ai/provider.js +29 -1
  7. package/dist/src/cli/commands/clear.d.ts +1 -0
  8. package/dist/src/cli/commands/clear.js +152 -0
  9. package/dist/src/cli/commands/registry.d.ts +4 -0
  10. package/dist/src/cli/commands/registry.js +48 -0
  11. package/dist/src/cli/commands/uninstall.d.ts +1 -0
  12. package/dist/src/cli/commands/uninstall.js +43 -0
  13. package/dist/src/cli/main.js +10 -0
  14. package/dist/src/config.d.ts +14 -0
  15. package/dist/src/config.js +38 -1
  16. package/dist/src/knowledge/synthesis.js +3 -0
  17. package/dist/src/local/review_lock.js +2 -2
  18. package/dist/src/server/api/knowledge.d.ts +7 -0
  19. package/dist/src/server/api/knowledge.js +68 -0
  20. package/dist/src/server/api/repos.js +5 -0
  21. package/dist/src/server/app.js +4 -0
  22. package/dist/src/server/pages/client.d.ts +1 -1
  23. package/dist/src/server/pages/client.js +1 -1
  24. package/dist/src/server/pages/knowledge.js +20 -1
  25. package/dist/src/server/pages/settings.js +18 -0
  26. package/dist/src/services/clear.d.ts +70 -0
  27. package/dist/src/services/clear.js +310 -0
  28. package/dist/src/store/app_db.d.ts +3 -0
  29. package/dist/src/store/app_db.js +5 -0
  30. package/dist/src/store/cache_db.d.ts +11 -0
  31. package/dist/src/store/cache_db.js +60 -1
  32. package/dist/src/store/repos.d.ts +3 -0
  33. package/dist/src/store/repos.js +8 -0
  34. package/dist/src/util/runtime.d.ts +3 -0
  35. package/dist/src/util/runtime.js +6 -1
  36. package/package.json +2 -4
@@ -0,0 +1,68 @@
1
+ /** The dashboard's clear routes.
2
+ *
3
+ * `DELETE /api/repos/:owner/:repo/knowledge` forgets one repository,
4
+ * `DELETE /api/knowledge` forgets every stored repository. Both share the job
5
+ * negotiation here: a queued job can be canceled and a running review can be
6
+ * aborted (`?onRunning=abort`), but a running `init`/`remake` cannot be
7
+ * stopped, so the route answers 409 and the page waits for it instead. */
8
+ import { errorResponse } from "../errors.js";
9
+ import { abortRunningReviews, cancelQueuedJobs, clearRepos, clearRepo, listStoredRepos, repoJobs, } from "../../services/clear.js";
10
+ function readClearParams(url) {
11
+ return {
12
+ includeCache: url.searchParams.get("includeCache") === "1",
13
+ abort: url.searchParams.get("onRunning") === "abort",
14
+ };
15
+ }
16
+ /** A running setup job ignores the abort signal and would write the guides
17
+ * again after the clear, so it is the one thing the caller cannot skip. */
18
+ function isSetupType(type) {
19
+ return type !== "review" && type !== "remote_review";
20
+ }
21
+ /** `undefined` means the clear may go ahead. A 409 carries enough detail for
22
+ * the page to ask the right question: whether to abort reviews and queued
23
+ * jobs, or to wait out a sync. */
24
+ export function negotiateJobs(repos, abort) {
25
+ const jobs = repoJobs(repos);
26
+ if (jobs.queued.length === 0 && jobs.running.length === 0)
27
+ return undefined;
28
+ if (!abort) {
29
+ return errorResponse(409, "jobs_running", "Jobs are pending for this target.", {
30
+ queued: jobs.queued.length,
31
+ running: jobs.running.map((job) => ({
32
+ id: job.id,
33
+ type: job.type,
34
+ abortable: !isSetupType(job.type),
35
+ })),
36
+ });
37
+ }
38
+ const setup = jobs.running.find((job) => isSetupType(job.type));
39
+ if (setup) {
40
+ return errorResponse(409, "job_not_abortable", "A sync is running and cannot be stopped.", { id: setup.id, type: setup.type });
41
+ }
42
+ cancelQueuedJobs(repos);
43
+ abortRunningReviews(repos);
44
+ return undefined;
45
+ }
46
+ export async function handleKnowledgeRoute(request, url) {
47
+ if (url.pathname !== "/api/knowledge" || request.method !== "DELETE") {
48
+ return errorResponse(404, "not_found", `no route for ${url.pathname}`);
49
+ }
50
+ const { includeCache, abort } = readClearParams(url);
51
+ // The same snapshot is negotiated and cleared, so a repository added in
52
+ // between cannot slip past the job check.
53
+ const repos = await listStoredRepos();
54
+ const conflict = negotiateJobs(repos, abort);
55
+ if (conflict)
56
+ return conflict;
57
+ const result = await clearRepos(repos, { includeCache });
58
+ return Response.json({ ok: true, ...result });
59
+ }
60
+ /** Shared by the per-repo route, which knows exactly one repository. */
61
+ export async function clearOneRepo(fullName, url) {
62
+ const { includeCache, abort } = readClearParams(url);
63
+ const conflict = negotiateJobs([fullName], abort);
64
+ if (conflict)
65
+ return conflict;
66
+ const result = await clearRepo(fullName, { includeCache });
67
+ return Response.json({ ok: true, ...result });
68
+ }
@@ -10,6 +10,7 @@ import { probePlan, recommendationPatch } from "../../services/probe.js";
10
10
  import { estimateInit, readJobHistory } from "../../ai/estimate.js";
11
11
  import { loadPrices } from "../../ai/pricing.js";
12
12
  import { prDetail, repoKnowledge, RepoNotFound, repoOverview, repoPulls, requireActiveRepo, } from "../../services/dashboard.js";
13
+ import { clearOneRepo } from "./knowledge.js";
13
14
  const REPO = /^\/api\/repos\/([^/]+)\/([^/]+)(?:\/(remake|knowledge|pulls)(?:\/(\d+)(?:\/(review))?)?)?$/;
14
15
  export async function handleReposRoute(request, url, githubApp) {
15
16
  if (url.pathname === "/api/repos" && request.method === "GET") {
@@ -264,6 +265,10 @@ export async function handleReposRoute(request, url, githubApp) {
264
265
  if (sub === "knowledge" && request.method === "GET") {
265
266
  return Response.json(await repoKnowledge(fullName));
266
267
  }
268
+ if (sub === "knowledge" && request.method === "DELETE") {
269
+ requireActiveRepo(fullName);
270
+ return await clearOneRepo(fullName, url);
271
+ }
267
272
  if (sub === "pulls" &&
268
273
  pr &&
269
274
  action === "review" &&
@@ -9,6 +9,7 @@ import { aiConfigured, readConfig, resolveAppPrivateKey } from "../config.js";
9
9
  import { listJobs } from "../store/jobs.js";
10
10
  import { handleJobsRoute } from "./api/jobs.js";
11
11
  import { handleReposRoute } from "./api/repos.js";
12
+ import { handleKnowledgeRoute } from "./api/knowledge.js";
12
13
  import { handleInstallationsRoute } from "./api/installations.js";
13
14
  import { handleSettingsRoute } from "./api/settings.js";
14
15
  import { handleActivityRoute } from "./api/activity.js";
@@ -154,6 +155,9 @@ export function createApp(deps) {
154
155
  if (url.pathname.startsWith("/api/repos")) {
155
156
  return await handleReposRoute(request, url, githubApp);
156
157
  }
158
+ if (url.pathname === "/api/knowledge") {
159
+ return await handleKnowledgeRoute(request, url);
160
+ }
157
161
  if (url.pathname === "/api/installations") {
158
162
  return await handleInstallationsRoute(request, githubApp);
159
163
  }
@@ -1,2 +1,2 @@
1
1
  /** Generated from src/server/pages/client.js by scripts/embed_assets.mjs — do not edit. */
2
- export declare const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n } catch {\n // body was not JSON; keep the generic message\n }\n throw new Error(message);\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
2
+ export declare const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n let code = \"\";\n let detail;\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n if (data.error && data.error.code) code = data.error.code;\n if (data.error) detail = data.error.detail;\n } catch {\n // body was not JSON; keep the generic message\n }\n const error = new Error(message);\n if (code) error.code = code;\n error.detail = detail;\n throw error;\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\n/** Polls one job until it reaches a terminal status. A running `init`/`remake`\n * ignores the abort signal, so \"wait for the sync\" is the only honest option\n * the clear pages can offer for it. */\nfunction waitForJob(id) {\n return new Promise(function (resolve, reject) {\n let attempts = 0;\n async function tick() {\n attempts++;\n try {\n const res = await fetch(\"/api/jobs/\" + encodeURIComponent(id), {\n headers: { \"x-requested-with\": \"co-maintainer\" },\n });\n if (!res.ok) throw new Error(\"Could not check the running job.\");\n const job = await res.json();\n if (job.status !== \"running\" && job.status !== \"queued\") {\n resolve();\n return;\n }\n if (attempts > 1200) {\n reject(new Error(\"The sync did not finish.\"));\n return;\n }\n setTimeout(tick, 3000);\n } catch (err) {\n reject(err);\n }\n }\n tick();\n });\n}\n\n/** Clears knowledge, asking about pending jobs when the server refuses.\n * Returns false when the user backed out, so the caller does not reload.\n * A running review is abortable and a running setup is not, which is the\n * difference between the two questions. */\nasync function clearKnowledge(url, includeCache) {\n const target = includeCache ? url + \"?includeCache=1\" : url;\n const retry = target.indexOf(\"?\") >= 0 ? \"&\" : \"?\";\n try {\n await api(\"DELETE\", target);\n } catch (err) {\n if (err.code !== \"jobs_running\") throw err;\n const detail = err.detail || { queued: 0, running: [] };\n const running = detail.running || [];\n const setup = running.find(function (job) {\n return job.abortable === false;\n });\n if (setup) {\n if (\n !confirm(\n \"A sync is running and cannot be stopped. Wait for it to finish and then clear?\",\n )\n ) {\n return false;\n }\n await waitForJob(setup.id);\n await api(\"DELETE\", target);\n return;\n }\n const parts = [];\n if (running.length) parts.push(running.length + \" running review(s)\");\n if (detail.queued) parts.push(detail.queued + \" queued job(s)\");\n if (!confirm(\"Stop \" + parts.join(\" and \") + \" and clear?\")) return false;\n try {\n await api(\"DELETE\", target + retry + \"onRunning=abort\");\n } catch (retryError) {\n // A setup job was claimed between the first 409 and this retry. It\n // cannot be aborted, so wait it out like the branch above.\n if (retryError.code !== \"job_not_abortable\") throw retryError;\n const jobId = retryError.detail && retryError.detail.id;\n if (jobId) await waitForJob(jobId);\n await api(\"DELETE\", target);\n }\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
@@ -1,2 +1,2 @@
1
1
  /** Generated from src/server/pages/client.js by scripts/embed_assets.mjs — do not edit. */
2
- export const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n } catch {\n // body was not JSON; keep the generic message\n }\n throw new Error(message);\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
2
+ export const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n let code = \"\";\n let detail;\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n if (data.error && data.error.code) code = data.error.code;\n if (data.error) detail = data.error.detail;\n } catch {\n // body was not JSON; keep the generic message\n }\n const error = new Error(message);\n if (code) error.code = code;\n error.detail = detail;\n throw error;\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\n/** Polls one job until it reaches a terminal status. A running `init`/`remake`\n * ignores the abort signal, so \"wait for the sync\" is the only honest option\n * the clear pages can offer for it. */\nfunction waitForJob(id) {\n return new Promise(function (resolve, reject) {\n let attempts = 0;\n async function tick() {\n attempts++;\n try {\n const res = await fetch(\"/api/jobs/\" + encodeURIComponent(id), {\n headers: { \"x-requested-with\": \"co-maintainer\" },\n });\n if (!res.ok) throw new Error(\"Could not check the running job.\");\n const job = await res.json();\n if (job.status !== \"running\" && job.status !== \"queued\") {\n resolve();\n return;\n }\n if (attempts > 1200) {\n reject(new Error(\"The sync did not finish.\"));\n return;\n }\n setTimeout(tick, 3000);\n } catch (err) {\n reject(err);\n }\n }\n tick();\n });\n}\n\n/** Clears knowledge, asking about pending jobs when the server refuses.\n * Returns false when the user backed out, so the caller does not reload.\n * A running review is abortable and a running setup is not, which is the\n * difference between the two questions. */\nasync function clearKnowledge(url, includeCache) {\n const target = includeCache ? url + \"?includeCache=1\" : url;\n const retry = target.indexOf(\"?\") >= 0 ? \"&\" : \"?\";\n try {\n await api(\"DELETE\", target);\n } catch (err) {\n if (err.code !== \"jobs_running\") throw err;\n const detail = err.detail || { queued: 0, running: [] };\n const running = detail.running || [];\n const setup = running.find(function (job) {\n return job.abortable === false;\n });\n if (setup) {\n if (\n !confirm(\n \"A sync is running and cannot be stopped. Wait for it to finish and then clear?\",\n )\n ) {\n return false;\n }\n await waitForJob(setup.id);\n await api(\"DELETE\", target);\n return;\n }\n const parts = [];\n if (running.length) parts.push(running.length + \" running review(s)\");\n if (detail.queued) parts.push(detail.queued + \" queued job(s)\");\n if (!confirm(\"Stop \" + parts.join(\" and \") + \" and clear?\")) return false;\n try {\n await api(\"DELETE\", target + retry + \"onRunning=abort\");\n } catch (retryError) {\n // A setup job was claimed between the first 409 and this retry. It\n // cannot be aborted, so wait it out like the branch above.\n if (retryError.code !== \"job_not_abortable\") throw retryError;\n const jobId = retryError.detail && retryError.detail.id;\n if (jobId) await waitForJob(jobId);\n await api(\"DELETE\", target);\n }\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
@@ -13,7 +13,7 @@ export function renderKnowledge(username, data, viewId) {
13
13
  ? `<div class="notice"><div class="txt">Built ${when(data.repo.knowledge_built_at)}.${drift
14
14
  ? ` Since then: <b>${drift.prs_since} new pull requests</b>, <b>${drift.prs_updated} changed pull requests</b>, <b>${capped(drift.commits_since, MAX_COMMITS_COUNTED)} new commits</b> and <b>${capped(drift.files_changed, COMPARE_FILE_CAP)} changed files</b>. <span class="muted">Checked ${when(drift.as_of)}.</span>`
15
15
  : ""}</div></div>`
16
- : `<div class="notice info"><div class="txt">Knowledge has not been built yet. Run init from the repository list.</div></div>`;
16
+ : `<div class="notice info"><div class="txt">Knowledge has not been built yet. Use Sync to build it.</div></div>`;
17
17
  const viewer = viewing
18
18
  ? `<div class="card"><div class="hd"><h2>${text(viewing.title)}</h2>
19
19
  <a class="btn sm" style="margin-left:auto" href="/repos/${text(name)}/knowledge">Close</a></div>
@@ -53,12 +53,31 @@ export function renderKnowledge(username, data, viewId) {
53
53
  <div class="hd"><h2>History</h2></div>
54
54
  <div class="bd">${empty("Only the current knowledge is kept", "Older versions are not stored yet.")}</div>
55
55
  </div>
56
+ <div class="card" data-async>
57
+ ${skSlot()}
58
+ <div class="hd"><h2>Clear</h2></div>
59
+ <div class="bd">
60
+ <p class="muted" style="margin:0 0 16px">Delete the guides and the saved settings for this repository. The next Sync builds them from scratch.</p>
61
+ <div class="field wide"><label><input type="checkbox" id="clear-cache"> Also delete the cached evidence: pull request listings, costs, clones and worktrees</label>
62
+ <div class="hint">Without it the next build still reuses the files and pull requests it already paid for.</div></div>
63
+ </div>
64
+ <div class="ft"><button id="clear" style="color:var(--red)">Clear knowledge</button></div>
65
+ </div>
56
66
  </div>
57
67
  </div>
58
68
  <script>
59
69
  document.getElementById("update").addEventListener("click", function() {
60
70
  postAndGo("/api/repos/" + ${JSON.stringify(name)} + "/remake", {}, "/activity", this);
61
71
  });
72
+ document.getElementById("clear").addEventListener("click", function() {
73
+ if (!confirm("Delete the guides and saved settings for this repository?")) return;
74
+ var includeCache = document.getElementById("clear-cache").checked;
75
+ var btn = this;
76
+ run(btn, btn.closest("[data-async]"), async function() {
77
+ var done = await clearKnowledge(${JSON.stringify(`/api/repos/${name}/knowledge`)}, includeCache);
78
+ if (done !== false) location.reload();
79
+ });
80
+ });
62
81
  </script>`,
63
82
  }));
64
83
  }
@@ -239,6 +239,15 @@ Leave blank to keep the current key"></textarea></div>
239
239
  </div>
240
240
  <div class="ft"><button class="primary" id="save-password">Change password</button></div>
241
241
  </div>
242
+ <div class="card" id="danger" data-async>
243
+ ${skSlot()}
244
+ <div class="hd"><h2>Clear all knowledge</h2></div>
245
+ <div class="bd">
246
+ <p class="muted" style="margin:0 0 16px">Delete every repository's guides and saved settings on this server. Repository records, reviews and tokens stay, so this is not an uninstall.</p>
247
+ <div class="field wide"><label><input type="checkbox" id="clear-all-cache"> Also delete the cached evidence: pull request listings, costs, clones and worktrees</label></div>
248
+ </div>
249
+ <div class="ft"><button id="clear-all" style="color:var(--red)">Clear all knowledge</button></div>
250
+ </div>
242
251
  <div class="card" id="about">
243
252
  <div class="hd"><h2>About</h2></div>
244
253
  <div class="bd">
@@ -501,6 +510,15 @@ document.getElementById("save-def").addEventListener("click", function() {
501
510
  if (policy !== "__custom") body.reviewPolicy = policy === "" ? null : policy;
502
511
  save(this, body);
503
512
  });
513
+ document.getElementById("clear-all").addEventListener("click", function() {
514
+ if (!confirm("Delete the guides and saved settings for every repository on this server?")) return;
515
+ var includeCache = document.getElementById("clear-all-cache").checked;
516
+ var btn = this;
517
+ run(btn, btn.closest("[data-async]"), async function() {
518
+ var done = await clearKnowledge("/api/knowledge", includeCache);
519
+ if (done !== false) location.reload();
520
+ });
521
+ });
504
522
  </script>`,
505
523
  }));
506
524
  }
@@ -0,0 +1,70 @@
1
+ import type { JobRow } from "../store/rows.ts";
2
+ /** Job cancel reasons, so the Activity page can say who asked. */
3
+ export declare const CLEARED_REASON = "knowledge_cleared";
4
+ export type ClearOptions = {
5
+ /** Also drop the fetched evidence (pull request listings, AI job cache,
6
+ * costs, local review carry-over, clones, worktrees). Without it the next
7
+ * init still finds what it paid for and only the knowledge is gone. */
8
+ includeCache?: boolean;
9
+ };
10
+ export type ClearRepoResult = {
11
+ repo: string;
12
+ guides: number;
13
+ cacheRows: number;
14
+ settings: boolean;
15
+ clones: boolean;
16
+ worktrees: boolean;
17
+ };
18
+ export type ClearAllResult = {
19
+ repos: ClearRepoResult[];
20
+ settings: boolean;
21
+ cacheFile: boolean;
22
+ clones: boolean;
23
+ worktrees: boolean;
24
+ };
25
+ export declare function isSafeRepoName(repo: string): boolean;
26
+ /** Opens app.db for the work when it already exists, and never creates one:
27
+ * a repository that was never inited has nothing to clear there. Reuses an
28
+ * open connection (the dashboard) and closes one it opened itself. */
29
+ export declare function withAppDbIfPresent<T>(fn: () => T | Promise<T>): Promise<T | undefined>;
30
+ /** The repositories `clear all` should visit: the guide directories under
31
+ * `reposDir()` plus everything `config.json` still remembers. Names that
32
+ * cannot be joined onto a path are dropped: `config.json` is editable by
33
+ * hand, and a key like `acme/..` would otherwise resolve to the repos root
34
+ * and take every repository with it. */
35
+ export declare function listStoredRepos(): Promise<string[]>;
36
+ /** What queued and running jobs exist for these repositories. Empty when
37
+ * app.db is not open, which is the same as "no jobs to worry about". */
38
+ export declare function repoJobs(repos: string[]): {
39
+ queued: JobRow[];
40
+ running: JobRow[];
41
+ };
42
+ /** Cancels queued jobs so a cleared repository does not rebuild itself from
43
+ * the queue. Running jobs are the caller's problem: reviews can be aborted,
44
+ * a running setup cannot (its fetch loops ignore the signal). */
45
+ export declare function cancelQueuedJobs(repos: string[], reason?: string): number;
46
+ /** Aborts running reviews. They never write guides, so clearing while one
47
+ * runs is safe either way; aborting stops the AI spend too. */
48
+ export declare function abortRunningReviews(repos: string[], reason?: string): number;
49
+ /** Running rows left behind by a crashed `serve`. Only safe when the caller
50
+ * holds the app.db lock, which the CLI guard guarantees. */
51
+ export declare function cancelOrphanedJobs(repos: string[], reason?: string): number;
52
+ /** Forgets one repository: knowledge, guides, cache (when asked) and the
53
+ * settings `init` remembered. */
54
+ export declare function clearRepo(repo: string, options?: ClearOptions): Promise<ClearRepoResult>;
55
+ /** Forgets exactly the repositories it is given. The dashboard route and the
56
+ * CLI pass the same snapshot they negotiated jobs against, so the list cannot
57
+ * move between the check and the delete. The app.db file and its review
58
+ * history stay: only the knowledge is gone. `--include-cache` takes the whole
59
+ * cache database plus every clone and worktree, the same as deleting
60
+ * `cache.db` by hand. */
61
+ export declare function clearRepos(repos: string[], options?: ClearOptions): Promise<ClearAllResult>;
62
+ /** Forgets every repository on this machine, from a fresh snapshot. */
63
+ export declare function clearAllKnowledge(options?: ClearOptions): Promise<ClearAllResult>;
64
+ export type UninstallResult = {
65
+ paths: string[];
66
+ };
67
+ /** Removes every co-maintainer data file. The npm package itself cannot be
68
+ * removed from inside the running CLI, so the command prints how to do that
69
+ * instead. Refuses nothing here; the caller checks for a running `serve`. */
70
+ export declare function uninstallData(): Promise<UninstallResult>;
@@ -0,0 +1,310 @@
1
+ /** Deleting what `init` produced, for the CLI's `clear`/`uninstall` and the
2
+ * dashboard's clear actions.
3
+ *
4
+ * The dashboard and the CLI share this module on purpose: the same data has to
5
+ * disappear whether a command or a button asked for it. The callers keep what
6
+ * differs instead: the CLI owns the `serve` lock guard, its prompts and its
7
+ * output, the dashboard route owns job negotiation before calling in.
8
+ *
9
+ * Deleting is ordered so an interruption never leaves the friendlier state:
10
+ * the app.db knowledge stamp goes first, then the guide files, then the
11
+ * rebuildable cache. An error partway leaves a repository that reads as "not
12
+ * built", which the next `init` fixes. */
13
+ import { cacheDbPath, cloneDir, clonesDir, configPath, getCacheDir, getConfigDir, locksDir, readConfig, removeAllRepoConfig, removeRepoConfig, repoWorktreesDir, reposDir, toolsDir, worktreesDir, } from "../config.js";
14
+ import { withKnowledgeLock } from "../review/guides.js";
15
+ import { appDbLockPath, appDbPath, backupPaths, closeAppDb, isAppDbOpen, openAppDb, } from "../store/app_db.js";
16
+ import { cacheDbExists, cacheDeleteRepoEvidence, cacheDeleteRepoKnowledge, } from "../store/cache_db.js";
17
+ import { clearRepoKnowledge } from "../store/repos.js";
18
+ import { listJobs, setJobStatus } from "../store/jobs.js";
19
+ import { cancel } from "./jobs.js";
20
+ import { isNotFound, readDir, remove, rmdir, stat } from "../util/runtime.js";
21
+ /** Job cancel reasons, so the Activity page can say who asked. */
22
+ export const CLEARED_REASON = "knowledge_cleared";
23
+ /** A repository name that is safe to join onto a directory. Rejects the `..`
24
+ * segment the API's route pattern would otherwise accept. */
25
+ const REPO_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
26
+ export function isSafeRepoName(repo) {
27
+ if (!REPO_NAME.test(repo))
28
+ return false;
29
+ return repo.split("/").every((part) => part !== "." && part !== "..");
30
+ }
31
+ function assertSafeRepoName(repo) {
32
+ if (!isSafeRepoName(repo)) {
33
+ throw new Error(`Not a repository name: ${repo}`);
34
+ }
35
+ }
36
+ async function pathExists(path) {
37
+ try {
38
+ await stat(path);
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ if (isNotFound(error))
43
+ return false;
44
+ throw error;
45
+ }
46
+ }
47
+ async function removeIfExists(path, options) {
48
+ if (!(await pathExists(path)))
49
+ return false;
50
+ await remove(path, options);
51
+ return true;
52
+ }
53
+ async function countEntries(dir) {
54
+ let count = 0;
55
+ try {
56
+ for await (const _entry of readDir(dir))
57
+ count++;
58
+ }
59
+ catch (error) {
60
+ if (!isNotFound(error))
61
+ throw error;
62
+ }
63
+ return count;
64
+ }
65
+ /** Opens app.db for the work when it already exists, and never creates one:
66
+ * a repository that was never inited has nothing to clear there. Reuses an
67
+ * open connection (the dashboard) and closes one it opened itself. */
68
+ export async function withAppDbIfPresent(fn) {
69
+ if (isAppDbOpen())
70
+ return await fn();
71
+ if (!(await pathExists(appDbPath())))
72
+ return undefined;
73
+ await openAppDb();
74
+ try {
75
+ return await fn();
76
+ }
77
+ finally {
78
+ await closeAppDb();
79
+ }
80
+ }
81
+ /** The repositories `clear all` should visit: the guide directories under
82
+ * `reposDir()` plus everything `config.json` still remembers. Names that
83
+ * cannot be joined onto a path are dropped: `config.json` is editable by
84
+ * hand, and a key like `acme/..` would otherwise resolve to the repos root
85
+ * and take every repository with it. */
86
+ export async function listStoredRepos() {
87
+ const repos = new Set();
88
+ try {
89
+ for await (const owner of readDir(reposDir())) {
90
+ if (!owner.isDirectory || owner.isSymlink)
91
+ continue;
92
+ try {
93
+ for await (const child of readDir(`${reposDir()}/${owner.name}`)) {
94
+ if (child.isDirectory && !child.isSymlink) {
95
+ repos.add(`${owner.name}/${child.name}`);
96
+ }
97
+ }
98
+ }
99
+ catch (error) {
100
+ if (!isNotFound(error))
101
+ throw error;
102
+ }
103
+ }
104
+ }
105
+ catch (error) {
106
+ if (!isNotFound(error))
107
+ throw error;
108
+ }
109
+ for (const repo of Object.keys(readConfig().repos ?? {}))
110
+ repos.add(repo);
111
+ return [...repos].filter(isSafeRepoName).sort();
112
+ }
113
+ /** Guide files live in one directory per repository. The count is taken
114
+ * before the removal so the caller can report what went. An owner directory
115
+ * left empty by its last repository is pruned: it is a leftover, not data. */
116
+ async function removeRepoGuides(repo) {
117
+ let removed = 0;
118
+ await withKnowledgeLock(repo, async () => {
119
+ const dir = `${reposDir()}/${repo}`;
120
+ removed = await countEntries(dir);
121
+ if (removed > 0)
122
+ await remove(dir, { recursive: true });
123
+ else
124
+ await removeIfExists(dir, { recursive: true });
125
+ await rmdir(dir.slice(0, dir.lastIndexOf("/"))).catch(() => { });
126
+ });
127
+ return removed;
128
+ }
129
+ /** `sync` used to keep its state as `.cache/<repo>/state.json` beside the
130
+ * working directory, and `readState` still falls back to it. Leaving it
131
+ * behind would let a `sync` run after a clear, which clear promises cannot
132
+ * happen. Best effort: it only exists on machines that ran the old layout. */
133
+ async function removeLegacyState(repo) {
134
+ await removeIfExists(`${process.cwd()}/.cache/${repo}`, { recursive: true });
135
+ }
136
+ async function clearRepoCache(repo, includeCache) {
137
+ if (!(await cacheDbExists()))
138
+ return 0;
139
+ let removed = await cacheDeleteRepoKnowledge(repo);
140
+ if (includeCache)
141
+ removed += await cacheDeleteRepoEvidence(repo);
142
+ return removed;
143
+ }
144
+ /** What queued and running jobs exist for these repositories. Empty when
145
+ * app.db is not open, which is the same as "no jobs to worry about". */
146
+ export function repoJobs(repos) {
147
+ if (!isAppDbOpen())
148
+ return { queued: [], running: [] };
149
+ const wanted = new Set(repos);
150
+ const queued = [];
151
+ const running = [];
152
+ for (const job of listJobs({})) {
153
+ if (!wanted.has(job.repo))
154
+ continue;
155
+ if (job.status === "queued")
156
+ queued.push(job);
157
+ else if (job.status === "running")
158
+ running.push(job);
159
+ }
160
+ return { queued, running };
161
+ }
162
+ /** Cancels queued jobs so a cleared repository does not rebuild itself from
163
+ * the queue. Running jobs are the caller's problem: reviews can be aborted,
164
+ * a running setup cannot (its fetch loops ignore the signal). */
165
+ export function cancelQueuedJobs(repos, reason = CLEARED_REASON) {
166
+ if (!isAppDbOpen())
167
+ return 0;
168
+ let canceled = 0;
169
+ for (const job of repoJobs(repos).queued) {
170
+ if (cancel(job.id, reason))
171
+ canceled++;
172
+ }
173
+ return canceled;
174
+ }
175
+ /** Aborts running reviews. They never write guides, so clearing while one
176
+ * runs is safe either way; aborting stops the AI spend too. */
177
+ export function abortRunningReviews(repos, reason = CLEARED_REASON) {
178
+ if (!isAppDbOpen())
179
+ return 0;
180
+ let aborted = 0;
181
+ for (const job of repoJobs(repos).running) {
182
+ if (job.type !== "review" && job.type !== "remote_review")
183
+ continue;
184
+ if (cancel(job.id, reason))
185
+ aborted++;
186
+ }
187
+ return aborted;
188
+ }
189
+ /** Running rows left behind by a crashed `serve`. Only safe when the caller
190
+ * holds the app.db lock, which the CLI guard guarantees. */
191
+ export function cancelOrphanedJobs(repos, reason = CLEARED_REASON) {
192
+ if (!isAppDbOpen())
193
+ return 0;
194
+ let canceled = 0;
195
+ for (const job of repoJobs(repos).running) {
196
+ setJobStatus(job.id, "canceled", { cancel_reason: reason });
197
+ canceled++;
198
+ }
199
+ return canceled;
200
+ }
201
+ /** Forgets one repository: knowledge, guides, cache (when asked) and the
202
+ * settings `init` remembered. */
203
+ export async function clearRepo(repo, options = {}) {
204
+ assertSafeRepoName(repo);
205
+ await withAppDbIfPresent(() => clearRepoKnowledge(repo));
206
+ const guides = await removeRepoGuides(repo);
207
+ const cacheRows = await clearRepoCache(repo, Boolean(options.includeCache));
208
+ await removeLegacyState(repo);
209
+ const settings = await removeRepoConfig(repo);
210
+ const clones = options.includeCache
211
+ ? await removeIfExists(cloneDir(repo), { recursive: true })
212
+ : false;
213
+ const worktrees = options.includeCache
214
+ ? await removeIfExists(repoWorktreesDir(repo), { recursive: true })
215
+ : false;
216
+ return { repo, guides, cacheRows, settings, clones, worktrees };
217
+ }
218
+ /** Forgets exactly the repositories it is given. The dashboard route and the
219
+ * CLI pass the same snapshot they negotiated jobs against, so the list cannot
220
+ * move between the check and the delete. The app.db file and its review
221
+ * history stay: only the knowledge is gone. `--include-cache` takes the whole
222
+ * cache database plus every clone and worktree, the same as deleting
223
+ * `cache.db` by hand. */
224
+ export async function clearRepos(repos, options = {}) {
225
+ for (const repo of repos)
226
+ assertSafeRepoName(repo);
227
+ const results = [];
228
+ await withAppDbIfPresent(() => {
229
+ for (const repo of repos)
230
+ clearRepoKnowledge(repo);
231
+ });
232
+ for (const repo of repos) {
233
+ const guides = await removeRepoGuides(repo);
234
+ const cacheRows = options.includeCache
235
+ ? 0
236
+ : await clearRepoCache(repo, false);
237
+ await removeLegacyState(repo);
238
+ results.push({
239
+ repo,
240
+ guides,
241
+ cacheRows,
242
+ settings: false,
243
+ clones: false,
244
+ worktrees: false,
245
+ });
246
+ }
247
+ const cacheFile = options.includeCache
248
+ ? await removeIfExists(cacheDbPath())
249
+ : false;
250
+ const clones = options.includeCache
251
+ ? await removeIfExists(clonesDir(), { recursive: true })
252
+ : false;
253
+ const worktrees = options.includeCache
254
+ ? await removeIfExists(worktreesDir(), { recursive: true })
255
+ : false;
256
+ const settings = await removeAllRepoConfig();
257
+ return { repos: results, settings, cacheFile, clones, worktrees };
258
+ }
259
+ /** Forgets every repository on this machine, from a fresh snapshot. */
260
+ export async function clearAllKnowledge(options = {}) {
261
+ return await clearRepos(await listStoredRepos(), options);
262
+ }
263
+ /** Every file and directory co-maintainer keeps on this machine. The `tools`
264
+ * directory (the codegraph install) goes too: uninstall means the disk is
265
+ * back to what it was before the first `init`. */
266
+ function uninstallTargets() {
267
+ const appDb = appDbPath();
268
+ const cacheDb = cacheDbPath();
269
+ return [
270
+ configPath(),
271
+ cacheDb,
272
+ `${cacheDb}-journal`,
273
+ appDb,
274
+ `${appDb}-wal`,
275
+ `${appDb}-shm`,
276
+ appDbLockPath(),
277
+ backupPaths().version,
278
+ reposDir(),
279
+ clonesDir(),
280
+ worktreesDir(),
281
+ locksDir(),
282
+ toolsDir(),
283
+ backupPaths().root,
284
+ ];
285
+ }
286
+ /** Removes every co-maintainer data file. The npm package itself cannot be
287
+ * removed from inside the running CLI, so the command prints how to do that
288
+ * instead. Refuses nothing here; the caller checks for a running `serve`. */
289
+ export async function uninstallData() {
290
+ // On Windows an open handle blocks the delete, and leaving wal/shm behind
291
+ // would be worse than the file itself.
292
+ if (isAppDbOpen())
293
+ await closeAppDb();
294
+ const removed = [];
295
+ for (const target of uninstallTargets()) {
296
+ if (await removeIfExists(target, { recursive: true })) {
297
+ removed.push(target);
298
+ }
299
+ }
300
+ // The containing `co-maintainer` directories only survive if something
301
+ // else put files there. `rmdir` refuses a non-empty directory, which is
302
+ // what we want.
303
+ for (const dir of [
304
+ `${getConfigDir()}/co-maintainer`,
305
+ `${getCacheDir()}/co-maintainer`,
306
+ ]) {
307
+ await rmdir(dir).catch(() => { });
308
+ }
309
+ return { paths: removed };
310
+ }
@@ -3,6 +3,9 @@ import { type BackupPaths } from "./backup.ts";
3
3
  export declare function appDbDir(): string;
4
4
  /** `CM_APP_DB` overrides the path — tests point it at a temp file. */
5
5
  export declare function appDbPath(): string;
6
+ /** The lock file on its own, so `uninstall` deletes it with the database
7
+ * instead of leaving a pid file that names a process that no longer exists. */
8
+ export declare function appDbLockPath(): string;
6
9
  /** Where the backup taken before a migration lives, and what it covers. */
7
10
  export declare function backupPaths(): BackupPaths;
8
11
  /** The pid holding the database lock, or `undefined` when nothing live does. */