akm-cli 0.10.261005-beta → 0.10.261007-beta
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/CHANGELOG.md +65 -0
- package/STABILITY.md +9 -0
- package/dist/assets/ui/app.js +91 -0
- package/dist/assets/ui/asset.js +67 -0
- package/dist/assets/ui/bundles.js +80 -0
- package/dist/assets/ui/changes.js +32 -0
- package/dist/assets/ui/charts.js +93 -0
- package/dist/assets/ui/config.js +69 -0
- package/dist/assets/ui/diff-view.js +202 -0
- package/dist/assets/ui/diff.js +261 -0
- package/dist/assets/ui/index.html +30 -0
- package/dist/assets/ui/lib.js +238 -0
- package/dist/assets/ui/markdown.js +103 -0
- package/dist/assets/ui/overview.js +145 -0
- package/dist/assets/ui/proposal-actions.js +273 -0
- package/dist/assets/ui/proposals.js +219 -0
- package/dist/assets/ui/search.js +62 -0
- package/dist/assets/ui/styles.css +1415 -0
- package/dist/cli.js +3 -0
- package/dist/commands/health/checks.js +5 -3
- package/dist/commands/health/egress.js +5 -1
- package/dist/commands/health/engine-usage.js +2 -2
- package/dist/commands/health/llm-usage.js +2 -2
- package/dist/commands/health/windows.js +5 -5
- package/dist/commands/health.js +40 -8
- package/dist/commands/improve/reflect-noise.js +4 -0
- package/dist/commands/improve/reflect.js +14 -5
- package/dist/commands/metrics/metrics-cli.js +3 -3
- package/dist/commands/proposal/propose-cli.js +5 -0
- package/dist/commands/proposal/propose.js +6 -2
- package/dist/commands/proposal/repository.js +34 -29
- package/dist/commands/read/search.js +2 -1
- package/dist/commands/read/show.js +18 -12
- package/dist/commands/sources/source-clone.js +123 -11
- package/dist/commands/ui/api.js +556 -0
- package/dist/commands/ui/git-read.js +101 -0
- package/dist/commands/ui/open-browser.js +30 -0
- package/dist/commands/ui/proposal-actions.js +179 -0
- package/dist/commands/ui/redact.js +88 -0
- package/dist/commands/ui/server.js +352 -0
- package/dist/commands/ui/ui-cli.js +62 -0
- package/dist/core/asset/resolve-ref.js +7 -3
- package/dist/indexer/indexer.js +14 -2
- package/dist/indexer/search/db-search.js +7 -3
- package/dist/output/format-exempt.js +3 -0
- package/dist/storage/repositories/improve-runs-repository.js +6 -0
- package/dist/storage/repositories/index-db.js +10 -2
- package/dist/storage/repositories/metrics-repository.js +17 -0
- package/docs/architecture/0.10-process-rules.md +3 -4
- package/docs/architecture/improvement.md +2 -1
- package/docs/architecture/internals/improve-workflow.md +2 -2
- package/docs/reference/README.md +1 -0
- package/docs/reference/cli.md +68 -0
- package/docs/reference/ui-api.md +576 -0
- package/package.json +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* The read-only JSON API behind `akm ui`. (The three proposal writes live in
|
|
6
|
+
* `proposal-actions.ts`; nothing in this file writes.) Every handler calls the function the
|
|
7
|
+
* matching CLI command renders (`akmHealth`, `akmMetrics`, `akmSearch`,
|
|
8
|
+
* `akmShowUnified`, the proposal repository, ...), so the UI and the CLI never
|
|
9
|
+
* disagree and there is no second data layer. Documented in
|
|
10
|
+
* `docs/reference/ui-api.md`.
|
|
11
|
+
*
|
|
12
|
+
* Read-only is enforced here, not hoped for: `state.db` and `index.db` are
|
|
13
|
+
* opened read-only and never created or migrated, search and show run with
|
|
14
|
+
* `readOnly` (no usage events, no index build), and git is only queried.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { parsePositiveIntFlag } from "../../cli/parse-args.js";
|
|
18
|
+
import { parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
19
|
+
import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
20
|
+
import { resolveStashDir } from "../../core/common.js";
|
|
21
|
+
import { getConfigValueSource, loadConfig } from "../../core/config/config.js";
|
|
22
|
+
import { NotFoundError, UsageError } from "../../core/errors.js";
|
|
23
|
+
import { getConfigPath, getDataDir, getDbPath } from "../../core/paths.js";
|
|
24
|
+
import { getStateDbPath, listPendingStateMigrations } from "../../core/state-db.js";
|
|
25
|
+
import { DURATION_UNITS, parseDuration } from "../../core/time.js";
|
|
26
|
+
import { resolveWriteTarget } from "../../core/write-source.js";
|
|
27
|
+
import { openDatabase } from "../../storage/database.js";
|
|
28
|
+
import { listRecentImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
|
|
29
|
+
import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
|
|
30
|
+
import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
|
|
31
|
+
import { getEmbeddingCount } from "../../storage/repositories/index-vec-repository.js";
|
|
32
|
+
import { countFeedbackBySignal, listFeedbackEventRows, listUsageEventRows, } from "../../storage/repositories/metrics-repository.js";
|
|
33
|
+
import { listStateProposals } from "../../storage/repositories/proposals-repository.js";
|
|
34
|
+
import { applyReadonlyPragmas } from "../../storage/sqlite-pragmas.js";
|
|
35
|
+
import { pkgVersion } from "../../version.js";
|
|
36
|
+
import { listConfig } from "../config-cli.js";
|
|
37
|
+
import { akmHealth } from "../health.js";
|
|
38
|
+
import { computeAcceptRateBySource } from "../health/accept-rate.js";
|
|
39
|
+
import { akmMetrics } from "../metrics/metrics-cli.js";
|
|
40
|
+
import { isRetireProposal, proposalWaitingSince } from "../proposal/proposal-types.js";
|
|
41
|
+
import { diffLoadedProposal, resolveProposalIdIn } from "../proposal/repository.js";
|
|
42
|
+
import { validateProposal } from "../proposal/validators/proposals.js";
|
|
43
|
+
import { akmSearch } from "../read/search.js";
|
|
44
|
+
import { akmShowUnified } from "../read/show.js";
|
|
45
|
+
import { assembleInfo } from "../sources/info.js";
|
|
46
|
+
import { akmListSources } from "../sources/installed-stashes.js";
|
|
47
|
+
import { readBundleGitStatus, readRecentCommits } from "./git-read.js";
|
|
48
|
+
import { isSecretEnvName, redactForUi } from "./redact.js";
|
|
49
|
+
const DEFAULT_LIST_LIMIT = 50;
|
|
50
|
+
const MAX_LIST_LIMIT = 200;
|
|
51
|
+
const IMPROVE_RUNS_SHOWN = 10;
|
|
52
|
+
const USAGE_DAYS = 14;
|
|
53
|
+
const DAY_MS = 86_400_000;
|
|
54
|
+
// ── Parameters ───────────────────────────────────────────────────────────────
|
|
55
|
+
function limitParam(query, fallback = DEFAULT_LIST_LIMIT) {
|
|
56
|
+
const raw = query.get("limit");
|
|
57
|
+
if (raw === null || raw === "")
|
|
58
|
+
return fallback;
|
|
59
|
+
const parsed = parsePositiveIntFlag(raw, "limit");
|
|
60
|
+
if (parsed === undefined)
|
|
61
|
+
return fallback;
|
|
62
|
+
return Math.min(parsed, MAX_LIST_LIMIT);
|
|
63
|
+
}
|
|
64
|
+
function flag(query, name) {
|
|
65
|
+
const raw = query.get(name);
|
|
66
|
+
return raw === "1" || raw === "true";
|
|
67
|
+
}
|
|
68
|
+
// ── Read-only databases ──────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Run `fn` against `state.db` opened read-only, or return `fallback` when it
|
|
71
|
+
* does not exist or is behind on migrations (a read-only open must not apply
|
|
72
|
+
* them; `akm health` reports that state). The handle is closed before returning,
|
|
73
|
+
* so no request holds a lock past its own read.
|
|
74
|
+
*/
|
|
75
|
+
function withReadonlyStateDb(fn, fallback) {
|
|
76
|
+
const dbPath = getStateDbPath();
|
|
77
|
+
if (!fs.existsSync(dbPath) || listPendingStateMigrations(dbPath).length > 0)
|
|
78
|
+
return fallback;
|
|
79
|
+
const db = openDatabase(dbPath, { readonly: true, create: false });
|
|
80
|
+
try {
|
|
81
|
+
applyReadonlyPragmas(db);
|
|
82
|
+
return fn(db);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function tryStashDir() {
|
|
89
|
+
try {
|
|
90
|
+
return resolveStashDir();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Run each section of a composite response on its own: one failing store degrades one section, not the page. */
|
|
97
|
+
async function section(errors, name, fn) {
|
|
98
|
+
try {
|
|
99
|
+
return await fn();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
errors[name] = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── /api/meta ────────────────────────────────────────────────────────────────
|
|
107
|
+
function meta(_query, info) {
|
|
108
|
+
return {
|
|
109
|
+
version: pkgVersion,
|
|
110
|
+
runtime: process.versions.bun ? "bun" : "node",
|
|
111
|
+
stashDir: tryStashDir(),
|
|
112
|
+
dataDir: getDataDir(),
|
|
113
|
+
configPath: getConfigPath(),
|
|
114
|
+
stateDbPath: getStateDbPath(),
|
|
115
|
+
indexDbPath: getDbPath(),
|
|
116
|
+
startedAt: info.startedAt,
|
|
117
|
+
host: info.host,
|
|
118
|
+
port: info.port,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ── /api/overview ────────────────────────────────────────────────────────────
|
|
122
|
+
/** One persisted improve run, projected to what the overview shows. Tolerant: a row from an older release just has fewer fields. */
|
|
123
|
+
function summarizeImproveRun(row) {
|
|
124
|
+
let result = {};
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(row.result_json);
|
|
127
|
+
if (parsed && typeof parsed === "object")
|
|
128
|
+
result = parsed;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// An unparseable result still has a row worth listing.
|
|
132
|
+
}
|
|
133
|
+
const byProcess = {};
|
|
134
|
+
const usage = result.usageReport;
|
|
135
|
+
for (const entry of Array.isArray(usage?.byProcessEngineModel) ? usage.byProcessEngineModel : []) {
|
|
136
|
+
const { process, calls, failures } = entry;
|
|
137
|
+
if (!process)
|
|
138
|
+
continue;
|
|
139
|
+
const bucket = byProcess[process] ?? { calls: 0, failures: 0 };
|
|
140
|
+
bucket.calls += calls ?? 0;
|
|
141
|
+
bucket.failures += failures ?? 0;
|
|
142
|
+
byProcess[process] = bucket;
|
|
143
|
+
}
|
|
144
|
+
const started = Date.parse(row.started_at);
|
|
145
|
+
const completed = Date.parse(row.completed_at);
|
|
146
|
+
return {
|
|
147
|
+
id: row.id,
|
|
148
|
+
startedAt: row.started_at,
|
|
149
|
+
completedAt: row.completed_at,
|
|
150
|
+
ok: row.ok === 1,
|
|
151
|
+
durationMs: Number.isFinite(started) && Number.isFinite(completed) && completed >= started ? completed - started : null,
|
|
152
|
+
strategy: row.strategy,
|
|
153
|
+
scope: { mode: row.scope_mode, ...(row.scope_value ? { value: row.scope_value } : {}) },
|
|
154
|
+
modelCalls: {
|
|
155
|
+
total: Object.values(byProcess).reduce((sum, bucket) => sum + bucket.calls, 0),
|
|
156
|
+
failures: Object.values(byProcess).reduce((sum, bucket) => sum + bucket.failures, 0),
|
|
157
|
+
byProcess,
|
|
158
|
+
},
|
|
159
|
+
preRunSync: result.preRunSync ?? null,
|
|
160
|
+
sync: result.sync ?? null,
|
|
161
|
+
triage: result.triage ?? null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** User-attributed search / curate / show / feedback counts per UTC day for the last {@link USAGE_DAYS} days, oldest first. */
|
|
165
|
+
function usageByDay(db, nowMs) {
|
|
166
|
+
const days = new Map();
|
|
167
|
+
for (let i = USAGE_DAYS - 1; i >= 0; i--) {
|
|
168
|
+
const date = new Date(nowMs - i * DAY_MS).toISOString().slice(0, 10);
|
|
169
|
+
days.set(date, { search: 0, curate: 0, show: 0, feedbackPositive: 0, feedbackNegative: 0 });
|
|
170
|
+
}
|
|
171
|
+
const first = [...days.keys()][0];
|
|
172
|
+
const rows = listUsageEventRows(db, {
|
|
173
|
+
sinceIso: `${first}T00:00:00.000Z`,
|
|
174
|
+
untilIso: new Date(nowMs).toISOString(),
|
|
175
|
+
source: "user",
|
|
176
|
+
});
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
// `created_at` is `YYYY-MM-DD HH:MM:SS` (UTC).
|
|
179
|
+
const bucket = days.get(row.created_at.slice(0, 10));
|
|
180
|
+
if (!bucket)
|
|
181
|
+
continue;
|
|
182
|
+
if (row.event_type === "feedback") {
|
|
183
|
+
if (row.signal === "positive")
|
|
184
|
+
bucket.feedbackPositive = (bucket.feedbackPositive ?? 0) + 1;
|
|
185
|
+
else if (row.signal === "negative")
|
|
186
|
+
bucket.feedbackNegative = (bucket.feedbackNegative ?? 0) + 1;
|
|
187
|
+
}
|
|
188
|
+
else if (row.event_type === "search" || row.event_type === "curate" || row.event_type === "show") {
|
|
189
|
+
bucket[row.event_type] = (bucket[row.event_type] ?? 0) + 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return [...days.entries()].map(([date, counts]) => ({ date, ...counts }));
|
|
193
|
+
}
|
|
194
|
+
async function overview(query) {
|
|
195
|
+
const errors = {};
|
|
196
|
+
const since = query.get("since") || "24h";
|
|
197
|
+
// The comparison window is the report window, like `akm health --report`; an absolute `since` has no duration to compare.
|
|
198
|
+
const sinceIsDuration = parseDuration(since, DURATION_UNITS) !== null;
|
|
199
|
+
const health = await section(errors, "health", async () => {
|
|
200
|
+
const base = await akmHealth({
|
|
201
|
+
since,
|
|
202
|
+
groupBy: "run",
|
|
203
|
+
windowCompare: sinceIsDuration ? since : undefined,
|
|
204
|
+
probe: flag(query, "probe"),
|
|
205
|
+
readOnly: true,
|
|
206
|
+
});
|
|
207
|
+
const report = withReadonlyStateDb((db) => ({
|
|
208
|
+
window: since,
|
|
209
|
+
compare: sinceIsDuration ? since : null,
|
|
210
|
+
pendingProposals: listStateProposals(db, { stashDir: tryStashDir() ?? "", status: "pending" }).map(({ ref, source, createdAt }) => ({ ref, source, createdAt })),
|
|
211
|
+
acceptRateBySource: computeAcceptRateBySource(undefined, { db }),
|
|
212
|
+
}), null);
|
|
213
|
+
return { ...base, ...(report ? { report } : {}) };
|
|
214
|
+
});
|
|
215
|
+
const metrics = await section(errors, "metrics", () => akmMetrics({ since: query.get("metricsSince") || "30d", indexSnapshot: false }));
|
|
216
|
+
const stateReads = await section(errors, "state", () => withReadonlyStateDb((db) => ({
|
|
217
|
+
improveRuns: listRecentImproveRuns(db, IMPROVE_RUNS_SHOWN).map(summarizeImproveRun),
|
|
218
|
+
usageByDay: usageByDay(db, Date.now()),
|
|
219
|
+
}), null));
|
|
220
|
+
return {
|
|
221
|
+
health,
|
|
222
|
+
metrics,
|
|
223
|
+
improveRuns: stateReads?.improveRuns ?? [],
|
|
224
|
+
usageByDay: stateReads?.usageByDay ?? [],
|
|
225
|
+
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// ── /api/bundles ─────────────────────────────────────────────────────────────
|
|
229
|
+
async function bundles() {
|
|
230
|
+
const config = loadConfig();
|
|
231
|
+
const listing = await akmListSources({ stashDir: tryStashDir() ?? "" });
|
|
232
|
+
const cards = await Promise.all(listing.sources.map(async (source) => ({
|
|
233
|
+
name: source.name,
|
|
234
|
+
kind: source.kind,
|
|
235
|
+
default: source.default,
|
|
236
|
+
source: source.source,
|
|
237
|
+
path: source.path ?? null,
|
|
238
|
+
writable: source.writable,
|
|
239
|
+
version: source.version ?? null,
|
|
240
|
+
exists: source.status.exists,
|
|
241
|
+
itemCount: source.itemCount,
|
|
242
|
+
byType: source.byType,
|
|
243
|
+
git: source.path ? await readBundleGitStatus(source.path) : null,
|
|
244
|
+
})));
|
|
245
|
+
const info = assembleInfo();
|
|
246
|
+
let embeddingCount = null;
|
|
247
|
+
try {
|
|
248
|
+
const db = openReadonlyExistingDatabase(getDbPath());
|
|
249
|
+
if (db) {
|
|
250
|
+
try {
|
|
251
|
+
embeddingCount = getEmbeddingCount(db);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
closeDatabase(db);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// An index without an embeddings table (older layout) has none.
|
|
260
|
+
}
|
|
261
|
+
const entryCount = info.indexStats.entryCount;
|
|
262
|
+
return {
|
|
263
|
+
defaultBundle: config.defaultBundle ?? null,
|
|
264
|
+
defaultWriteTarget: config.defaultWriteTarget ?? null,
|
|
265
|
+
bundles: cards,
|
|
266
|
+
index: {
|
|
267
|
+
entryCount,
|
|
268
|
+
byType: info.indexStats.byType,
|
|
269
|
+
lastBuiltAt: info.indexStats.lastBuiltAt,
|
|
270
|
+
hasEmbeddings: info.indexStats.hasEmbeddings,
|
|
271
|
+
embeddingCount,
|
|
272
|
+
embeddingCoverage: embeddingCount !== null && entryCount > 0 ? Math.min(1, embeddingCount / entryCount) : null,
|
|
273
|
+
semanticSearch: info.semanticSearch,
|
|
274
|
+
...(info.indexStats.links ? { links: info.indexStats.links } : {}),
|
|
275
|
+
...(info.indexStats.unavailable ? { unavailable: info.indexStats.unavailable } : {}),
|
|
276
|
+
...(info.indexStats.unreadable ? { unreadable: info.indexStats.unreadable } : {}),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Flatten to dotted leaf paths. Arrays and empty objects are leaves, matching how config layers merge. */
|
|
281
|
+
function configLeaves(value, prefix, out) {
|
|
282
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0) {
|
|
283
|
+
for (const [key, child] of Object.entries(value))
|
|
284
|
+
configLeaves(child, [...prefix, key], out);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
out.push({ path: prefix, value });
|
|
288
|
+
}
|
|
289
|
+
function configSource(path) {
|
|
290
|
+
// `getConfigValueSource` takes a dotted path; a key that contains a dot cannot be told apart from two keys.
|
|
291
|
+
if (path.some((segment) => segment.includes(".")))
|
|
292
|
+
return null;
|
|
293
|
+
try {
|
|
294
|
+
const source = getConfigValueSource(path.join("."));
|
|
295
|
+
return source === "local" ? "file" : source;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function config() {
|
|
302
|
+
const effective = redactForUi(listConfig(loadConfig()));
|
|
303
|
+
const leaves = [];
|
|
304
|
+
configLeaves(effective, [], leaves);
|
|
305
|
+
const entries = leaves.map(({ path, value }) => ({
|
|
306
|
+
path: path.join("."),
|
|
307
|
+
value,
|
|
308
|
+
source: configSource(path),
|
|
309
|
+
}));
|
|
310
|
+
const engines = Object.entries((effective.engines ?? {})).map(([name, engine]) => ({
|
|
311
|
+
name,
|
|
312
|
+
...engine,
|
|
313
|
+
}));
|
|
314
|
+
const env = Object.keys(process.env)
|
|
315
|
+
.filter((name) => name.startsWith("AKM_"))
|
|
316
|
+
.sort()
|
|
317
|
+
.map((name) => ({ name, value: isSecretEnvName(name) ? "[redacted]" : (process.env[name] ?? "") }));
|
|
318
|
+
const configPath = getConfigPath();
|
|
319
|
+
return { path: configPath, exists: fs.existsSync(configPath), config: effective, entries, engines, env };
|
|
320
|
+
}
|
|
321
|
+
// ── /api/proposals ───────────────────────────────────────────────────────────
|
|
322
|
+
export function proposalSummary(proposal) {
|
|
323
|
+
const retirement = proposal.retirement;
|
|
324
|
+
return {
|
|
325
|
+
id: proposal.id,
|
|
326
|
+
source: proposal.source,
|
|
327
|
+
ref: proposal.ref,
|
|
328
|
+
status: proposal.status,
|
|
329
|
+
createdAt: proposal.createdAt,
|
|
330
|
+
updatedAt: proposal.updatedAt,
|
|
331
|
+
waitingSince: proposalWaitingSince(proposal),
|
|
332
|
+
confidence: proposal.confidence ?? null,
|
|
333
|
+
gateDecision: proposal.gateDecision ?? null,
|
|
334
|
+
review: proposal.review ?? null,
|
|
335
|
+
isRetire: isRetireProposal(proposal),
|
|
336
|
+
retirement: retirement
|
|
337
|
+
? {
|
|
338
|
+
retiredRef: retirement.retiredRef,
|
|
339
|
+
successorRef: retirement.successorRef,
|
|
340
|
+
reason: retirement.reason,
|
|
341
|
+
judgeLabel: retirement.judgeLabel,
|
|
342
|
+
judgeReason: retirement.judgeReason,
|
|
343
|
+
cosine: retirement.cosine,
|
|
344
|
+
onlyInRetired: retirement.onlyInRetired ?? [],
|
|
345
|
+
onlyInSuccessor: retirement.onlyInSuccessor ?? [],
|
|
346
|
+
continuityRisk: retirement.continuityRisk ?? null,
|
|
347
|
+
}
|
|
348
|
+
: null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const RECENT_STATUSES = new Set(["accepted", "rejected", "reverted"]);
|
|
352
|
+
function proposals(query) {
|
|
353
|
+
const status = query.get("status") || "pending";
|
|
354
|
+
if (status !== "pending" && status !== "recent") {
|
|
355
|
+
throw new UsageError(`Invalid status "${status}". Expected pending or recent.`, "INVALID_FLAG_VALUE");
|
|
356
|
+
}
|
|
357
|
+
const limit = limitParam(query, 100);
|
|
358
|
+
return withReadonlyStateDb((db) => {
|
|
359
|
+
const all = listStateProposals(db, { stashDir: resolveStashDir() });
|
|
360
|
+
const picked = status === "pending"
|
|
361
|
+
? all
|
|
362
|
+
.filter((p) => p.status === "pending")
|
|
363
|
+
.sort((a, b) => Date.parse(proposalWaitingSince(a)) - Date.parse(proposalWaitingSince(b)))
|
|
364
|
+
: all
|
|
365
|
+
.filter((p) => RECENT_STATUSES.has(p.status))
|
|
366
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
367
|
+
const bySource = {};
|
|
368
|
+
for (const proposal of picked)
|
|
369
|
+
bySource[proposal.source] = (bySource[proposal.source] ?? 0) + 1;
|
|
370
|
+
return {
|
|
371
|
+
status,
|
|
372
|
+
totalCount: picked.length,
|
|
373
|
+
bySource,
|
|
374
|
+
proposals: picked.slice(0, limit).map(proposalSummary),
|
|
375
|
+
};
|
|
376
|
+
}, { status, totalCount: 0, bySource: {}, proposals: [] });
|
|
377
|
+
}
|
|
378
|
+
/** Most characters of either side the detail view returns; a larger file is cut and flagged `truncated`. */
|
|
379
|
+
export const DIFF_SIDE_MAX_CHARS = 512 * 1024;
|
|
380
|
+
/** True when the proposal's target is an env or secret asset, whose content never leaves the machine's files via the UI. */
|
|
381
|
+
function targetsSensitiveAsset(proposal) {
|
|
382
|
+
try {
|
|
383
|
+
const type = typeNameFromConceptId(parseBundleRef(proposal.ref).conceptId)?.type;
|
|
384
|
+
return type === "env" || type === "secret";
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function capSide(text) {
|
|
391
|
+
if (text === null || text.length <= DIFF_SIDE_MAX_CHARS)
|
|
392
|
+
return { text, truncated: false };
|
|
393
|
+
return { text: text.slice(0, DIFF_SIDE_MAX_CHARS), truncated: true };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The diff for the detail view: akm's unified text plus both sides, so the client can line them up itself.
|
|
397
|
+
* `before`/`after` come from the same read as `unified` ({@link diffLoadedProposal}); they are `null` for a retire
|
|
398
|
+
* proposal (the file leaves, nothing is proposed) and, with `redacted`, for an env or secret target.
|
|
399
|
+
*/
|
|
400
|
+
function proposalDiffView(proposal) {
|
|
401
|
+
const result = diffLoadedProposal(loadConfig(), proposal);
|
|
402
|
+
const base = { isNew: result.isNew, targetPath: result.targetPath ?? null };
|
|
403
|
+
if (targetsSensitiveAsset(proposal)) {
|
|
404
|
+
return {
|
|
405
|
+
unified: "Env and secret values are never shown in the UI.",
|
|
406
|
+
...base,
|
|
407
|
+
before: null,
|
|
408
|
+
after: null,
|
|
409
|
+
truncated: false,
|
|
410
|
+
redacted: true,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (isRetireProposal(proposal)) {
|
|
414
|
+
return { unified: result.unified, ...base, before: null, after: null, truncated: false, redacted: false };
|
|
415
|
+
}
|
|
416
|
+
const before = capSide(result.existing);
|
|
417
|
+
const after = capSide(result.proposed);
|
|
418
|
+
return {
|
|
419
|
+
unified: result.unified,
|
|
420
|
+
...base,
|
|
421
|
+
before: before.text,
|
|
422
|
+
after: after.text,
|
|
423
|
+
truncated: before.truncated || after.truncated,
|
|
424
|
+
redacted: false,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function proposalDetail(id) {
|
|
428
|
+
const detail = withReadonlyStateDb((db) => {
|
|
429
|
+
const proposal = resolveProposalIdIn(db, resolveStashDir(), id);
|
|
430
|
+
let diff = null;
|
|
431
|
+
let diffError = null;
|
|
432
|
+
try {
|
|
433
|
+
diff = proposalDiffView(proposal);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
diffError = error instanceof Error ? error.message : String(error);
|
|
437
|
+
}
|
|
438
|
+
// Content lives in the diff; `backupContent` is "never shown" by contract and the accept intent is internal.
|
|
439
|
+
const { backupContent: _backup, retireAcceptIntent: _intent, payload, changes, ...rest } = proposal;
|
|
440
|
+
return {
|
|
441
|
+
proposal: {
|
|
442
|
+
...proposalSummary(proposal),
|
|
443
|
+
...rest,
|
|
444
|
+
payload: { frontmatter: payload.frontmatter ?? null },
|
|
445
|
+
changes: changes.map((change) => ({ op: change.op, path: change.path })),
|
|
446
|
+
},
|
|
447
|
+
validation: validateProposal(proposal),
|
|
448
|
+
diff,
|
|
449
|
+
diffError,
|
|
450
|
+
};
|
|
451
|
+
}, null);
|
|
452
|
+
if (!detail)
|
|
453
|
+
throw new NotFoundError(`Proposal "${id}" not found: state.db is not available.`, "PROPOSAL_NOT_FOUND");
|
|
454
|
+
return detail;
|
|
455
|
+
}
|
|
456
|
+
// ── /api/changes ─────────────────────────────────────────────────────────────
|
|
457
|
+
async function changes(query) {
|
|
458
|
+
const limit = limitParam(query);
|
|
459
|
+
const target = resolveWriteTarget(loadConfig(), undefined, { requireWritable: false });
|
|
460
|
+
const commits = await readRecentCommits(target.source.path, limit);
|
|
461
|
+
return {
|
|
462
|
+
bundle: target.source.name,
|
|
463
|
+
path: target.source.path,
|
|
464
|
+
git: commits !== null,
|
|
465
|
+
commits: commits ?? [],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
// ── /api/search and /api/asset ───────────────────────────────────────────────
|
|
469
|
+
async function search(query) {
|
|
470
|
+
const type = query.get("type") || undefined;
|
|
471
|
+
const bundle = query.get("bundle") || undefined;
|
|
472
|
+
return akmSearch({
|
|
473
|
+
query: (query.get("q") ?? "").trim(),
|
|
474
|
+
type,
|
|
475
|
+
limit: limitParam(query, 20),
|
|
476
|
+
// `--from <name>` narrows to one configured bundle; absent means every local bundle, as `akm search` does.
|
|
477
|
+
source: bundle ?? "local",
|
|
478
|
+
readOnly: true,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/** The durable `bundle//conceptId` of the indexed asset at `filePath`, the key feedback rows use. */
|
|
482
|
+
function durableRefForPath(filePath) {
|
|
483
|
+
const db = openReadonlyExistingDatabase(getDbPath());
|
|
484
|
+
if (!db)
|
|
485
|
+
return null;
|
|
486
|
+
try {
|
|
487
|
+
const id = getEntryIdByFilePath(db, filePath);
|
|
488
|
+
return id === undefined ? null : getItemRefById(db, id);
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
closeDatabase(db);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function feedbackFor(durableRef) {
|
|
495
|
+
const empty = { counts: {}, history: [] };
|
|
496
|
+
if (!durableRef)
|
|
497
|
+
return empty;
|
|
498
|
+
return withReadonlyStateDb((db) => {
|
|
499
|
+
const history = listFeedbackEventRows(db, durableRef, 50).map((row) => {
|
|
500
|
+
let detail = {};
|
|
501
|
+
try {
|
|
502
|
+
detail = row.metadata ? JSON.parse(row.metadata) : {};
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// Metadata is optional.
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
// `created_at` is SQLite's `YYYY-MM-DD HH:MM:SS` in UTC.
|
|
509
|
+
at: new Date(`${row.created_at.replace(" ", "T")}Z`).toISOString(),
|
|
510
|
+
signal: row.signal,
|
|
511
|
+
source: row.source,
|
|
512
|
+
...(typeof detail.reason === "string" ? { reason: detail.reason } : {}),
|
|
513
|
+
...(Array.isArray(detail.tags) ? { tags: detail.tags } : {}),
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
return { counts: countFeedbackBySignal(db, durableRef), history };
|
|
517
|
+
}, empty);
|
|
518
|
+
}
|
|
519
|
+
async function asset(query) {
|
|
520
|
+
const ref = (query.get("ref") ?? "").trim();
|
|
521
|
+
if (!ref)
|
|
522
|
+
throw new UsageError("An asset ref is required: /api/asset?ref=<ref>.", "MISSING_REQUIRED_ARGUMENT");
|
|
523
|
+
const parsed = parseBundleRef(ref);
|
|
524
|
+
const type = typeNameFromConceptId(parsed.conceptId)?.type;
|
|
525
|
+
if (type === "env" || type === "secret") {
|
|
526
|
+
// Values never leave the machine's own files through the UI, whatever the renderers would show.
|
|
527
|
+
return { redacted: true, type, ref, note: "Env and secret values are never shown in the UI." };
|
|
528
|
+
}
|
|
529
|
+
const shown = await akmShowUnified({ ref, readOnly: true });
|
|
530
|
+
const durableRef = shown.path ? durableRefForPath(shown.path) : null;
|
|
531
|
+
return { redacted: false, durableRef, asset: shown, feedback: feedbackFor(durableRef) };
|
|
532
|
+
}
|
|
533
|
+
// ── Routing ──────────────────────────────────────────────────────────────────
|
|
534
|
+
const ROUTES = {
|
|
535
|
+
"/api/meta": meta,
|
|
536
|
+
"/api/overview": overview,
|
|
537
|
+
"/api/bundles": bundles,
|
|
538
|
+
"/api/config": config,
|
|
539
|
+
"/api/proposals": proposals,
|
|
540
|
+
"/api/changes": changes,
|
|
541
|
+
"/api/search": search,
|
|
542
|
+
"/api/asset": asset,
|
|
543
|
+
};
|
|
544
|
+
const PROPOSAL_ROUTE = /^\/api\/proposals\/([^/]+)$/;
|
|
545
|
+
/** What {@link handleApiRequest} returns for a path that is not an API route. */
|
|
546
|
+
export const NO_SUCH_ROUTE = Symbol("no-such-route");
|
|
547
|
+
/** Dispatch one `/api/...` request; handler errors propagate, an unknown route is {@link NO_SUCH_ROUTE}. */
|
|
548
|
+
export async function handleApiRequest(pathname, query, info) {
|
|
549
|
+
const route = ROUTES[pathname];
|
|
550
|
+
if (route)
|
|
551
|
+
return route(query, info);
|
|
552
|
+
const proposalMatch = PROPOSAL_ROUTE.exec(pathname);
|
|
553
|
+
if (proposalMatch)
|
|
554
|
+
return proposalDetail(decodeURIComponent(proposalMatch[1]));
|
|
555
|
+
return NO_SUCH_ROUTE;
|
|
556
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Read-only git queries for `akm ui`: a bundle's branch, ahead/behind and last
|
|
6
|
+
* commit, and the write-target stash's recent commits.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here fetches, and nothing takes a lock: `GIT_OPTIONAL_LOCKS=0` keeps
|
|
9
|
+
* `git status` from refreshing the index, and ahead/behind is measured against
|
|
10
|
+
* the remote-tracking branch as the last `akm sync` left it. Calls are async so
|
|
11
|
+
* a slow repository stalls only its own request, not the server.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** `git log` output for a large accept can be big; cap what we buffer. */
|
|
18
|
+
const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
19
|
+
/** Paths listed per commit; `pathCount` still counts them all. */
|
|
20
|
+
const PATHS_PER_COMMIT = 50;
|
|
21
|
+
function git(repoDir, args) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile("git", ["-C", repoDir, "-c", "core.quotepath=false", ...args], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: GIT_TIMEOUT_MS,
|
|
26
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
27
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
|
|
28
|
+
}, (error, stdout) => resolve(error ? null : stdout));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Whether `dir` is the root of its own git repository (a bundle inside a larger repo is not reported as git-backed). */
|
|
32
|
+
export function isGitRoot(dir) {
|
|
33
|
+
return fs.existsSync(path.join(dir, ".git"));
|
|
34
|
+
}
|
|
35
|
+
export async function readBundleGitStatus(repoDir) {
|
|
36
|
+
if (!isGitRoot(repoDir))
|
|
37
|
+
return null;
|
|
38
|
+
const [branch, upstream, status, last] = await Promise.all([
|
|
39
|
+
// symbolic-ref also names the branch of a repository with no commits yet; a detached HEAD has none.
|
|
40
|
+
git(repoDir, ["symbolic-ref", "--short", "-q", "HEAD"]),
|
|
41
|
+
git(repoDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]),
|
|
42
|
+
git(repoDir, ["status", "--porcelain"]),
|
|
43
|
+
git(repoDir, ["log", "-1", "--format=%H%x1f%cI%x1f%s"]),
|
|
44
|
+
]);
|
|
45
|
+
let ahead = null;
|
|
46
|
+
let behind = null;
|
|
47
|
+
if (upstream?.trim()) {
|
|
48
|
+
const counts = (await git(repoDir, ["rev-list", "--left-right", "--count", "HEAD...@{u}"]))?.trim().split(/\s+/);
|
|
49
|
+
if (counts?.length === 2) {
|
|
50
|
+
ahead = Number(counts[0]);
|
|
51
|
+
behind = Number(counts[1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const [sha, time, subject] = (last ?? "").trim().split("\x1f");
|
|
55
|
+
return {
|
|
56
|
+
branch: branch?.trim() || null,
|
|
57
|
+
upstream: upstream?.trim() || null,
|
|
58
|
+
ahead,
|
|
59
|
+
behind,
|
|
60
|
+
dirtyPaths: status ? status.split("\n").filter((line) => line.trim() !== "").length : 0,
|
|
61
|
+
lastCommit: sha && time ? { sha, time, subject: subject ?? "" } : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function classifyCommitOrigin(subject) {
|
|
65
|
+
if (/^akm improve\b/i.test(subject))
|
|
66
|
+
return "improve-sync";
|
|
67
|
+
if (/^akm (accept|proposal)\b/i.test(subject))
|
|
68
|
+
return "accept";
|
|
69
|
+
if (/\bremember\b/i.test(subject))
|
|
70
|
+
return "remember";
|
|
71
|
+
if (/^akm (save|sync)\b/i.test(subject))
|
|
72
|
+
return "sync";
|
|
73
|
+
return "other";
|
|
74
|
+
}
|
|
75
|
+
/** The `limit` most recent commits of the repository at `repoDir`, newest first; `null` when it is not a git root. */
|
|
76
|
+
export async function readRecentCommits(repoDir, limit) {
|
|
77
|
+
if (!isGitRoot(repoDir))
|
|
78
|
+
return null;
|
|
79
|
+
const out = await git(repoDir, ["log", "-n", String(limit), "--name-only", "--format=%x1e%H%x1f%cI%x1f%an%x1f%s"]);
|
|
80
|
+
if (out === null)
|
|
81
|
+
return [];
|
|
82
|
+
const commits = [];
|
|
83
|
+
for (const record of out.split("\x1e")) {
|
|
84
|
+
if (!record.trim())
|
|
85
|
+
continue;
|
|
86
|
+
const [header = "", ...pathLines] = record.split("\n");
|
|
87
|
+
const [sha = "", time = "", author = "", subject = ""] = header.split("\x1f");
|
|
88
|
+
const paths = pathLines.map((line) => line.trim()).filter(Boolean);
|
|
89
|
+
commits.push({
|
|
90
|
+
sha,
|
|
91
|
+
shortSha: sha.slice(0, 8),
|
|
92
|
+
time,
|
|
93
|
+
author,
|
|
94
|
+
subject,
|
|
95
|
+
origin: classifyCommitOrigin(subject),
|
|
96
|
+
paths: paths.slice(0, PATHS_PER_COMMIT),
|
|
97
|
+
pathCount: paths.length,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return commits;
|
|
101
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
/** The command that opens `url` in the default browser on `platform`. */
|
|
6
|
+
export function browserOpener(url, platform = process.platform) {
|
|
7
|
+
if (platform === "darwin")
|
|
8
|
+
return ["open", [url]];
|
|
9
|
+
// `start` is a cmd builtin; its first quoted argument is the window title, hence the empty one.
|
|
10
|
+
if (platform === "win32")
|
|
11
|
+
return ["cmd", ["/c", "start", '""', url]];
|
|
12
|
+
return ["xdg-open", [url]];
|
|
13
|
+
}
|
|
14
|
+
/** Open `url` in the default browser. Resolves `false` when no opener could be started; the caller prints the URL either way. */
|
|
15
|
+
export function openBrowser(url) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const [command, args] = browserOpener(url);
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsVerbatimArguments: true });
|
|
20
|
+
child.once("error", () => resolve(false));
|
|
21
|
+
child.once("spawn", () => {
|
|
22
|
+
child.unref();
|
|
23
|
+
resolve(true);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
resolve(false);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|