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,273 @@
|
|
|
1
|
+
// Accepting and rejecting proposals in the browser: the buttons on a proposal's page and the batch bar on the pending
|
|
2
|
+
// list. These are the only places the UI writes anything, and each goes through POST /api/proposals/... (lib.js `post`).
|
|
3
|
+
//
|
|
4
|
+
// Markup is built with `html` and written with `mount`, like every other page. Handlers are attached with
|
|
5
|
+
// addEventListener on an element this render created (never on <main>, which outlives the page), so they go away with
|
|
6
|
+
// the page. Confirmations are inline: no window.confirm, alert or prompt.
|
|
7
|
+
|
|
8
|
+
import { ApiError, badge, html, mount, post, proposalHref } from "./lib.js";
|
|
9
|
+
|
|
10
|
+
const REASON_MAX = 2000;
|
|
11
|
+
|
|
12
|
+
/** What went wrong, in words a reviewer can act on; the server's own message and code are shown beside it. */
|
|
13
|
+
export function describeError(error) {
|
|
14
|
+
const code = error instanceof ApiError ? error.code : "ERROR";
|
|
15
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
16
|
+
const known = {
|
|
17
|
+
PROPOSAL_STALE: "The asset changed since this proposal was made; it can't be accepted.",
|
|
18
|
+
PROPOSAL_NOT_PENDING: "This proposal has already been decided.",
|
|
19
|
+
PROPOSAL_NOT_FOUND: "This proposal no longer exists.",
|
|
20
|
+
ASSET_MUTATION_LEASE_HELD: "Another akm process is writing to the bundle. Try again in a moment.",
|
|
21
|
+
ACCEPT_FAILED: "",
|
|
22
|
+
};
|
|
23
|
+
return { text: known[code] ?? "", message, code };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function errorBlock(error) {
|
|
27
|
+
const { text, message, code } = describeError(error);
|
|
28
|
+
return html`<div class="notice notice-fail" role="alert" tabindex="-1">
|
|
29
|
+
<p class="state-title">${text || message}</p>
|
|
30
|
+
${text && text !== message ? html`<p class="small">${message}</p>` : ""}
|
|
31
|
+
<p class="muted small"><code>${code}</code></p>
|
|
32
|
+
</div>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function commitLine(commit) {
|
|
36
|
+
return commit
|
|
37
|
+
? html`Committed <span class="mono">${commit.sha.slice(0, 8)}</span> <span class="muted">${commit.subject}</span>`
|
|
38
|
+
: "No commit was recorded for this change.";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- one proposal ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function detailView(state, nextId) {
|
|
44
|
+
const busy = state.mode === "busy";
|
|
45
|
+
switch (state.mode) {
|
|
46
|
+
case "done": {
|
|
47
|
+
const { kind, result, commit } = state;
|
|
48
|
+
return html`<div class="notice notice-ok" role="status" tabindex="-1">
|
|
49
|
+
<p class="state-title">${kind === "accept" ? "Accepted" : "Rejected"}</p>
|
|
50
|
+
${
|
|
51
|
+
kind === "accept"
|
|
52
|
+
? html`<p>Written to <span class="mono wrap">${result.assetPath}</span>.</p><p>${commitLine(commit)}</p>`
|
|
53
|
+
: html`<p>The reason was recorded: <em>${result.reason}</em></p>`
|
|
54
|
+
}
|
|
55
|
+
</div>
|
|
56
|
+
<p class="btn-row">${nextId ? html`<a class="btn" href="${proposalHref(nextId)}">Next pending</a>` : ""}<a class="btn btn-secondary" href="#/proposals">Back to queue</a></p>
|
|
57
|
+
${nextId ? "" : html`<p class="muted small">No other proposal is waiting.</p>`}`;
|
|
58
|
+
}
|
|
59
|
+
case "confirm-accept":
|
|
60
|
+
return html`<p class="state-title">Accept this proposal?</p>
|
|
61
|
+
<p class="muted">The asset is written to its bundle and the change is committed.</p>
|
|
62
|
+
<p class="btn-row"><button type="button" data-act="accept-confirm">Confirm accept</button><button type="button" class="btn-secondary" data-act="cancel">Cancel</button></p>`;
|
|
63
|
+
case "reject":
|
|
64
|
+
case "busy":
|
|
65
|
+
if (state.mode === "busy" && state.kind === "accept") {
|
|
66
|
+
return html`<p class="btn-row"><button type="button" disabled>Accept</button><button type="button" class="btn-secondary" disabled>Reject</button></p>
|
|
67
|
+
<p class="muted" role="status">Accepting...</p>`;
|
|
68
|
+
}
|
|
69
|
+
return html`${state.error ? errorBlock(state.error) : ""}<label for="reject-reason"><strong>Why reject this proposal?</strong> <span class="muted small">Required, up to ${REASON_MAX} characters.</span></label>
|
|
70
|
+
<textarea id="reject-reason" data-reason rows="3" maxlength="${REASON_MAX}" required ${busy ? "disabled" : ""}>${state.reason}</textarea>
|
|
71
|
+
${state.reasonMissing ? html`<p class="field-error" role="alert">Enter a reason before rejecting.</p>` : ""}
|
|
72
|
+
<p class="btn-row"><button type="button" class="btn-danger" data-act="reject-confirm" ${busy ? "disabled" : ""}>Confirm reject</button><button type="button" class="btn-secondary" data-act="cancel" ${busy ? "disabled" : ""}>Cancel</button></p>
|
|
73
|
+
${busy ? html`<p class="muted" role="status">Rejecting...</p>` : ""}`;
|
|
74
|
+
default:
|
|
75
|
+
return html`${state.error ? errorBlock(state.error) : ""}
|
|
76
|
+
<p class="btn-row"><button type="button" data-act="accept">Accept</button><button type="button" class="btn-secondary" data-act="reject">Reject</button></p>`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The action area of a pending proposal's page; {@link wireDetailActions} brings it to life. */
|
|
81
|
+
export function detailActions(nextId) {
|
|
82
|
+
return html`<section class="actions" data-actions aria-label="Decide this proposal">${detailView({ mode: "idle" }, nextId)}</section>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Attach the Accept / Reject controls inside `root`. `onDecided(status)` runs after the server confirms, so the page can
|
|
87
|
+
* show the new status.
|
|
88
|
+
*/
|
|
89
|
+
export function wireDetailActions(root, { id, nextId, onDecided }) {
|
|
90
|
+
const region = root.querySelector("[data-actions]");
|
|
91
|
+
if (!region) return;
|
|
92
|
+
let state = { mode: "idle", reason: "" };
|
|
93
|
+
const draw = (focus) => {
|
|
94
|
+
mount(region, detailView(state, nextId));
|
|
95
|
+
region.querySelector(focus)?.focus();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const run = async (kind, path, body) => {
|
|
99
|
+
state = { ...state, mode: "busy", kind, error: null };
|
|
100
|
+
draw();
|
|
101
|
+
try {
|
|
102
|
+
const result = await post(path, body);
|
|
103
|
+
state = { mode: "done", kind, result, commit: result.commit ?? null, reason: state.reason };
|
|
104
|
+
draw("[role=status]");
|
|
105
|
+
onDecided?.(kind === "accept" ? "accepted" : "rejected");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
state = { mode: kind === "reject" ? "reject" : "idle", reason: state.reason, error };
|
|
108
|
+
draw("[role=alert]");
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
region.addEventListener("input", (event) => {
|
|
113
|
+
if (!event.target.matches("[data-reason]")) return;
|
|
114
|
+
state.reason = event.target.value;
|
|
115
|
+
if (state.reasonMissing && state.reason.trim() !== "") {
|
|
116
|
+
state.reasonMissing = false;
|
|
117
|
+
region.querySelector(".field-error")?.remove();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
region.addEventListener("click", (event) => {
|
|
121
|
+
const button = event.target.closest("button[data-act]");
|
|
122
|
+
if (!button || button.disabled || state.mode === "busy") return;
|
|
123
|
+
const base = `/api/proposals/${encodeURIComponent(id)}`;
|
|
124
|
+
switch (button.dataset.act) {
|
|
125
|
+
case "accept":
|
|
126
|
+
state = { mode: "confirm-accept", reason: state.reason };
|
|
127
|
+
draw("[data-act=accept-confirm]");
|
|
128
|
+
break;
|
|
129
|
+
case "accept-confirm":
|
|
130
|
+
run("accept", `${base}/accept`, {});
|
|
131
|
+
break;
|
|
132
|
+
case "reject":
|
|
133
|
+
state = { mode: "reject", reason: state.reason };
|
|
134
|
+
draw("[data-reason]");
|
|
135
|
+
break;
|
|
136
|
+
case "reject-confirm": {
|
|
137
|
+
const reason = state.reason.trim();
|
|
138
|
+
if (reason === "" || reason.length > REASON_MAX) {
|
|
139
|
+
state = { ...state, mode: "reject", reasonMissing: true };
|
|
140
|
+
draw("[data-reason]");
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
run("reject", `${base}/reject`, { reason });
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "cancel":
|
|
147
|
+
state = { mode: "idle", reason: state.reason };
|
|
148
|
+
draw();
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- the pending list --------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
function batchView(state, count) {
|
|
157
|
+
switch (state.mode) {
|
|
158
|
+
case "confirm":
|
|
159
|
+
return html`<span>Accept ${state.ids.length} ${state.ids.length === 1 ? "proposal" : "proposals"}? They are written and committed together.</span>
|
|
160
|
+
<span class="btn-row"><button type="button" data-act="batch-confirm">Confirm accept</button><button type="button" class="btn-secondary" data-act="batch-cancel">Cancel</button></span>`;
|
|
161
|
+
case "busy":
|
|
162
|
+
return html`<span role="status">Accepting ${state.ids.length}...</span>
|
|
163
|
+
<span class="btn-row"><button type="button" disabled>Accept selected</button></span>`;
|
|
164
|
+
case "results": {
|
|
165
|
+
const { data } = state;
|
|
166
|
+
return html`<div class="batch-results" role="status" tabindex="-1">
|
|
167
|
+
<p class="state-title">${data.accepted} accepted${data.failed > 0 ? html`, ${data.failed} failed` : ""}</p>
|
|
168
|
+
<p class="small">${commitLine(data.commit)}</p>
|
|
169
|
+
<ul class="plain">${data.results.map((r) => {
|
|
170
|
+
const label = state.refs[r.id] ?? r.ref ?? r.id;
|
|
171
|
+
return r.ok
|
|
172
|
+
? html`<li>${badge("accepted", "ok")} <span class="mono wrap">${label}</span></li>`
|
|
173
|
+
: html`<li>${badge("failed", "fail")} <span class="mono wrap">${label}</span> <span class="small">${describeError(new ApiError(0, r.code, r.error)).text || r.error}</span> <code class="small muted">${r.code}</code></li>`;
|
|
174
|
+
})}</ul>
|
|
175
|
+
</div>
|
|
176
|
+
<span class="btn-row"><button type="button" class="btn-secondary" data-act="batch-dismiss">Dismiss</button></span>`;
|
|
177
|
+
}
|
|
178
|
+
case "error":
|
|
179
|
+
return html`${errorBlock(state.error)}<span class="btn-row"><button type="button" class="btn-secondary" data-act="batch-dismiss">Dismiss</button></span>`;
|
|
180
|
+
default:
|
|
181
|
+
return count === 0
|
|
182
|
+
? html``
|
|
183
|
+
: html`<span class="num">${count} selected</span>
|
|
184
|
+
<span class="btn-row"><button type="button" data-act="batch-start">Accept selected</button><button type="button" class="btn-secondary" data-act="batch-clear">Clear</button></span>`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Attach selection and the batch bar inside `root`. `reload()` resolves to fresh list markup, shown after a batch so the
|
|
190
|
+
* accepted rows are gone while the per-id results stay in the bar.
|
|
191
|
+
*/
|
|
192
|
+
export function wireBatch(root, { reload }) {
|
|
193
|
+
const page = root.querySelector("[data-proposal-list]");
|
|
194
|
+
if (!page) return;
|
|
195
|
+
const list = page.querySelector("[data-list]");
|
|
196
|
+
const bar = page.querySelector("[data-batch-bar]");
|
|
197
|
+
let state = { mode: "idle" };
|
|
198
|
+
|
|
199
|
+
const boxes = () => [...list.querySelectorAll("input[data-select]")];
|
|
200
|
+
const chosen = () => boxes().filter((box) => box.checked);
|
|
201
|
+
const draw = (focus) => {
|
|
202
|
+
mount(bar, batchView(state, chosen().length));
|
|
203
|
+
bar.hidden = bar.childElementCount === 0;
|
|
204
|
+
bar.querySelector(focus)?.focus();
|
|
205
|
+
};
|
|
206
|
+
const syncGroups = () => {
|
|
207
|
+
for (const section of list.querySelectorAll("section[data-group]")) {
|
|
208
|
+
const group = section.querySelector("input[data-select-group]");
|
|
209
|
+
const rows = [...section.querySelectorAll("input[data-select]")];
|
|
210
|
+
const on = rows.filter((box) => box.checked).length;
|
|
211
|
+
group.checked = rows.length > 0 && on === rows.length;
|
|
212
|
+
group.indeterminate = on > 0 && on < rows.length;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
page.addEventListener("change", (event) => {
|
|
217
|
+
const target = event.target;
|
|
218
|
+
if (target.matches("input[data-select-group]")) {
|
|
219
|
+
for (const box of target.closest("section[data-group]").querySelectorAll("input[data-select]")) {
|
|
220
|
+
box.checked = target.checked;
|
|
221
|
+
}
|
|
222
|
+
} else if (!target.matches("input[data-select]")) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
syncGroups();
|
|
226
|
+
if (state.mode === "idle") draw();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
page.addEventListener("click", async (event) => {
|
|
230
|
+
const button = event.target.closest("button[data-act]");
|
|
231
|
+
if (!button || button.disabled || state.mode === "busy") return;
|
|
232
|
+
switch (button.dataset.act) {
|
|
233
|
+
case "batch-start": {
|
|
234
|
+
const picked = chosen();
|
|
235
|
+
state = {
|
|
236
|
+
mode: "confirm",
|
|
237
|
+
ids: picked.map((box) => box.value),
|
|
238
|
+
refs: Object.fromEntries(picked.map((box) => [box.value, box.dataset.ref])),
|
|
239
|
+
};
|
|
240
|
+
draw("[data-act=batch-confirm]");
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "batch-clear":
|
|
244
|
+
for (const box of chosen()) box.checked = false;
|
|
245
|
+
syncGroups();
|
|
246
|
+
draw();
|
|
247
|
+
break;
|
|
248
|
+
case "batch-cancel":
|
|
249
|
+
case "batch-dismiss":
|
|
250
|
+
state = { mode: "idle" };
|
|
251
|
+
draw();
|
|
252
|
+
break;
|
|
253
|
+
case "batch-confirm": {
|
|
254
|
+
const { ids, refs } = state;
|
|
255
|
+
state = { mode: "busy", ids, refs };
|
|
256
|
+
draw();
|
|
257
|
+
try {
|
|
258
|
+
const data = await post("/api/proposals/accept", { ids });
|
|
259
|
+
state = { mode: "results", data, refs };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
state = { mode: "error", error };
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
mount(list, await reload());
|
|
265
|
+
} catch {
|
|
266
|
+
// The results stay visible; the list refreshes on the next visit.
|
|
267
|
+
}
|
|
268
|
+
draw(".batch-results, [role=alert]");
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Proposals: the pending queue grouped by source, recent decisions, and a detail view with the diff.
|
|
2
|
+
|
|
3
|
+
import { diffSection } from "./diff-view.js";
|
|
4
|
+
import {
|
|
5
|
+
api,
|
|
6
|
+
assetHref,
|
|
7
|
+
badge,
|
|
8
|
+
definitionList,
|
|
9
|
+
emptyState,
|
|
10
|
+
fmtDateTime,
|
|
11
|
+
html,
|
|
12
|
+
mount,
|
|
13
|
+
pageHeader,
|
|
14
|
+
pretty,
|
|
15
|
+
proposalHref,
|
|
16
|
+
tableWrap,
|
|
17
|
+
when,
|
|
18
|
+
} from "./lib.js";
|
|
19
|
+
import { detailActions, wireBatch, wireDetailActions } from "./proposal-actions.js";
|
|
20
|
+
|
|
21
|
+
function listHref(status, source) {
|
|
22
|
+
const params = new URLSearchParams();
|
|
23
|
+
if (status !== "pending") params.set("status", status);
|
|
24
|
+
if (source) params.set("source", source);
|
|
25
|
+
const q = params.toString();
|
|
26
|
+
return `#/proposals${q ? `?${q}` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gate(decision) {
|
|
30
|
+
if (!decision) return html`<span class="muted">-</span>`;
|
|
31
|
+
const kind = decision.outcome === "auto-rejected" ? "fail" : decision.outcome === "deferred" ? "warn" : "info";
|
|
32
|
+
return html`${badge(decision.outcome, kind)}${decision.reason ? html` <span class="muted small">${decision.reason}</span>` : ""}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function selectCell(p) {
|
|
36
|
+
return html`<td class="select"><input type="checkbox" data-select value="${p.id}" data-ref="${p.ref}" aria-label="Select ${p.ref}"></td>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function refCell(p) {
|
|
40
|
+
return html`<a class="mono wrap" href="${proposalHref(p.id)}">${p.ref}</a>${p.isRetire && p.retirement ? html`<div class="small muted">retires in favour of <span class="mono">${p.retirement.successorRef}</span></div>` : ""}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sourceChips(counts, active, status) {
|
|
44
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
45
|
+
const chip = (label, source, n) =>
|
|
46
|
+
html`<a class="chip ${(active ?? "") === source ? "active" : ""}" href="${listHref(status, source)}" ${(active ?? "") === source ? html`aria-current="true"` : ""}>${label} <span class="num">${n}</span></a>`;
|
|
47
|
+
return html`<div class="chips">${chip("All", "", total)}${Object.entries(counts)
|
|
48
|
+
.sort()
|
|
49
|
+
.map(([s, n]) => chip(s, s, n))}</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pendingView(data, source) {
|
|
53
|
+
const shown = data.proposals.filter((p) => !source || p.source === source);
|
|
54
|
+
if (data.totalCount === 0)
|
|
55
|
+
return emptyState(
|
|
56
|
+
"No pending proposals.",
|
|
57
|
+
"The queue is empty. Proposals appear here when akm improve stages a change for review.",
|
|
58
|
+
);
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (const p of shown) groups.set(p.source, [...(groups.get(p.source) ?? []), p]);
|
|
61
|
+
return html`${sourceChips(data.bySource, source, "pending")}
|
|
62
|
+
${data.totalCount > data.proposals.length ? html`<p class="muted">Showing the first ${data.proposals.length} of ${data.totalCount}.</p>` : ""}
|
|
63
|
+
${
|
|
64
|
+
shown.length === 0
|
|
65
|
+
? emptyState("Nothing pending from this source.")
|
|
66
|
+
: [...groups].map(
|
|
67
|
+
([name, items]) =>
|
|
68
|
+
html`<section data-group="${name}"><h2>${name} <span class="muted num">${items.length}</span></h2>
|
|
69
|
+
<label class="select-group"><input type="checkbox" data-select-group> select all in this group</label>${tableWrap(
|
|
70
|
+
html`<table><thead><tr><th class="select"><span class="sr-only">Select</span></th><th>Asset</th><th>Waiting</th><th>Gate decision</th><th class="r">Confidence</th></tr></thead><tbody>${items.map(
|
|
71
|
+
(p) =>
|
|
72
|
+
html`<tr>${selectCell(p)}<td>${refCell(p)}</td><td>${when(p.waitingSince)}</td><td>${gate(p.gateDecision)}</td><td class="r num">${typeof p.confidence === "number" ? p.confidence.toFixed(2) : "-"}</td></tr>`,
|
|
73
|
+
)}</tbody></table>`,
|
|
74
|
+
)}</section>`,
|
|
75
|
+
)
|
|
76
|
+
}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function outcomeBadge(status) {
|
|
80
|
+
return badge(
|
|
81
|
+
status,
|
|
82
|
+
status === "accepted" ? "ok" : status === "rejected" ? "fail" : status === "pending" ? "info" : "warn",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function recentView(data, source) {
|
|
87
|
+
const counts = {};
|
|
88
|
+
for (const p of data.proposals) counts[p.source] = (counts[p.source] ?? 0) + 1;
|
|
89
|
+
const shown = data.proposals.filter((p) => !source || p.source === source);
|
|
90
|
+
if (data.totalCount === 0)
|
|
91
|
+
return emptyState("No decided proposals yet.", "Accepted, rejected and reverted proposals are listed here.");
|
|
92
|
+
return html`${sourceChips(counts, source, "recent")}
|
|
93
|
+
${
|
|
94
|
+
shown.length === 0
|
|
95
|
+
? emptyState("Nothing decided from this source.")
|
|
96
|
+
: tableWrap(
|
|
97
|
+
html`<table><thead><tr><th>Decided</th><th>Outcome</th><th>Source</th><th>Asset</th><th>Reason</th></tr></thead><tbody>${shown.map(
|
|
98
|
+
(p) =>
|
|
99
|
+
html`<tr><td>${when(p.review?.decidedAt ?? p.updatedAt)}</td><td>${outcomeBadge(p.status)}</td><td class="mono">${p.source}</td><td>${refCell(p)}</td><td class="wrap">${p.review?.reason ?? p.gateDecision?.reason ?? html`<span class="muted">-</span>`}</td></tr>`,
|
|
100
|
+
)}</tbody></table>`,
|
|
101
|
+
)
|
|
102
|
+
}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PENDING_LIMIT = 200;
|
|
106
|
+
|
|
107
|
+
export async function renderList(ctx) {
|
|
108
|
+
const status = ctx.query.get("status") === "recent" ? "recent" : "pending";
|
|
109
|
+
const source = ctx.query.get("source") ?? "";
|
|
110
|
+
const data = await api("/api/proposals", { status, limit: PENDING_LIMIT });
|
|
111
|
+
const tab = (name, label) =>
|
|
112
|
+
html`<a class="tab ${status === name ? "active" : ""}" href="${listHref(name, "")}" ${status === name ? html`aria-current="page"` : ""}>${label}</a>`;
|
|
113
|
+
const head = html`${pageHeader("Proposals", "Changes akm improve has staged for review.")}
|
|
114
|
+
<nav class="tabs" aria-label="Proposal lists">${tab("pending", "Pending")}${tab("recent", "Recent")}</nav>`;
|
|
115
|
+
if (status === "recent") return html`${head}${recentView(data, source)}`;
|
|
116
|
+
ctx.after.push((root) =>
|
|
117
|
+
wireBatch(root, {
|
|
118
|
+
reload: async () => pendingView(await api("/api/proposals", { status: "pending", limit: PENDING_LIMIT }), source),
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
return html`${head}<div data-proposal-list><div data-list>${pendingView(data, source)}</div><div class="batch-bar" data-batch-bar hidden></div></div>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- detail ----------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function retirementBlock(r) {
|
|
127
|
+
const list = (items) =>
|
|
128
|
+
items && items.length > 0
|
|
129
|
+
? html`<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>`
|
|
130
|
+
: html`<span class="muted">nothing</span>`;
|
|
131
|
+
return html`<section><h2>Retirement</h2><div class="card">${definitionList([
|
|
132
|
+
["Retires", html`<a class="mono wrap" href="${assetHref(r.retiredRef)}">${r.retiredRef}</a>`],
|
|
133
|
+
["Successor", html`<a class="mono wrap" href="${assetHref(r.successorRef)}">${r.successorRef}</a>`],
|
|
134
|
+
["Reason", r.reason],
|
|
135
|
+
["Judge", html`${badge(r.judgeLabel, "info")} ${r.judgeReason}`],
|
|
136
|
+
["Similarity", typeof r.cosine === "number" ? html`<span class="num">${r.cosine.toFixed(2)}</span> cosine` : ""],
|
|
137
|
+
["Only in retired", list(r.onlyInRetired)],
|
|
138
|
+
["Only in successor", list(r.onlyInSuccessor)],
|
|
139
|
+
[
|
|
140
|
+
"Continuity risk",
|
|
141
|
+
r.continuityRisk
|
|
142
|
+
? html`${badge("flagged", "warn")} <pre class="code">${pretty(r.continuityRisk)}</pre>`
|
|
143
|
+
: html`<span class="muted">none flagged</span>`,
|
|
144
|
+
],
|
|
145
|
+
])}</div></section>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function statusBadges(p, status) {
|
|
149
|
+
return html`${outcomeBadge(status)}${badge(p.source, "info")}${p.isRetire ? badge("retire", "warn") : ""}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The pending proposal after `id` in the pending list's order (else the first other one); `null` when none is waiting. */
|
|
153
|
+
async function nextPendingId(id) {
|
|
154
|
+
try {
|
|
155
|
+
const { proposals } = await api("/api/proposals", { status: "pending", limit: PENDING_LIMIT });
|
|
156
|
+
const at = proposals.findIndex((p) => p.id === id);
|
|
157
|
+
return (proposals[at + 1] ?? proposals.find((p) => p.id !== id))?.id ?? null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function renderDetail(ctx) {
|
|
164
|
+
const data = await api(`/api/proposals/${encodeURIComponent(ctx.param)}`);
|
|
165
|
+
const p = data.proposal;
|
|
166
|
+
const findings = data.validation?.findings ?? [];
|
|
167
|
+
const pending = p.status === "pending";
|
|
168
|
+
const nextId = pending ? await nextPendingId(p.id) : null;
|
|
169
|
+
const diff = data.diff ? diffSection(data.diff) : null;
|
|
170
|
+
if (diff?.wire) ctx.after.push((root) => diff.wire(root));
|
|
171
|
+
if (pending) {
|
|
172
|
+
ctx.after.push((root) =>
|
|
173
|
+
wireDetailActions(root, {
|
|
174
|
+
id: p.id,
|
|
175
|
+
nextId,
|
|
176
|
+
onDecided: (status) => {
|
|
177
|
+
const target = root.querySelector("[data-status]");
|
|
178
|
+
if (target) mount(target, statusBadges(p, status));
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return html`<p class="crumbs"><a href="#/proposals">Proposals</a> / <span class="mono">${p.id.slice(0, 8)}</span></p>
|
|
184
|
+
${pageHeader(html`<span class="mono wrap">${p.ref}</span>`)}
|
|
185
|
+
<div class="badges" data-status>${statusBadges(p, p.status)}</div>
|
|
186
|
+
${pending ? detailActions(nextId) : ""}
|
|
187
|
+
<section><div class="card">${definitionList([
|
|
188
|
+
["Id", html`<span class="mono">${p.id}</span>`],
|
|
189
|
+
["Created", html`${when(p.createdAt)} <span class="muted small">${fmtDateTime(p.createdAt)}</span>`],
|
|
190
|
+
["Waiting since", when(p.waitingSince)],
|
|
191
|
+
["Confidence", typeof p.confidence === "number" ? p.confidence.toFixed(2) : ""],
|
|
192
|
+
["Source run", p.sourceRun ? html`<span class="mono">${p.sourceRun}</span>` : ""],
|
|
193
|
+
[
|
|
194
|
+
"Target",
|
|
195
|
+
p.proposedTarget
|
|
196
|
+
? html`<span class="mono wrap">${p.proposedTarget.root}</span> <span class="muted">(${p.proposedTarget.source})</span>`
|
|
197
|
+
: "",
|
|
198
|
+
],
|
|
199
|
+
["Gate decision", p.gateDecision ? gate(p.gateDecision) : ""],
|
|
200
|
+
[
|
|
201
|
+
"Review",
|
|
202
|
+
p.review ? html`${outcomeBadge(p.review.outcome)} ${p.review.reason ?? ""} ${when(p.review.decidedAt)}` : "",
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
"Changes",
|
|
206
|
+
p.changes?.length > 0
|
|
207
|
+
? html`<ul class="plain">${p.changes.map((c) => html`<li>${badge(c.op, "neutral")} <span class="mono wrap">${c.path}</span></li>`)}</ul>`
|
|
208
|
+
: "",
|
|
209
|
+
],
|
|
210
|
+
])}</div></section>
|
|
211
|
+
${p.retirement ? retirementBlock(p.retirement) : ""}
|
|
212
|
+
${findings.length > 0 ? html`<section><h2>Validation</h2><ul class="plain">${findings.map((f) => html`<li>${badge(f.severity ?? "info", f.severity === "error" ? "fail" : "warn")} <span class="mono small">${f.kind}</span> ${f.message}</li>`)}</ul></section>` : ""}
|
|
213
|
+
<section><h2>Diff</h2>${
|
|
214
|
+
diff
|
|
215
|
+
? html`<p class="muted small">${data.diff.isNew ? "New asset. " : ""}Target <span class="mono wrap">${data.diff.targetPath ?? ""}</span></p>${diff.markup}`
|
|
216
|
+
: emptyState("No diff available.", data.diffError ?? "")
|
|
217
|
+
}</section>
|
|
218
|
+
<p><a href="${assetHref(p.ref)}">View the asset as it is now</a></p>`;
|
|
219
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Search: query box with type and bundle filters; results link to the asset view.
|
|
2
|
+
|
|
3
|
+
import { api, assetHref, badge, emptyState, html, pageHeader } from "./lib.js";
|
|
4
|
+
|
|
5
|
+
const RESULT_LIMIT = 50;
|
|
6
|
+
|
|
7
|
+
function options(values, selected, allLabel) {
|
|
8
|
+
return html`<option value="">${allLabel}</option>${values.map((v) => html`<option value="${v}" ${v === selected ? html`selected` : ""}>${v}</option>`)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hit(h) {
|
|
12
|
+
return html`<li class="hit">
|
|
13
|
+
<div class="hit-head"><a class="mono wrap" href="${assetHref(h.ref)}">${h.ref}</a>${badge(h.type, "info")}${h.origin ? html`<span class="muted small">${h.origin}</span>` : ""}${typeof h.score === "number" ? html`<span class="num muted small" title="Ranking score">score ${h.score.toFixed(3)}</span>` : ""}</div>
|
|
14
|
+
${h.description ? html`<p>${h.description}</p>` : ""}
|
|
15
|
+
${Array.isArray(h.tags) && h.tags.length > 0 ? html`<div class="tags">${h.tags.map((t) => html`<span class="tag">${t}</span>`)}</div>` : ""}
|
|
16
|
+
${Array.isArray(h.whyMatched) && h.whyMatched.length > 0 ? html`<p class="muted small">Matched: ${h.whyMatched.join(", ")}</p>` : ""}
|
|
17
|
+
</li>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function render(ctx) {
|
|
21
|
+
const q = ctx.query.get("q") ?? "";
|
|
22
|
+
const type = ctx.query.get("type") ?? "";
|
|
23
|
+
const bundle = ctx.query.get("bundle") ?? "";
|
|
24
|
+
const [result, bundles] = await Promise.all([
|
|
25
|
+
api("/api/search", { q, type, bundle, limit: RESULT_LIMIT }),
|
|
26
|
+
api("/api/bundles").catch(() => null),
|
|
27
|
+
]);
|
|
28
|
+
const types = new Set(Object.keys(bundles?.index?.byType ?? {}));
|
|
29
|
+
for (const h of result.hits) types.add(h.type);
|
|
30
|
+
if (type) types.add(type);
|
|
31
|
+
const bundleNames = (bundles?.bundles ?? []).map((b) => b.name);
|
|
32
|
+
|
|
33
|
+
ctx.after.push((root) => {
|
|
34
|
+
const form = root.querySelector("form.search-form");
|
|
35
|
+
const go = () => {
|
|
36
|
+
const params = new URLSearchParams();
|
|
37
|
+
for (const [key, value] of new FormData(form)) if (value) params.set(key, String(value));
|
|
38
|
+
const next = `#/search${params.size > 0 ? `?${params}` : ""}`;
|
|
39
|
+
if (location.hash === next) window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
40
|
+
else location.hash = next;
|
|
41
|
+
};
|
|
42
|
+
form.addEventListener("submit", (event) => {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
go();
|
|
45
|
+
});
|
|
46
|
+
for (const select of form.querySelectorAll("select")) select.addEventListener("change", go);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return html`${pageHeader("Search", "Search your bundles. Browsing here is not counted as usage.")}
|
|
50
|
+
<form class="search-form" role="search">
|
|
51
|
+
<input type="search" name="q" value="${q}" placeholder="Search assets (empty lists everything)" aria-label="Query" autofocus>
|
|
52
|
+
<select name="type" aria-label="Type">${options([...types].sort(), type, "All types")}</select>
|
|
53
|
+
<select name="bundle" aria-label="Bundle">${options(bundleNames, bundle, "All bundles")}</select>
|
|
54
|
+
<button type="submit">Search</button>
|
|
55
|
+
</form>
|
|
56
|
+
${(result.warnings ?? []).map((w) => html`<div class="notice notice-warn">${w}</div>`)}
|
|
57
|
+
${
|
|
58
|
+
result.hits.length === 0
|
|
59
|
+
? emptyState(q ? `No assets match "${q}".` : "No assets found.", result.tip ?? "")
|
|
60
|
+
: html`<p class="muted">${result.hits.length} ${result.hits.length === 1 ? "result" : "results"}${result.hits.length === RESULT_LIMIT ? ` (the first ${RESULT_LIMIT})` : ""}, ${result.searchMode ?? "search"} mode${result.timing ? `, ${result.timing.totalMs} ms` : ""}.</p><ul class="hits">${result.hits.map(hit)}</ul>`
|
|
61
|
+
}`;
|
|
62
|
+
}
|