paseo-beads 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/client/board-view.tsx +290 -0
- package/client/board.ts +160 -0
- package/client/focus.ts +51 -0
- package/client/format.ts +227 -0
- package/client/markdown-view.tsx +156 -0
- package/client/markdown.ts +435 -0
- package/client/overview-view.tsx +462 -0
- package/client/panel.tsx +961 -0
- package/client/project.ts +603 -0
- package/client/rows.tsx +666 -0
- package/client/styles.ts +557 -0
- package/images/board.png +0 -0
- package/images/overview.png +0 -0
- package/images/plan.png +0 -0
- package/images/risks.png +0 -0
- package/index.client.tsx +68 -0
- package/index.server.ts +21 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/attachments.ts +166 -0
- package/server/bv.ts +213 -0
- package/server/cache.ts +51 -0
- package/server/command.ts +343 -0
- package/server/dashboard.ts +244 -0
- package/server/issue.ts +59 -0
- package/server/normalize.ts +687 -0
- package/server/search.ts +40 -0
- package/server/tracker.ts +122 -0
- package/server/workspace.ts +153 -0
- package/shared/beads.ts +325 -0
- package/shared/rpc.ts +129 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
3
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
4
|
+
import type { CommandError, CommandErrorCode } from "../shared/beads";
|
|
5
|
+
|
|
6
|
+
export interface CommandLimits {
|
|
7
|
+
readonly timeoutMs: number;
|
|
8
|
+
readonly maxOutputBytes: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_LIMITS: CommandLimits = {
|
|
12
|
+
timeoutMs: 20_000,
|
|
13
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Raw process outcome, before it is interpreted as JSON. */
|
|
17
|
+
export interface ProcessOutcome {
|
|
18
|
+
readonly exitCode: number | null;
|
|
19
|
+
readonly signal: string | null;
|
|
20
|
+
readonly stdout: string;
|
|
21
|
+
readonly stderr: string;
|
|
22
|
+
readonly timedOut: boolean;
|
|
23
|
+
readonly truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CommandResult<Value> =
|
|
27
|
+
| { readonly ok: true; readonly value: Value }
|
|
28
|
+
| { readonly ok: false; readonly error: CommandError };
|
|
29
|
+
|
|
30
|
+
function commandError(code: CommandErrorCode, message: string, exitCode: number | null = null): CommandError {
|
|
31
|
+
return { code, message, exitCode };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function failure(code: CommandErrorCode, message: string, exitCode: number | null = null): CommandResult<never> {
|
|
35
|
+
return { ok: false, error: commandError(code, message, exitCode) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Keeps stderr useful without leaking a multi-megabyte blob into the UI. */
|
|
39
|
+
export function summarizeStderr(stderr: string, maxLength = 400): string {
|
|
40
|
+
const collapsed = stderr.replace(/\s+/g, " ").trim();
|
|
41
|
+
if (collapsed.length <= maxLength) return collapsed;
|
|
42
|
+
return `${collapsed.slice(0, maxLength)}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const executableCache = new Map<string, string | null>();
|
|
46
|
+
const activeProcesses = new Set<ChildProcess>();
|
|
47
|
+
|
|
48
|
+
async function isExecutableFile(candidate: string): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
await access(candidate, constants.X_OK);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves a bare tool name to an absolute path by scanning `PATH` directly.
|
|
59
|
+
* No shell is involved, so `PATH` entries cannot inject arguments or operators.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveExecutable(name: string): Promise<string | null> {
|
|
62
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return null;
|
|
63
|
+
const cached = executableCache.get(name);
|
|
64
|
+
if (cached !== undefined) return cached;
|
|
65
|
+
|
|
66
|
+
const searchPath = process.env.PATH ?? "";
|
|
67
|
+
let resolved: string | null = null;
|
|
68
|
+
for (const entry of searchPath.split(delimiter)) {
|
|
69
|
+
if (entry.length === 0) continue;
|
|
70
|
+
if (!isAbsolute(entry)) continue;
|
|
71
|
+
const candidate = join(entry, name);
|
|
72
|
+
if (await isExecutableFile(candidate)) {
|
|
73
|
+
resolved = candidate;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Do not cache misses: installing a missing CLI should take effect on the next
|
|
78
|
+
// explicit refresh without requiring a plugin reload.
|
|
79
|
+
if (resolved !== null) executableCache.set(name, resolved);
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test seam: lets unit tests exercise resolution failures without touching `PATH`. */
|
|
84
|
+
export function primeExecutableCache(name: string, value: string | null): void {
|
|
85
|
+
executableCache.set(name, value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clearExecutableCache(): void {
|
|
89
|
+
executableCache.clear();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Terminates every process this module still owns. Used by plugin cleanup. */
|
|
93
|
+
export function killActiveProcesses(): void {
|
|
94
|
+
for (const child of activeProcesses) {
|
|
95
|
+
child.kill("SIGKILL");
|
|
96
|
+
}
|
|
97
|
+
activeProcesses.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type BeadsEnvironment = Readonly<
|
|
101
|
+
Partial<Record<"BEADS_DIR" | "BEADS_DB" | "BEADS_JSONL" | "BD_DB", string | null>>
|
|
102
|
+
>;
|
|
103
|
+
|
|
104
|
+
function childEnvironment(overrides: BeadsEnvironment | undefined): NodeJS.ProcessEnv {
|
|
105
|
+
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: "1", CLICOLOR: "0" };
|
|
106
|
+
if (overrides === undefined) return env;
|
|
107
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
108
|
+
if (value === null) delete env[key];
|
|
109
|
+
else env[key] = value;
|
|
110
|
+
}
|
|
111
|
+
return env;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SpawnRequest {
|
|
115
|
+
/** Absolute path to the executable. Never a shell string. */
|
|
116
|
+
readonly executable: string;
|
|
117
|
+
/** Literal argv. Values are passed verbatim; no interpolation, no shell. */
|
|
118
|
+
readonly args: readonly string[];
|
|
119
|
+
readonly cwd: string;
|
|
120
|
+
readonly limits?: CommandLimits;
|
|
121
|
+
readonly env?: BeadsEnvironment;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Spawns a process with a literal argv, a fixed cwd, a hard timeout, and an
|
|
126
|
+
* output cap. `shell` is never enabled.
|
|
127
|
+
*/
|
|
128
|
+
export async function runProcess(request: SpawnRequest): Promise<ProcessOutcome> {
|
|
129
|
+
const limits = request.limits ?? DEFAULT_LIMITS;
|
|
130
|
+
return await new Promise<ProcessOutcome>((resolve, reject) => {
|
|
131
|
+
const child = spawn(request.executable, [...request.args], {
|
|
132
|
+
cwd: request.cwd,
|
|
133
|
+
shell: false,
|
|
134
|
+
windowsHide: true,
|
|
135
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
+
env: childEnvironment(request.env),
|
|
137
|
+
});
|
|
138
|
+
activeProcesses.add(child);
|
|
139
|
+
|
|
140
|
+
let stdout = "";
|
|
141
|
+
let stderr = "";
|
|
142
|
+
let stdoutBytes = 0;
|
|
143
|
+
let truncated = false;
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let settled = false;
|
|
146
|
+
let killGraceTimer: NodeJS.Timeout | null = null;
|
|
147
|
+
|
|
148
|
+
const finish = (outcome: ProcessOutcome): void => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
if (killGraceTimer !== null) clearTimeout(killGraceTimer);
|
|
153
|
+
activeProcesses.delete(child);
|
|
154
|
+
resolve(outcome);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// A grandchild can inherit the pipes and prevent `close` after this child is
|
|
158
|
+
// killed. Bound that case too, otherwise one timeout can pin the per-workspace queue.
|
|
159
|
+
const killAndBoundClose = (): void => {
|
|
160
|
+
child.kill("SIGKILL");
|
|
161
|
+
if (killGraceTimer !== null) return;
|
|
162
|
+
killGraceTimer = setTimeout(() => {
|
|
163
|
+
child.stdout?.destroy();
|
|
164
|
+
child.stderr?.destroy();
|
|
165
|
+
finish({
|
|
166
|
+
exitCode: child.exitCode,
|
|
167
|
+
signal: child.signalCode,
|
|
168
|
+
stdout,
|
|
169
|
+
stderr,
|
|
170
|
+
timedOut,
|
|
171
|
+
truncated,
|
|
172
|
+
});
|
|
173
|
+
}, 250);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
killAndBoundClose();
|
|
179
|
+
}, limits.timeoutMs);
|
|
180
|
+
|
|
181
|
+
child.stdout?.setEncoding("utf8");
|
|
182
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
183
|
+
stdoutBytes += Buffer.byteLength(chunk, "utf8");
|
|
184
|
+
if (stdoutBytes > limits.maxOutputBytes) {
|
|
185
|
+
if (!truncated) {
|
|
186
|
+
truncated = true;
|
|
187
|
+
killAndBoundClose();
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
stdout += chunk;
|
|
192
|
+
});
|
|
193
|
+
child.stderr?.setEncoding("utf8");
|
|
194
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
195
|
+
if (stderr.length < 8192) stderr += chunk;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
child.on("error", (error: Error) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
if (killGraceTimer !== null) clearTimeout(killGraceTimer);
|
|
203
|
+
activeProcesses.delete(child);
|
|
204
|
+
reject(error);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
child.on("close", (exitCode: number | null, signal: NodeJS.Signals | null) => {
|
|
208
|
+
finish({ exitCode, signal, stdout, stderr, timedOut, truncated });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Maps a raw process outcome onto the plugin's error taxonomy. Timeout, output
|
|
215
|
+
* overrun, non-zero exit, and malformed JSON stay distinguishable.
|
|
216
|
+
*/
|
|
217
|
+
export function interpretJsonOutcome<Value>(
|
|
218
|
+
label: string,
|
|
219
|
+
outcome: ProcessOutcome,
|
|
220
|
+
parse: (payload: unknown) => Value,
|
|
221
|
+
): CommandResult<Value> {
|
|
222
|
+
if (outcome.truncated) {
|
|
223
|
+
return failure("output_limit", `${label} produced more output than the plugin accepts.`, outcome.exitCode);
|
|
224
|
+
}
|
|
225
|
+
if (outcome.timedOut) {
|
|
226
|
+
return failure("timeout", `${label} timed out.`, outcome.exitCode);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const trimmed = outcome.stdout.trim();
|
|
230
|
+
if (trimmed.length === 0) {
|
|
231
|
+
const detail = summarizeStderr(outcome.stderr);
|
|
232
|
+
if (outcome.exitCode === 0) {
|
|
233
|
+
return failure("invalid_json", `${label} returned no output.`, outcome.exitCode);
|
|
234
|
+
}
|
|
235
|
+
return failure(
|
|
236
|
+
"exit",
|
|
237
|
+
detail.length > 0 ? `${label} failed: ${detail}` : `${label} failed with exit code ${outcome.exitCode ?? "unknown"}.`,
|
|
238
|
+
outcome.exitCode,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let payload: unknown;
|
|
243
|
+
try {
|
|
244
|
+
payload = JSON.parse(trimmed) as unknown;
|
|
245
|
+
} catch {
|
|
246
|
+
if (outcome.exitCode !== 0) {
|
|
247
|
+
const detail = summarizeStderr(outcome.stderr);
|
|
248
|
+
return failure(
|
|
249
|
+
"exit",
|
|
250
|
+
detail.length > 0 ? `${label} failed: ${detail}` : `${label} failed with exit code ${outcome.exitCode ?? "unknown"}.`,
|
|
251
|
+
outcome.exitCode,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return failure("invalid_json", `${label} returned output that is not JSON.`, outcome.exitCode);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (outcome.exitCode !== 0) {
|
|
258
|
+
const record = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload as Record<string, unknown> : null;
|
|
259
|
+
const payloadError = typeof record?.["error"] === "string" ? record["error"].trim() : "";
|
|
260
|
+
const stderrDetail = summarizeStderr(outcome.stderr);
|
|
261
|
+
const detail = payloadError || stderrDetail;
|
|
262
|
+
return failure(
|
|
263
|
+
"exit",
|
|
264
|
+
detail.length > 0 ? `${label} failed: ${detail}` : `${label} failed with exit code ${outcome.exitCode}.`,
|
|
265
|
+
outcome.exitCode,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
return { ok: true, value: parse(payload) };
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const message = error instanceof Error ? error.message : "unrecognized payload";
|
|
273
|
+
return failure("invalid_json", `${label} returned an unexpected payload: ${message}`, outcome.exitCode);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface JsonCommandRequest {
|
|
278
|
+
readonly label: string;
|
|
279
|
+
readonly executableName: string;
|
|
280
|
+
readonly args: readonly string[];
|
|
281
|
+
readonly cwd: string;
|
|
282
|
+
readonly limits?: CommandLimits;
|
|
283
|
+
readonly env?: BeadsEnvironment;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Resolves the executable, runs it, and interprets its stdout as JSON. */
|
|
287
|
+
export async function runJsonCommand<Value>(
|
|
288
|
+
request: JsonCommandRequest,
|
|
289
|
+
parse: (payload: unknown) => Value,
|
|
290
|
+
): Promise<CommandResult<Value>> {
|
|
291
|
+
const executable = await resolveExecutable(request.executableName);
|
|
292
|
+
if (executable === null) {
|
|
293
|
+
return failure("unavailable", `${request.executableName} was not found on the daemon PATH.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let outcome: ProcessOutcome;
|
|
297
|
+
try {
|
|
298
|
+
outcome = await runProcess({
|
|
299
|
+
executable,
|
|
300
|
+
args: request.args,
|
|
301
|
+
cwd: request.cwd,
|
|
302
|
+
limits: request.limits,
|
|
303
|
+
env: request.env,
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message = error instanceof Error ? error.message : "spawn failed";
|
|
307
|
+
return failure("unavailable", `${request.label} could not start: ${message}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return interpretJsonOutcome(request.label, outcome, parse);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Runs a command purely for its text output, e.g. `--version`. */
|
|
314
|
+
export async function runTextCommand(request: JsonCommandRequest): Promise<CommandResult<string>> {
|
|
315
|
+
const executable = await resolveExecutable(request.executableName);
|
|
316
|
+
if (executable === null) {
|
|
317
|
+
return failure("unavailable", `${request.executableName} was not found on the daemon PATH.`);
|
|
318
|
+
}
|
|
319
|
+
let outcome: ProcessOutcome;
|
|
320
|
+
try {
|
|
321
|
+
outcome = await runProcess({
|
|
322
|
+
executable,
|
|
323
|
+
args: request.args,
|
|
324
|
+
cwd: request.cwd,
|
|
325
|
+
limits: request.limits,
|
|
326
|
+
env: request.env,
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const message = error instanceof Error ? error.message : "spawn failed";
|
|
330
|
+
return failure("unavailable", `${request.label} could not start: ${message}`);
|
|
331
|
+
}
|
|
332
|
+
if (outcome.truncated) return failure("output_limit", `${request.label} produced more output than the plugin accepts.`, outcome.exitCode);
|
|
333
|
+
if (outcome.timedOut) return failure("timeout", `${request.label} timed out.`, outcome.exitCode);
|
|
334
|
+
if (outcome.exitCode !== 0) {
|
|
335
|
+
const detail = summarizeStderr(outcome.stderr);
|
|
336
|
+
return failure(
|
|
337
|
+
"exit",
|
|
338
|
+
detail.length > 0 ? `${request.label} failed: ${detail}` : `${request.label} failed.`,
|
|
339
|
+
outcome.exitCode,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, value: outcome.stdout.trim() };
|
|
343
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import type { CommandError, SectionState } from "../shared/beads";
|
|
4
|
+
import { dashboardRpc } from "../shared/rpc";
|
|
5
|
+
import { runBvJson, runBvVersion, runTrackerFacets } from "./bv";
|
|
6
|
+
import { ExpiringCache } from "./cache";
|
|
7
|
+
import type { CommandResult } from "./command";
|
|
8
|
+
import {
|
|
9
|
+
EMPTY_FACETS,
|
|
10
|
+
normalizeAlertSummary,
|
|
11
|
+
normalizeAlerts,
|
|
12
|
+
normalizeBlockers,
|
|
13
|
+
normalizeBoardIssues,
|
|
14
|
+
normalizeCounts,
|
|
15
|
+
normalizeHealth,
|
|
16
|
+
normalizePlanSummary,
|
|
17
|
+
normalizeRecommendations,
|
|
18
|
+
normalizeSource,
|
|
19
|
+
normalizeTracks,
|
|
20
|
+
parseTrackerFacets,
|
|
21
|
+
readPayloadError,
|
|
22
|
+
type TrackerFacets,
|
|
23
|
+
} from "./normalize";
|
|
24
|
+
import { rememberTracker, resolveTrackerFromPayload, type TrackerResolution } from "./tracker";
|
|
25
|
+
import { resolveWorkspaceTarget, UNKNOWN_TRACKER } from "./workspace";
|
|
26
|
+
|
|
27
|
+
type DashboardOutput = RpcOutput<typeof dashboardRpc>;
|
|
28
|
+
|
|
29
|
+
const DASHBOARD_TTL_MS = 15_000;
|
|
30
|
+
const dashboardCache = new ExpiringCache<DashboardOutput>(DASHBOARD_TTL_MS);
|
|
31
|
+
const dashboardGenerations = new ExpiringCache<number>(300_000);
|
|
32
|
+
|
|
33
|
+
export function clearDashboardCache(): void {
|
|
34
|
+
dashboardCache.clear();
|
|
35
|
+
dashboardGenerations.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OK_SECTION: SectionState = { status: "ok", error: null };
|
|
39
|
+
|
|
40
|
+
/** No graph read happened, so the board has nothing rather than a guess. */
|
|
41
|
+
function emptyBoard(): DashboardOutput["board"] {
|
|
42
|
+
return { issues: [], typed: false, total: 0, truncated: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function degraded(error: CommandError): SectionState {
|
|
46
|
+
return { status: "unavailable", error };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sectionOf(result: CommandResult<unknown>): SectionState {
|
|
50
|
+
if (!result.ok) return degraded(result.error);
|
|
51
|
+
const payloadError = readPayloadError(result.value);
|
|
52
|
+
return payloadError === null
|
|
53
|
+
? OK_SECTION
|
|
54
|
+
: degraded({ code: "exit", message: payloadError, exitCode: null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function identity(payload: unknown): unknown {
|
|
58
|
+
return payload;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A directory without a Beads project makes `bv` exit non-zero while still
|
|
63
|
+
* printing a valid JSON envelope carrying a top-level `error`. When it cannot
|
|
64
|
+
* even print JSON, the exit message is matched instead. Either way this is an
|
|
65
|
+
* ordinary, representable state rather than a plugin failure.
|
|
66
|
+
*/
|
|
67
|
+
function isMissingProject(error: CommandError): boolean {
|
|
68
|
+
if (error.code !== "exit") return false;
|
|
69
|
+
return /beads directory|br init|bd init|no such file|not initialized/i.test(error.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ProjectState = "ready" | "missing" | "error";
|
|
73
|
+
|
|
74
|
+
export interface ProjectClassification {
|
|
75
|
+
readonly projectState: ProjectState;
|
|
76
|
+
readonly triage: SectionState;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Separates "no Beads project here" and "the triage read failed" from
|
|
81
|
+
* "healthy project that happens to be empty".
|
|
82
|
+
*/
|
|
83
|
+
export function classifyProject(triage: CommandResult<unknown>): ProjectClassification {
|
|
84
|
+
if (!triage.ok) {
|
|
85
|
+
return {
|
|
86
|
+
projectState: isMissingProject(triage.error) ? "missing" : "error",
|
|
87
|
+
triage: degraded(triage.error),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const loadError = readPayloadError(triage.value);
|
|
91
|
+
if (loadError !== null) {
|
|
92
|
+
const error: CommandError = { code: "exit", message: loadError, exitCode: null };
|
|
93
|
+
return { projectState: isMissingProject(error) ? "missing" : "error", triage: degraded(error) };
|
|
94
|
+
}
|
|
95
|
+
return { projectState: "ready", triage: OK_SECTION };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function getDashboard(
|
|
99
|
+
input: RpcInput<typeof dashboardRpc>,
|
|
100
|
+
context: PluginHandlerContext,
|
|
101
|
+
): Promise<DashboardOutput> {
|
|
102
|
+
const previousGeneration = dashboardGenerations.get(input.workspaceId) ?? 0;
|
|
103
|
+
const requestGeneration = input.refresh === true ? previousGeneration + 1 : previousGeneration;
|
|
104
|
+
if (input.refresh === true) dashboardGenerations.set(input.workspaceId, requestGeneration);
|
|
105
|
+
|
|
106
|
+
const fetchedAt = new Date().toISOString();
|
|
107
|
+
const workspace = await resolveWorkspaceTarget(context, input.workspaceId);
|
|
108
|
+
if (!workspace.ok) {
|
|
109
|
+
return {
|
|
110
|
+
workspaceId: input.workspaceId,
|
|
111
|
+
directory: null,
|
|
112
|
+
tool: { available: false, version: null, error: workspace.error },
|
|
113
|
+
tracker: UNKNOWN_TRACKER,
|
|
114
|
+
projectState: "error",
|
|
115
|
+
source: null,
|
|
116
|
+
counts: null,
|
|
117
|
+
health: null,
|
|
118
|
+
recommendations: [],
|
|
119
|
+
blockers: [],
|
|
120
|
+
board: emptyBoard(),
|
|
121
|
+
tracks: [],
|
|
122
|
+
planSummary: null,
|
|
123
|
+
alerts: [],
|
|
124
|
+
alertSummary: null,
|
|
125
|
+
sections: {
|
|
126
|
+
triage: degraded(workspace.error),
|
|
127
|
+
plan: degraded(workspace.error),
|
|
128
|
+
alerts: degraded(workspace.error),
|
|
129
|
+
graph: degraded(workspace.error),
|
|
130
|
+
},
|
|
131
|
+
fetchedAt,
|
|
132
|
+
cached: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { directory } = workspace.value;
|
|
137
|
+
const cacheKey = `${input.workspaceId}\0${directory}`;
|
|
138
|
+
if (input.refresh === true) {
|
|
139
|
+
dashboardCache.delete(cacheKey);
|
|
140
|
+
} else {
|
|
141
|
+
const cached = dashboardCache.get(cacheKey);
|
|
142
|
+
if (cached !== null) return { ...cached, cached: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const version = await runBvVersion(directory);
|
|
146
|
+
if (!version.ok) {
|
|
147
|
+
return {
|
|
148
|
+
workspaceId: input.workspaceId,
|
|
149
|
+
directory,
|
|
150
|
+
tool: { available: false, version: null, error: version.error },
|
|
151
|
+
tracker: UNKNOWN_TRACKER,
|
|
152
|
+
projectState: "error",
|
|
153
|
+
source: null,
|
|
154
|
+
counts: null,
|
|
155
|
+
health: null,
|
|
156
|
+
recommendations: [],
|
|
157
|
+
blockers: [],
|
|
158
|
+
board: emptyBoard(),
|
|
159
|
+
tracks: [],
|
|
160
|
+
planSummary: null,
|
|
161
|
+
alerts: [],
|
|
162
|
+
alertSummary: null,
|
|
163
|
+
sections: {
|
|
164
|
+
triage: degraded(version.error),
|
|
165
|
+
plan: degraded(version.error),
|
|
166
|
+
alerts: degraded(version.error),
|
|
167
|
+
graph: degraded(version.error),
|
|
168
|
+
},
|
|
169
|
+
fetchedAt,
|
|
170
|
+
cached: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// These analyses are logically independent; server/bv serializes their subprocesses per workspace to avoid bd export races.
|
|
175
|
+
const [triage, plan, alerts, graph] = await Promise.all([
|
|
176
|
+
runBvJson("triage", directory, identity),
|
|
177
|
+
runBvJson("plan", directory, identity),
|
|
178
|
+
runBvJson("alerts", directory, identity),
|
|
179
|
+
runBvJson("graph", directory, identity),
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const triagePayload = triage.ok ? triage.value : null;
|
|
183
|
+
const planPayload = plan.ok ? plan.value : null;
|
|
184
|
+
const alertsPayload = alerts.ok ? alerts.value : null;
|
|
185
|
+
const graphSection = sectionOf(graph);
|
|
186
|
+
const graphPayload = graphSection.status === "ok" && graph.ok ? graph.value : null;
|
|
187
|
+
|
|
188
|
+
const classification = classifyProject(triage);
|
|
189
|
+
const projectState = classification.projectState;
|
|
190
|
+
|
|
191
|
+
const source = normalizeSource(triagePayload) ?? normalizeSource(planPayload) ?? normalizeSource(alertsPayload);
|
|
192
|
+
const trackerResolution: TrackerResolution =
|
|
193
|
+
projectState === "ready"
|
|
194
|
+
? await resolveTrackerFromPayload(triagePayload, directory)
|
|
195
|
+
: { state: UNKNOWN_TRACKER, route: null };
|
|
196
|
+
const tracker = trackerResolution.state;
|
|
197
|
+
if (tracker.kind !== null) rememberTracker(input.workspaceId, directory, trackerResolution);
|
|
198
|
+
|
|
199
|
+
// The graph carries no type or assignee, so the type and assignee facets
|
|
200
|
+
// depend on this overlay. A tracker that is absent or rejects the flags costs
|
|
201
|
+
// those two facets and nothing else; it never fails the dashboard.
|
|
202
|
+
let facets: TrackerFacets = EMPTY_FACETS;
|
|
203
|
+
if (trackerResolution.route !== null && graphPayload !== null) {
|
|
204
|
+
const csv = await runTrackerFacets(trackerResolution.route, directory);
|
|
205
|
+
if (csv.ok) facets = parseTrackerFacets(csv.value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const triageSection = classification.triage;
|
|
209
|
+
|
|
210
|
+
const output: DashboardOutput = {
|
|
211
|
+
workspaceId: input.workspaceId,
|
|
212
|
+
directory,
|
|
213
|
+
tool: { available: true, version: version.value, error: null },
|
|
214
|
+
tracker,
|
|
215
|
+
projectState,
|
|
216
|
+
source,
|
|
217
|
+
counts: normalizeCounts(triagePayload),
|
|
218
|
+
health: normalizeHealth(triagePayload),
|
|
219
|
+
recommendations: normalizeRecommendations(triagePayload),
|
|
220
|
+
blockers: normalizeBlockers(triagePayload),
|
|
221
|
+
board: normalizeBoardIssues(graphPayload, facets),
|
|
222
|
+
tracks: normalizeTracks(planPayload),
|
|
223
|
+
planSummary: normalizePlanSummary(planPayload),
|
|
224
|
+
alerts: normalizeAlerts(alertsPayload),
|
|
225
|
+
alertSummary: normalizeAlertSummary(alertsPayload),
|
|
226
|
+
sections: {
|
|
227
|
+
triage: triageSection,
|
|
228
|
+
plan: sectionOf(plan),
|
|
229
|
+
alerts: sectionOf(alerts),
|
|
230
|
+
graph: graphSection,
|
|
231
|
+
},
|
|
232
|
+
fetchedAt,
|
|
233
|
+
cached: false,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Only a fully readable, non-superseded snapshot is worth reusing.
|
|
237
|
+
if (
|
|
238
|
+
triageSection.status === "ok" &&
|
|
239
|
+
(dashboardGenerations.get(input.workspaceId) ?? 0) === requestGeneration
|
|
240
|
+
) {
|
|
241
|
+
dashboardCache.set(cacheKey, output);
|
|
242
|
+
}
|
|
243
|
+
return output;
|
|
244
|
+
}
|
package/server/issue.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import type { IssueDetail } from "../shared/beads";
|
|
4
|
+
import { issueRpc } from "../shared/rpc";
|
|
5
|
+
import { runTrackerShow } from "./bv";
|
|
6
|
+
import { failure, type CommandResult } from "./command";
|
|
7
|
+
import { normalizeIssueDetail } from "./normalize";
|
|
8
|
+
import { resolveTracker } from "./tracker";
|
|
9
|
+
import { resolveWorkspaceTarget } from "./workspace";
|
|
10
|
+
|
|
11
|
+
type IssueOutput = RpcOutput<typeof issueRpc>;
|
|
12
|
+
|
|
13
|
+
function parseDetail(payload: unknown): IssueDetail {
|
|
14
|
+
const detail = normalizeIssueDetail(payload);
|
|
15
|
+
if (detail === null) throw new Error("no issue record in the response");
|
|
16
|
+
return detail;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read-only detail read. There is no mutation path in this plugin. */
|
|
20
|
+
export async function readIssueDetail(input: {
|
|
21
|
+
readonly workspaceId: string;
|
|
22
|
+
readonly directory: string;
|
|
23
|
+
readonly issueId: string;
|
|
24
|
+
}): Promise<{ readonly tracker: IssueOutput["tracker"]; readonly result: CommandResult<IssueDetail> }> {
|
|
25
|
+
const resolution = await resolveTracker(input.workspaceId, input.directory);
|
|
26
|
+
const tracker = resolution.state;
|
|
27
|
+
if (resolution.route === null || tracker.kind === null || !tracker.available) {
|
|
28
|
+
return {
|
|
29
|
+
tracker,
|
|
30
|
+
result: failure(
|
|
31
|
+
"tracker_unknown",
|
|
32
|
+
tracker.detail ?? "The Beads tracker CLI for this workspace could not be established.",
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const result = await runTrackerShow(resolution.route, input.directory, input.issueId, parseDetail);
|
|
37
|
+
return { tracker, result };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getIssue(
|
|
41
|
+
input: RpcInput<typeof issueRpc>,
|
|
42
|
+
context: PluginHandlerContext,
|
|
43
|
+
): Promise<IssueOutput> {
|
|
44
|
+
const workspace = await resolveWorkspaceTarget(context, input.workspaceId);
|
|
45
|
+
if (!workspace.ok) {
|
|
46
|
+
return { issueId: input.issueId, tracker: { kind: null, available: false, detail: null }, issue: null, error: workspace.error };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { tracker, result } = await readIssueDetail({
|
|
50
|
+
workspaceId: input.workspaceId,
|
|
51
|
+
directory: workspace.value.directory,
|
|
52
|
+
issueId: input.issueId,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
return { issueId: input.issueId, tracker, issue: null, error: result.error };
|
|
57
|
+
}
|
|
58
|
+
return { issueId: input.issueId, tracker, issue: result.value, error: null };
|
|
59
|
+
}
|