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
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "paseo-beads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only Beads console for Paseo: project progress, ready and waiting work, the critical chain, and a whole-project board, read through bv.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "cuongntr",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"paseo",
|
|
9
|
+
"paseo-plugin",
|
|
10
|
+
"beads",
|
|
11
|
+
"bv",
|
|
12
|
+
"issue-tracker",
|
|
13
|
+
"kanban"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/cuongntr/paseo-beads-viewer.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/cuongntr/paseo-beads-viewer#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/cuongntr/paseo-beads-viewer/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"client",
|
|
28
|
+
"images",
|
|
29
|
+
"server",
|
|
30
|
+
"shared",
|
|
31
|
+
"index.client.tsx",
|
|
32
|
+
"index.server.ts",
|
|
33
|
+
"README.md",
|
|
34
|
+
"paseo-plugin.json",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@getpaseo/plugin": "0.8.0",
|
|
44
|
+
"@tanstack/react-query": "^5.90.11",
|
|
45
|
+
"@types/node": "26.6.1",
|
|
46
|
+
"@types/react": "~19.2.0",
|
|
47
|
+
"react": "19.1.0",
|
|
48
|
+
"react-native": "0.81.5",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "4.1.6",
|
|
51
|
+
"zod": "^4.4.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { PluginAttachmentItem, RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { access, constants } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
ATTACHMENT_RESOURCE_TYPE,
|
|
7
|
+
ATTACHMENT_RESULT_LIMIT,
|
|
8
|
+
buildIssueUrl,
|
|
9
|
+
type SearchResult,
|
|
10
|
+
} from "../shared/beads";
|
|
11
|
+
import { attachmentSearchRpc } from "../shared/rpc";
|
|
12
|
+
import { clampSearchLimit, runBvJson, sanitizeSearchQuery } from "./bv";
|
|
13
|
+
import { buildIssueSnapshot, normalizeSearchResults } from "./normalize";
|
|
14
|
+
import { readIssueDetail } from "./issue";
|
|
15
|
+
|
|
16
|
+
type AttachmentOutput = RpcOutput<typeof attachmentSearchRpc>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attachment search runs without workspace context, so it discovers candidates
|
|
20
|
+
* itself. Every bound below exists to keep subprocess fanout finite: at most
|
|
21
|
+
* WORKSPACE_SCAN_LIMIT workspaces are inspected, WORKSPACE_SEARCH_LIMIT of them
|
|
22
|
+
* are searched, and at most ATTACHMENT_RESULT_LIMIT detail reads follow.
|
|
23
|
+
*/
|
|
24
|
+
const WORKSPACE_SCAN_LIMIT = 12;
|
|
25
|
+
const WORKSPACE_SEARCH_LIMIT = 4;
|
|
26
|
+
const PER_WORKSPACE_SEARCH_LIMIT = 5;
|
|
27
|
+
const SEARCH_CONCURRENCY = 2;
|
|
28
|
+
const DETAIL_CONCURRENCY = 3;
|
|
29
|
+
|
|
30
|
+
interface Candidate {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly directory: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Presence of a `.beads` directory. The plugin never reads its contents. */
|
|
37
|
+
async function hasBeadsDirectory(directory: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
await access(join(directory, ".beads"), constants.R_OK);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function mapWithConcurrency<Input, Output>(
|
|
47
|
+
inputs: readonly Input[],
|
|
48
|
+
concurrency: number,
|
|
49
|
+
run: (input: Input) => Promise<Output>,
|
|
50
|
+
): Promise<Output[]> {
|
|
51
|
+
const results: Output[] = new Array<Output>(inputs.length);
|
|
52
|
+
let cursor = 0;
|
|
53
|
+
const workers = Array.from({ length: Math.min(concurrency, inputs.length) }, async () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const index = cursor;
|
|
56
|
+
cursor += 1;
|
|
57
|
+
if (index >= inputs.length) return;
|
|
58
|
+
const input = inputs[index];
|
|
59
|
+
if (input === undefined) return;
|
|
60
|
+
results[index] = await run(input);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
await Promise.all(workers);
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findCandidates(context: PluginHandlerContext): Promise<Candidate[]> {
|
|
68
|
+
let entries: Awaited<ReturnType<PluginHandlerContext["paseo"]["workspaces"]["list"]>>["entries"];
|
|
69
|
+
try {
|
|
70
|
+
const listed = await context.paseo.workspaces.list({
|
|
71
|
+
page: { limit: WORKSPACE_SCAN_LIMIT },
|
|
72
|
+
sort: [{ key: "activity_at", direction: "desc" }],
|
|
73
|
+
});
|
|
74
|
+
entries = listed.entries;
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const unique: Candidate[] = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const directory = entry.workspaceDirectory;
|
|
83
|
+
if (typeof directory !== "string" || !isAbsolute(directory)) continue;
|
|
84
|
+
if (seen.has(directory)) continue;
|
|
85
|
+
seen.add(directory);
|
|
86
|
+
unique.push({ id: entry.id, name: entry.title ?? entry.name, directory });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const enabled = await mapWithConcurrency(unique, DETAIL_CONCURRENCY, async (candidate) => ({
|
|
90
|
+
candidate,
|
|
91
|
+
enabled: await hasBeadsDirectory(candidate.directory),
|
|
92
|
+
}));
|
|
93
|
+
return enabled
|
|
94
|
+
.filter((entry) => entry.enabled)
|
|
95
|
+
.map((entry) => entry.candidate)
|
|
96
|
+
.slice(0, WORKSPACE_SEARCH_LIMIT);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Hit {
|
|
100
|
+
readonly candidate: Candidate;
|
|
101
|
+
readonly result: SearchResult;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function searchAttachments(
|
|
105
|
+
input: RpcInput<typeof attachmentSearchRpc>,
|
|
106
|
+
context: PluginHandlerContext,
|
|
107
|
+
): Promise<AttachmentOutput> {
|
|
108
|
+
const query = sanitizeSearchQuery(input.query);
|
|
109
|
+
if (query === null) return { items: [] };
|
|
110
|
+
|
|
111
|
+
const candidates = await findCandidates(context);
|
|
112
|
+
if (candidates.length === 0) return { items: [] };
|
|
113
|
+
|
|
114
|
+
const limit = clampSearchLimit(PER_WORKSPACE_SEARCH_LIMIT);
|
|
115
|
+
// One failing workspace must never remove the other workspaces' results.
|
|
116
|
+
const perWorkspace = await mapWithConcurrency(candidates, SEARCH_CONCURRENCY, async (candidate) => {
|
|
117
|
+
const result = await runBvJson(
|
|
118
|
+
"search",
|
|
119
|
+
candidate.directory,
|
|
120
|
+
(payload) => normalizeSearchResults(payload, limit),
|
|
121
|
+
{ query, limit },
|
|
122
|
+
);
|
|
123
|
+
return result.ok ? result.value.map((entry) => ({ candidate, result: entry })) : [];
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const hits: Hit[] = [];
|
|
128
|
+
for (const group of perWorkspace) {
|
|
129
|
+
for (const hit of group) {
|
|
130
|
+
const key = `${hit.candidate.id}:${hit.result.id}`;
|
|
131
|
+
if (seen.has(key)) continue;
|
|
132
|
+
seen.add(key);
|
|
133
|
+
hits.push(hit);
|
|
134
|
+
if (hits.length >= ATTACHMENT_RESULT_LIMIT) break;
|
|
135
|
+
}
|
|
136
|
+
if (hits.length >= ATTACHMENT_RESULT_LIMIT) break;
|
|
137
|
+
}
|
|
138
|
+
if (hits.length === 0) return { items: [] };
|
|
139
|
+
|
|
140
|
+
const items = await mapWithConcurrency(hits, DETAIL_CONCURRENCY, async (hit): Promise<PluginAttachmentItem> => {
|
|
141
|
+
const detail = await readIssueDetail({
|
|
142
|
+
workspaceId: hit.candidate.id,
|
|
143
|
+
directory: hit.candidate.directory,
|
|
144
|
+
issueId: hit.result.id,
|
|
145
|
+
});
|
|
146
|
+
const issue = detail.result.ok ? detail.result.value : null;
|
|
147
|
+
const title = issue?.title ?? hit.result.title;
|
|
148
|
+
return {
|
|
149
|
+
id: `${hit.candidate.id}:${hit.result.id}`,
|
|
150
|
+
identifier: hit.result.id,
|
|
151
|
+
title,
|
|
152
|
+
subtitle: `${hit.candidate.name}${issue === null ? "" : ` · ${issue.status}`}`,
|
|
153
|
+
url: buildIssueUrl(hit.candidate.id, hit.result.id),
|
|
154
|
+
text: buildIssueSnapshot({
|
|
155
|
+
workspaceName: hit.candidate.name,
|
|
156
|
+
workspaceDirectory: hit.candidate.directory,
|
|
157
|
+
issueId: hit.result.id,
|
|
158
|
+
title,
|
|
159
|
+
detail: issue,
|
|
160
|
+
}),
|
|
161
|
+
resourceType: ATTACHMENT_RESOURCE_TYPE,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { items };
|
|
166
|
+
}
|
package/server/bv.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ISSUE_ID_MAX_LENGTH,
|
|
3
|
+
SEARCH_LIMIT_DEFAULT,
|
|
4
|
+
SEARCH_LIMIT_MAX,
|
|
5
|
+
SEARCH_LIMIT_MIN,
|
|
6
|
+
SEARCH_QUERY_MAX_LENGTH,
|
|
7
|
+
} from "../shared/beads";
|
|
8
|
+
import { failure, runJsonCommand, runTextCommand, type CommandResult, type JsonCommandRequest } from "./command";
|
|
9
|
+
import type { TrackerRoute } from "./tracker";
|
|
10
|
+
|
|
11
|
+
export const BV_EXECUTABLE = "bv";
|
|
12
|
+
const JSON_FORMAT_ARGS = ["--format", "json"] as const;
|
|
13
|
+
const CLEAN_BEADS_ENV = { BEADS_DIR: null, BEADS_DB: null, BEADS_JSONL: null, BD_DB: null } as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Only these read-only `bv` invocations exist in the plugin. Every argv is
|
|
17
|
+
* literal; nothing is templated from user input except the bounded search
|
|
18
|
+
* query and limit below, which are passed as separate argv values.
|
|
19
|
+
*/
|
|
20
|
+
export type BvRobotCommand = "version" | "triage" | "plan" | "alerts" | "graph" | "search";
|
|
21
|
+
|
|
22
|
+
interface BvInvocation {
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly args: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `bv` refreshes the compatibility export in bd/Dolt workspaces. Serialize
|
|
28
|
+
// invocations per workspace so dashboard and attachment reads cannot race on it.
|
|
29
|
+
const workspaceCommandTails = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
31
|
+
async function serializeWorkspaceCommand<Value>(cwd: string, run: () => Promise<Value>): Promise<Value> {
|
|
32
|
+
const previous = workspaceCommandTails.get(cwd) ?? Promise.resolve();
|
|
33
|
+
let release: () => void = () => {};
|
|
34
|
+
const gate = new Promise<void>((resolve) => {
|
|
35
|
+
release = resolve;
|
|
36
|
+
});
|
|
37
|
+
const tail = previous.catch(() => {}).then(() => gate);
|
|
38
|
+
workspaceCommandTails.set(cwd, tail);
|
|
39
|
+
await previous.catch(() => {});
|
|
40
|
+
try {
|
|
41
|
+
return await run();
|
|
42
|
+
} finally {
|
|
43
|
+
release();
|
|
44
|
+
if (workspaceCommandTails.get(cwd) === tail) workspaceCommandTails.delete(cwd);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BvSearchParams {
|
|
49
|
+
readonly query: string;
|
|
50
|
+
readonly limit: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collapses whitespace and enforces the shared query bound. */
|
|
54
|
+
export function sanitizeSearchQuery(raw: string): string | null {
|
|
55
|
+
const collapsed = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
56
|
+
if (collapsed.length === 0) return null;
|
|
57
|
+
return collapsed.slice(0, SEARCH_QUERY_MAX_LENGTH);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function clampSearchLimit(raw: number | undefined): number {
|
|
61
|
+
if (raw === undefined || !Number.isFinite(raw)) return SEARCH_LIMIT_DEFAULT;
|
|
62
|
+
const rounded = Math.trunc(raw);
|
|
63
|
+
return Math.min(SEARCH_LIMIT_MAX, Math.max(SEARCH_LIMIT_MIN, rounded));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function bvInvocation(command: BvRobotCommand, params?: BvSearchParams): BvInvocation {
|
|
67
|
+
switch (command) {
|
|
68
|
+
case "version":
|
|
69
|
+
return { label: "bv --version", args: ["--version"] };
|
|
70
|
+
case "triage":
|
|
71
|
+
return { label: "bv --robot-triage", args: ["--robot-triage", ...JSON_FORMAT_ARGS] };
|
|
72
|
+
case "plan":
|
|
73
|
+
return { label: "bv --robot-plan", args: ["--robot-plan", ...JSON_FORMAT_ARGS] };
|
|
74
|
+
case "alerts":
|
|
75
|
+
return { label: "bv --robot-alerts", args: ["--robot-alerts", ...JSON_FORMAT_ARGS] };
|
|
76
|
+
case "graph":
|
|
77
|
+
// The only read that returns every issue, which is what the board needs;
|
|
78
|
+
// triage and plan both hand back analysis-selected subsets.
|
|
79
|
+
return {
|
|
80
|
+
label: "bv --robot-graph",
|
|
81
|
+
args: ["--robot-graph", "--graph-format", "json", ...JSON_FORMAT_ARGS],
|
|
82
|
+
};
|
|
83
|
+
case "search": {
|
|
84
|
+
if (params === undefined) {
|
|
85
|
+
throw new Error("bv --robot-search requires search parameters");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
label: "bv --robot-search",
|
|
89
|
+
args: [
|
|
90
|
+
"--robot-search",
|
|
91
|
+
`--search=${params.query}`,
|
|
92
|
+
`--search-limit=${clampSearchLimit(params.limit)}`,
|
|
93
|
+
...JSON_FORMAT_ARGS,
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function bvCommandRequest(
|
|
101
|
+
command: BvRobotCommand,
|
|
102
|
+
cwd: string,
|
|
103
|
+
params?: BvSearchParams,
|
|
104
|
+
): JsonCommandRequest {
|
|
105
|
+
const invocation = bvInvocation(command, params);
|
|
106
|
+
return {
|
|
107
|
+
label: invocation.label,
|
|
108
|
+
executableName: BV_EXECUTABLE,
|
|
109
|
+
args: invocation.args,
|
|
110
|
+
cwd,
|
|
111
|
+
env: CLEAN_BEADS_ENV,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Runs one allowlisted `bv` robot command in a fixed workspace directory. */
|
|
116
|
+
export async function runBvJson<Value>(
|
|
117
|
+
command: Exclude<BvRobotCommand, "version">,
|
|
118
|
+
cwd: string,
|
|
119
|
+
parse: (payload: unknown) => Value,
|
|
120
|
+
params?: BvSearchParams,
|
|
121
|
+
): Promise<CommandResult<Value>> {
|
|
122
|
+
const request = bvCommandRequest(command, cwd, params);
|
|
123
|
+
return await serializeWorkspaceCommand(cwd, async () => await runJsonCommand(request, parse));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runBvVersion(cwd: string): Promise<CommandResult<string>> {
|
|
127
|
+
const request = bvCommandRequest("version", cwd);
|
|
128
|
+
return await serializeWorkspaceCommand(cwd, async () => await runTextCommand(request));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function trackerShowInvocation(route: TrackerRoute, issueId: string) {
|
|
132
|
+
if (issueId.length > ISSUE_ID_MAX_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(issueId)) return null;
|
|
133
|
+
const safetyArgs = route.kind === "br" ? ["--no-auto-import", "--no-auto-flush"] : [];
|
|
134
|
+
return {
|
|
135
|
+
args: ["--db", route.database, ...safetyArgs, "show", "--json", "--", issueId],
|
|
136
|
+
env: {
|
|
137
|
+
BEADS_DIR: route.beadsDirectory,
|
|
138
|
+
BEADS_DB: route.database,
|
|
139
|
+
BEADS_JSONL: null,
|
|
140
|
+
BD_DB: route.database,
|
|
141
|
+
},
|
|
142
|
+
} as const;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The issue facets `bv --robot-graph` does not carry: type and assignee. CSV
|
|
147
|
+
* with an explicit `--fields` list is the only compact shape the trackers
|
|
148
|
+
* offer; their JSON form embeds every description and measured 2.5 MB for the
|
|
149
|
+
* same 778 issues this returns in 24 KB. The argv is literal and takes no user
|
|
150
|
+
* input, and the selected columns hold no free text, so no quoted field or
|
|
151
|
+
* embedded newline can appear.
|
|
152
|
+
*/
|
|
153
|
+
export function trackerFacetsInvocation(route: TrackerRoute) {
|
|
154
|
+
const safetyArgs = route.kind === "br" ? ["--no-auto-import", "--no-auto-flush"] : [];
|
|
155
|
+
return {
|
|
156
|
+
args: [
|
|
157
|
+
"--db",
|
|
158
|
+
route.database,
|
|
159
|
+
...safetyArgs,
|
|
160
|
+
"list",
|
|
161
|
+
"--status",
|
|
162
|
+
"all",
|
|
163
|
+
"--fields",
|
|
164
|
+
"id,issue_type,assignee",
|
|
165
|
+
"--format",
|
|
166
|
+
"csv",
|
|
167
|
+
],
|
|
168
|
+
env: {
|
|
169
|
+
BEADS_DIR: route.beadsDirectory,
|
|
170
|
+
BEADS_DB: route.database,
|
|
171
|
+
BEADS_JSONL: null,
|
|
172
|
+
BD_DB: route.database,
|
|
173
|
+
},
|
|
174
|
+
} as const;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Runs the facet read. A tracker that rejects these flags degrades to no overlay. */
|
|
178
|
+
export async function runTrackerFacets(route: TrackerRoute, cwd: string): Promise<CommandResult<string>> {
|
|
179
|
+
const invocation = trackerFacetsInvocation(route);
|
|
180
|
+
return await runTextCommand({
|
|
181
|
+
label: `${route.kind} list --format csv`,
|
|
182
|
+
executableName: route.kind,
|
|
183
|
+
args: invocation.args,
|
|
184
|
+
cwd,
|
|
185
|
+
env: invocation.env,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The only tracker invocation the plugin performs for one issue: a read-only
|
|
191
|
+
* detail read. `--` separates the id so an id can never be read as a flag.
|
|
192
|
+
*/
|
|
193
|
+
export async function runTrackerShow<Value>(
|
|
194
|
+
route: TrackerRoute,
|
|
195
|
+
cwd: string,
|
|
196
|
+
issueId: string,
|
|
197
|
+
parse: (payload: unknown) => Value,
|
|
198
|
+
): Promise<CommandResult<Value>> {
|
|
199
|
+
const invocation = trackerShowInvocation(route, issueId);
|
|
200
|
+
if (invocation === null) {
|
|
201
|
+
return failure("internal", "Refusing to query an issue id with unexpected characters.");
|
|
202
|
+
}
|
|
203
|
+
return await runJsonCommand(
|
|
204
|
+
{
|
|
205
|
+
label: `${route.kind} show --json`,
|
|
206
|
+
executableName: route.kind,
|
|
207
|
+
args: invocation.args,
|
|
208
|
+
cwd,
|
|
209
|
+
env: invocation.env,
|
|
210
|
+
},
|
|
211
|
+
parse,
|
|
212
|
+
);
|
|
213
|
+
}
|
package/server/cache.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived cache of normalized command results only. Nothing derived from
|
|
3
|
+
* graph analysis is stored, and entries are invalidated purely by expiry, so a
|
|
4
|
+
* stale dependency graph can never be served from here.
|
|
5
|
+
*/
|
|
6
|
+
interface CacheEntry<Value> {
|
|
7
|
+
readonly value: Value;
|
|
8
|
+
readonly expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ExpiringCache<Value> {
|
|
12
|
+
private readonly entries = new Map<string, CacheEntry<Value>>();
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly ttlMs: number,
|
|
16
|
+
private readonly maxEntries = 32,
|
|
17
|
+
private readonly now: () => number = () => Date.now(),
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
get(key: string): Value | null {
|
|
21
|
+
const entry = this.entries.get(key);
|
|
22
|
+
if (entry === undefined) return null;
|
|
23
|
+
if (entry.expiresAt <= this.now()) {
|
|
24
|
+
this.entries.delete(key);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return entry.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
set(key: string, value: Value): void {
|
|
31
|
+
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
|
|
32
|
+
const oldest = this.entries.keys().next();
|
|
33
|
+
if (!oldest.done) this.entries.delete(oldest.value);
|
|
34
|
+
}
|
|
35
|
+
// Refresh insertion order as well as expiry when replacing an entry.
|
|
36
|
+
this.entries.delete(key);
|
|
37
|
+
this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
delete(key: string): void {
|
|
41
|
+
this.entries.delete(key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear(): void {
|
|
45
|
+
this.entries.clear();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get size(): number {
|
|
49
|
+
return this.entries.size;
|
|
50
|
+
}
|
|
51
|
+
}
|