@a-t-h-i/bot-lobby 0.6.11 → 0.6.13
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/README.md +26 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +6 -4
- package/src/roles/reviewer.ts +9 -1
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/src/workflow/workflow.ts +22 -6
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- package/webui/dist/vite.svg +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Git tab over HTTP: the open pull requests with their checks, size and
|
|
3
|
+
* review marks; one pull request with its files and discussion, our review
|
|
4
|
+
* and Jev's read; starting and stopping a review; asking Jev. Everything goes
|
|
5
|
+
* through the lobby's `PullsState` and `PullReviews`, so the terminal and the
|
|
6
|
+
* page see one set of reviews. Nothing is ever posted to GitHub.
|
|
7
|
+
*
|
|
8
|
+
* Only types come from `src/lobby/pulls.ts` and `pr-review.ts` (erased at
|
|
9
|
+
* compile time): the review module loads the agent runner, and the server
|
|
10
|
+
* needs none of it.
|
|
11
|
+
*/
|
|
12
|
+
import type { PullDetail, PullSummary } from "../../lobby/pulls.ts";
|
|
13
|
+
import type { PullReadState, PullReview } from "../../lobby/pr-review.ts";
|
|
14
|
+
import type { GitPull, GitPulls, PullInfo, PullReadInfo, PullReviewInfo, PullReviewMark } from "../protocol.ts";
|
|
15
|
+
import type { ApiContext } from "./index.ts";
|
|
16
|
+
import { ghRefusal } from "./gh-error.ts";
|
|
17
|
+
|
|
18
|
+
/** The pull request has commits the review did not see (mirrors `isStale`). */
|
|
19
|
+
function staleOf(review: PullReview, headSha: string | undefined): boolean {
|
|
20
|
+
return Boolean(review.headSha && headSha && review.headSha !== headSha);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function markOf(review: PullReview | undefined, headSha: string | undefined): PullReviewMark | undefined {
|
|
24
|
+
if (!review) return undefined;
|
|
25
|
+
return { status: review.status, ...(review.verdict ? { verdict: review.verdict } : {}), stale: staleOf(review, headSha) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pullInfo(pull: PullSummary, review: PullReview | undefined): PullInfo {
|
|
29
|
+
const { headSha, ...rest } = pull;
|
|
30
|
+
const mark = markOf(review, headSha);
|
|
31
|
+
return { ...rest, labels: [...rest.labels], ...(mark ? { review: mark } : {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function reviewInfo(review: PullReview, headSha: string | undefined): PullReviewInfo {
|
|
35
|
+
const { headSha: _seen, ...rest } = review;
|
|
36
|
+
return { ...rest, steps: [...rest.steps], stale: staleOf(review, headSha) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readInfo(state: PullReadState): PullReadInfo {
|
|
40
|
+
return { ...state, ...(state.read ? { read: { ...state.read } } : {}) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The open pull requests; read from `gh` the first time or when `refresh` asks (a failure is not retried until asked). */
|
|
44
|
+
export async function gitPulls(body: { refresh?: boolean }, ctx: ApiContext): Promise<GitPulls> {
|
|
45
|
+
const state = ctx.service.pulls;
|
|
46
|
+
if (body.refresh || (!state.loaded && !state.error)) {
|
|
47
|
+
state.forget();
|
|
48
|
+
await state.refresh();
|
|
49
|
+
}
|
|
50
|
+
const pulls = state.pulls.map((pull) => pullInfo(pull, ctx.service.reviews.review(pull.number)));
|
|
51
|
+
return { pulls, loading: state.loading, loaded: state.loaded, ...(state.error ? { error: state.error } : {}) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One pull request with its files, description, reviews and comments, our review and Jev's read. */
|
|
55
|
+
export async function gitPull(body: { number: number }, ctx: ApiContext): Promise<GitPull> {
|
|
56
|
+
const detail: PullDetail | undefined = await ctx.service.pulls.detail(body.number);
|
|
57
|
+
if (!detail) ghRefusal(ctx.service.pulls.error, `#${body.number}`);
|
|
58
|
+
const { body: text, state, mergeable, files, notes, ...summary } = detail!;
|
|
59
|
+
const review = ctx.service.reviews.review(body.number);
|
|
60
|
+
const read = ctx.service.reviews.reads.get(body.number);
|
|
61
|
+
return {
|
|
62
|
+
pull: {
|
|
63
|
+
...pullInfo(summary, review),
|
|
64
|
+
body: text,
|
|
65
|
+
...(state ? { state } : {}),
|
|
66
|
+
...(mergeable ? { mergeable } : {}),
|
|
67
|
+
files: files.map((file) => ({ ...file })),
|
|
68
|
+
notes: notes.map((note) => ({ ...note })),
|
|
69
|
+
},
|
|
70
|
+
...(review ? { review: reviewInfo(review, detail!.headSha) } : {}),
|
|
71
|
+
...(read ? { read: readInfo(read) } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start a review by a read-only agent; it runs on, streaming into the activity log. */
|
|
76
|
+
export function gitReview(body: { number: number; focus?: string }, ctx: ApiContext): { notice: string } {
|
|
77
|
+
const { reviews, pulls } = ctx.service;
|
|
78
|
+
const number = body.number;
|
|
79
|
+
if (reviews.running(number)) return { notice: `already reviewing #${number}` };
|
|
80
|
+
const focus = body.focus?.trim();
|
|
81
|
+
const detail = pulls.details.get(number);
|
|
82
|
+
reviews.start(number, { ...(focus ? { focus } : {}), ...(detail ? { detail } : {}) }).catch(() => undefined);
|
|
83
|
+
return { notice: `reviewing #${number}${focus ? ` — looking at: ${focus.length > 40 ? `${focus.slice(0, 39)}…` : focus}` : ""} — it streams into the activity log` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Stop a running review. */
|
|
87
|
+
export function gitCancelReview(body: { number: number }, ctx: ApiContext): { notice: string } {
|
|
88
|
+
const stopped = ctx.service.reviews.cancel(body.number);
|
|
89
|
+
return { notice: stopped ? `stopping the review of #${body.number}` : `no review of #${body.number} is running` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Ask Jev for a quick read; the answer arrives with `git.pull`. */
|
|
93
|
+
export function gitJev(body: { number: number }, ctx: ApiContext): { notice: string } {
|
|
94
|
+
const { reviews, pulls } = ctx.service;
|
|
95
|
+
if (reviews.reads.get(body.number)?.status === "running") return { notice: `Jev is already reading #${body.number}` };
|
|
96
|
+
reviews.readWithJev(body.number, pulls.details.get(body.number)).catch(() => undefined);
|
|
97
|
+
return { notice: `asking Jev to read #${body.number}…` };
|
|
98
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router: `POST /api/<group>.<action>` with a JSON object body, answered
|
|
3
|
+
* `{ok:true,result}` or `{ok:false,error,code}`. One TypeBox schema per
|
|
4
|
+
* request; groups and actions live in a null-prototype map so inherited
|
|
5
|
+
* names (`__proto__`, `constructor`) are never callable. Shared response
|
|
6
|
+
* helpers live here too, so static/stream failures answer the same shape.
|
|
7
|
+
*/
|
|
8
|
+
import type { ServerResponse } from "node:http";
|
|
9
|
+
import { Type, type TObject } from "typebox";
|
|
10
|
+
import { Compile } from "typebox/compile";
|
|
11
|
+
import { statusFor, type ApiName, type ErrorCode } from "../protocol.ts";
|
|
12
|
+
import type { LobbyService } from "../../lobby/service.ts";
|
|
13
|
+
import { statusGet } from "./status.ts";
|
|
14
|
+
import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
|
|
15
|
+
import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
|
|
16
|
+
import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerToggleSeat, plannerSave } from "./planner.ts";
|
|
17
|
+
import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
|
|
18
|
+
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksGet, tasksList, tasksMessage, tasksRestore } from "./tasks.ts";
|
|
19
|
+
import { metricsGet } from "./metrics.ts";
|
|
20
|
+
import { knowledgeAdd, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
|
|
21
|
+
import { excalidrawAdd, excalidrawCheck, excalidrawCreate, excalidrawList, excalidrawRemove, excalidrawRename, excalidrawReveal, excalidrawToggleAgent, excalidrawToggleAll, excalidrawToggleContribute } from "./excalidraw.ts";
|
|
22
|
+
import { plansDiscard, plansGet, plansStart } from "./plans.ts";
|
|
23
|
+
import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
|
|
24
|
+
import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
|
|
25
|
+
import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
|
|
26
|
+
import { settingsGet, settingsSet } from "./settings.ts";
|
|
27
|
+
import { noticeText, pushNotice } from "../notices.ts";
|
|
28
|
+
|
|
29
|
+
/** What a handler reads besides the request body. */
|
|
30
|
+
export interface ApiContext {
|
|
31
|
+
service: LobbyService;
|
|
32
|
+
port: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A request the router refused: an HTTP status, a machine code, a message. */
|
|
36
|
+
export class HttpError extends Error {
|
|
37
|
+
readonly status: number;
|
|
38
|
+
readonly code: ErrorCode;
|
|
39
|
+
constructor(status: number, code: ErrorCode, message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Refuse a request with a status, a machine code and a message. */
|
|
47
|
+
export function fail(status: number, code: ErrorCode, message: string): never {
|
|
48
|
+
throw new HttpError(status, code, message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Security headers on every response (the page loads nothing from anywhere else). */
|
|
52
|
+
export function applyBaseHeaders(res: ServerResponse): void {
|
|
53
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
54
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
55
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Answer JSON, never cached. */
|
|
59
|
+
export function sendJson(res: ServerResponse, status: number, body: unknown, extra: Record<string, string> = {}): void {
|
|
60
|
+
applyBaseHeaders(res);
|
|
61
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...extra });
|
|
62
|
+
res.end(JSON.stringify(body));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Answer `{ok:false,error,code}` with the code's status. */
|
|
66
|
+
export function sendError(res: ServerResponse, code: ErrorCode, error: string, status = statusFor(code)): void {
|
|
67
|
+
sendJson(res, status, { ok: false, error, code });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Handler = (body: Record<string, unknown>, ctx: ApiContext) => unknown | Promise<unknown>;
|
|
71
|
+
|
|
72
|
+
interface Route {
|
|
73
|
+
schema: TObject;
|
|
74
|
+
run: Handler;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const Empty = Type.Object({}, { additionalProperties: false });
|
|
78
|
+
const TaskId = Type.String({ minLength: 1, maxLength: 200 });
|
|
79
|
+
const GhNumber = Type.Integer({ minimum: 1, maximum: 1_000_000_000 });
|
|
80
|
+
const NoticeText = Type.String({ maxLength: 20_000 });
|
|
81
|
+
const CommentText = Type.String({ maxLength: 10_000 });
|
|
82
|
+
const DialogAnswer = Type.Union([
|
|
83
|
+
Type.Object({ value: Type.String() }, { additionalProperties: false }),
|
|
84
|
+
Type.Object({ confirmed: Type.Boolean() }, { additionalProperties: false }),
|
|
85
|
+
Type.Object({ cancelled: Type.Literal(true) }, { additionalProperties: false }),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const Member = Type.Union([Type.Literal("backend"), Type.Literal("designer"), Type.Literal("qa"), Type.Literal("researcher")]);
|
|
89
|
+
const KnowledgeAgent = Type.Union([Type.Literal("master"), Type.Literal("designer"), Type.Literal("backend"), Type.Literal("qa")]);
|
|
90
|
+
const KnowledgeFile = Type.String({ minLength: 1, maxLength: 200 });
|
|
91
|
+
const KnowledgeText = Type.String({ minLength: 1, maxLength: 100_000 });
|
|
92
|
+
const EntryRef = Type.Object({ text: Type.String({ minLength: 1, maxLength: 100_000 }), occurrence: Type.Integer({ minimum: 0, maximum: 100000 }) }, { additionalProperties: false });
|
|
93
|
+
const ExcalidrawAgent = Type.Union([Type.Literal("master"), Type.Literal("designer"), Type.Literal("backend"), Type.Literal("qa"), Type.Literal("scout"), Type.Literal("researcher"), Type.Literal("quickfix"), Type.Literal("planner")]);
|
|
94
|
+
const PlannerSeed = Type.Object({
|
|
95
|
+
issue: Type.Object({ number: Type.Integer({ minimum: 1 }), title: Type.String({ minLength: 1, maxLength: 500 }), url: Type.Optional(Type.String({ maxLength: 2000 })) }, { additionalProperties: false }),
|
|
96
|
+
body: Type.String({ maxLength: 20_000 }),
|
|
97
|
+
}, { additionalProperties: false });
|
|
98
|
+
|
|
99
|
+
function firstError(schema: TObject, body: unknown): string {
|
|
100
|
+
const check = Compile(schema);
|
|
101
|
+
const [first] = [...check.Errors(body ?? {})];
|
|
102
|
+
return first?.message ?? "the request does not match its schema";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every call the server answers, by `group.action` name. */
|
|
106
|
+
function buildRoutes(): Record<string, Route> {
|
|
107
|
+
const routes: Record<string, Route> = Object.create(null);
|
|
108
|
+
routes["status.get"] = { schema: Empty, run: (_body, ctx) => statusGet(ctx) };
|
|
109
|
+
routes["lobby.snapshot"] = { schema: Empty, run: (_body, ctx) => lobbySnapshot(ctx) };
|
|
110
|
+
routes["lobby.history"] = {
|
|
111
|
+
schema: Type.Object({ before: Type.Optional(Type.Number()) }, { additionalProperties: false }),
|
|
112
|
+
run: (body, ctx) => lobbyHistory(body as { before?: number }, ctx),
|
|
113
|
+
};
|
|
114
|
+
routes["lobby.send"] = {
|
|
115
|
+
schema: Type.Object({ text: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
|
|
116
|
+
run: (body, ctx) => lobbySend(body as { text: string }, ctx),
|
|
117
|
+
};
|
|
118
|
+
routes["lobby.abort"] = { schema: Empty, run: (_body, ctx) => lobbyAbort(ctx) };
|
|
119
|
+
routes["tasks.list"] = { schema: Empty, run: (_body, ctx) => tasksList(ctx) };
|
|
120
|
+
routes["tasks.archived"] = { schema: Empty, run: (_body, ctx) => tasksArchived(ctx) };
|
|
121
|
+
routes["tasks.get"] = {
|
|
122
|
+
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
123
|
+
run: (body, ctx) => tasksGet(body as { taskId: string }, ctx),
|
|
124
|
+
};
|
|
125
|
+
routes["tasks.comments"] = {
|
|
126
|
+
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
127
|
+
run: (body, ctx) => tasksComments(body as { taskId: string }, ctx),
|
|
128
|
+
};
|
|
129
|
+
routes["tasks.comment"] = {
|
|
130
|
+
schema: Type.Object({ taskId: TaskId, text: CommentText }, { additionalProperties: false }),
|
|
131
|
+
run: (body, ctx) => tasksComment(body as { taskId: string; text: string }, ctx),
|
|
132
|
+
};
|
|
133
|
+
routes["tasks.archive"] = {
|
|
134
|
+
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
135
|
+
run: (body, ctx) => tasksArchive(body as { taskId: string }, ctx),
|
|
136
|
+
};
|
|
137
|
+
routes["tasks.restore"] = {
|
|
138
|
+
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
139
|
+
run: (body, ctx) => tasksRestore(body as { taskId: string }, ctx),
|
|
140
|
+
};
|
|
141
|
+
routes["tasks.delete"] = {
|
|
142
|
+
schema: Type.Object({ taskId: TaskId, where: Type.Union([Type.Literal("list"), Type.Literal("archive")]) }, { additionalProperties: false }),
|
|
143
|
+
run: (body, ctx) => tasksDelete(body as { taskId: string; where: "list" | "archive" }, ctx),
|
|
144
|
+
};
|
|
145
|
+
routes["tasks.auto"] = {
|
|
146
|
+
schema: Type.Object({ taskId: TaskId, on: Type.Boolean() }, { additionalProperties: false }),
|
|
147
|
+
run: (body, ctx) => tasksAuto(body as { taskId: string; on: boolean }, ctx),
|
|
148
|
+
};
|
|
149
|
+
routes["tasks.message"] = {
|
|
150
|
+
schema: Type.Object({ taskId: TaskId, text: NoticeText }, { additionalProperties: false }),
|
|
151
|
+
run: (body, ctx) => tasksMessage(body as { taskId: string; text: string }, ctx),
|
|
152
|
+
};
|
|
153
|
+
routes["plans.get"] = {
|
|
154
|
+
schema: Type.Object({ planId: TaskId }, { additionalProperties: false }),
|
|
155
|
+
run: (body, ctx) => plansGet(body as { planId: string }, ctx),
|
|
156
|
+
};
|
|
157
|
+
routes["plans.start"] = {
|
|
158
|
+
schema: Type.Object({ planId: TaskId, where: Type.Union([Type.Literal("here"), Type.Literal("session")]), auto: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
159
|
+
run: (body, ctx) => plansStart(body as { planId: string; where: "here" | "session"; auto?: boolean }, ctx),
|
|
160
|
+
};
|
|
161
|
+
routes["plans.discard"] = {
|
|
162
|
+
schema: Type.Object({ planId: TaskId }, { additionalProperties: false }),
|
|
163
|
+
run: (body, ctx) => plansDiscard(body as { planId: string }, ctx),
|
|
164
|
+
};
|
|
165
|
+
routes["sessions.list"] = { schema: Empty, run: (_body, ctx) => sessionsList(ctx) };
|
|
166
|
+
routes["sessions.chat"] = {
|
|
167
|
+
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), before: Type.Optional(Type.Number()) }, { additionalProperties: false }),
|
|
168
|
+
run: (body, ctx) => sessionsChat(body as { key?: string; sessionId?: string; before?: number }, ctx),
|
|
169
|
+
};
|
|
170
|
+
routes["sessions.start"] = {
|
|
171
|
+
schema: Type.Object({ request: Type.Optional(NoticeText), planId: Type.Optional(TaskId), auto: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
172
|
+
run: (body, ctx) => sessionsStart(body as { request?: string; planId?: string; auto?: boolean }, ctx),
|
|
173
|
+
};
|
|
174
|
+
routes["sessions.stop"] = {
|
|
175
|
+
schema: Type.Object({ key: TaskId }, { additionalProperties: false }),
|
|
176
|
+
run: (body, ctx) => sessionsStop(body as { key: string }, ctx),
|
|
177
|
+
};
|
|
178
|
+
routes["sessions.message"] = {
|
|
179
|
+
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), text: NoticeText }, { additionalProperties: false }),
|
|
180
|
+
run: (body, ctx) => sessionsMessage(body as { key?: string; sessionId?: string; text: string }, ctx),
|
|
181
|
+
};
|
|
182
|
+
routes["sessions.switch"] = {
|
|
183
|
+
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), claimTaskId: Type.Optional(TaskId) }, { additionalProperties: false }),
|
|
184
|
+
run: (body, ctx) => sessionsSwitch(body as { key?: string; sessionId?: string; claimTaskId?: string }, ctx),
|
|
185
|
+
};
|
|
186
|
+
routes["sessions.answer"] = {
|
|
187
|
+
schema: Type.Object({ key: TaskId, dialogId: TaskId, answer: DialogAnswer }, { additionalProperties: false }),
|
|
188
|
+
run: (body, ctx) => sessionsAnswer(body as { key: string; dialogId: string; answer: { value: string } | { confirmed: boolean } | { cancelled: true } }, ctx),
|
|
189
|
+
};
|
|
190
|
+
routes["prompts.list"] = { schema: Empty, run: (_body, ctx) => promptsList(ctx) };
|
|
191
|
+
routes["prompts.answer"] = {
|
|
192
|
+
schema: Type.Object({ id: Type.String(), answer: Type.Any() }, { additionalProperties: false }),
|
|
193
|
+
run: (body, ctx) => promptsAnswer(body as { id: string; answer: unknown }, ctx),
|
|
194
|
+
};
|
|
195
|
+
routes["prompts.dismiss"] = {
|
|
196
|
+
schema: Type.Object({ id: Type.String() }, { additionalProperties: false }),
|
|
197
|
+
run: (body, ctx) => promptsDismiss(body as { id: string }, ctx),
|
|
198
|
+
};
|
|
199
|
+
routes["planner.get"] = { schema: Empty, run: (_body, ctx) => plannerGet(ctx) };
|
|
200
|
+
routes["planner.new"] = {
|
|
201
|
+
schema: Type.Object({ seed: Type.Optional(PlannerSeed), seats: Type.Optional(Type.Array(Member, { maxItems: 4 })) }, { additionalProperties: false }),
|
|
202
|
+
run: (body, ctx) => plannerNew(body as { seed?: { issue: { number: number; title: string; url?: string }; body: string }; seats?: ("backend" | "designer" | "qa" | "researcher")[] }, ctx),
|
|
203
|
+
};
|
|
204
|
+
routes["planner.send"] = {
|
|
205
|
+
schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
|
|
206
|
+
run: (body, ctx) => plannerSend(body as { text: string }, ctx),
|
|
207
|
+
};
|
|
208
|
+
routes["planner.toggleSeat"] = {
|
|
209
|
+
schema: Type.Object({ member: Member }, { additionalProperties: false }),
|
|
210
|
+
run: (body, ctx) => plannerToggleSeat(body as { member: "backend" | "designer" | "qa" | "researcher" }, ctx),
|
|
211
|
+
};
|
|
212
|
+
routes["planner.retry"] = { schema: Empty, run: (_body, ctx) => plannerRetry(ctx) };
|
|
213
|
+
routes["planner.commentLine"] = {
|
|
214
|
+
schema: Type.Object({ line: Type.String({ minLength: 1, maxLength: 5000 }), text: CommentText }, { additionalProperties: false }),
|
|
215
|
+
run: (body, ctx) => plannerCommentLine(body as { line: string; text: string }, ctx),
|
|
216
|
+
};
|
|
217
|
+
routes["planner.answer"] = { schema: Empty, run: (_body, ctx) => plannerAnswer(ctx) };
|
|
218
|
+
routes["planner.save"] = { schema: Empty, run: (_body, ctx) => plannerSave(ctx) };
|
|
219
|
+
routes["quickfix.list"] = { schema: Empty, run: (_body, ctx) => quickfixList(ctx) };
|
|
220
|
+
routes["quickfix.submit"] = {
|
|
221
|
+
schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
|
|
222
|
+
run: (body, ctx) => quickfixSubmit(body as { text: string }, ctx),
|
|
223
|
+
};
|
|
224
|
+
routes["quickfix.cancel"] = {
|
|
225
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
226
|
+
run: (body, ctx) => quickfixCancel(body as { id: string }, ctx),
|
|
227
|
+
};
|
|
228
|
+
routes["quickfix.runAnyway"] = {
|
|
229
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
230
|
+
run: (body, ctx) => quickfixRunAnyway(body as { id: string }, ctx),
|
|
231
|
+
};
|
|
232
|
+
routes["quickfix.movedToTask"] = {
|
|
233
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
234
|
+
run: (body, ctx) => quickfixMovedToTask(body as { id: string }, ctx),
|
|
235
|
+
};
|
|
236
|
+
routes["metrics.get"] = {
|
|
237
|
+
schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })) }, { additionalProperties: false }),
|
|
238
|
+
run: (body, ctx) => metricsGet(body as { groupBy: "model" | "model-kind"; query?: string }, ctx),
|
|
239
|
+
};
|
|
240
|
+
routes["knowledge.files"] = { schema: Empty, run: (_body, ctx) => knowledgeFiles(ctx) };
|
|
241
|
+
routes["knowledge.open"] = {
|
|
242
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile }, { additionalProperties: false }),
|
|
243
|
+
run: (body, ctx) => knowledgeOpen(body as { agent: "master" | "designer" | "backend" | "qa"; file: string }, ctx),
|
|
244
|
+
};
|
|
245
|
+
routes["knowledge.edit"] = {
|
|
246
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile, ref: EntryRef, text: KnowledgeText }, { additionalProperties: false }),
|
|
247
|
+
run: (body, ctx) => knowledgeEdit(body as { agent: "master" | "designer" | "backend" | "qa"; file: string; ref: { text: string; occurrence: number }; text: string }, ctx),
|
|
248
|
+
};
|
|
249
|
+
routes["knowledge.add"] = {
|
|
250
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile, ref: Type.Optional(EntryRef), text: KnowledgeText }, { additionalProperties: false }),
|
|
251
|
+
run: (body, ctx) => knowledgeAdd(body as { agent: "master" | "designer" | "backend" | "qa"; file: string; ref?: { text: string; occurrence: number }; text: string }, ctx),
|
|
252
|
+
};
|
|
253
|
+
routes["knowledge.remove"] = {
|
|
254
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile, ref: EntryRef }, { additionalProperties: false }),
|
|
255
|
+
run: (body, ctx) => knowledgeRemove(body as { agent: "master" | "designer" | "backend" | "qa"; file: string; ref: { text: string; occurrence: number } }, ctx),
|
|
256
|
+
};
|
|
257
|
+
routes["knowledge.replaceFile"] = {
|
|
258
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile, text: Type.String({ maxLength: 100_000 }) }, { additionalProperties: false }),
|
|
259
|
+
run: (body, ctx) => knowledgeReplaceFile(body as { agent: "master" | "designer" | "backend" | "qa"; file: string; text: string }, ctx),
|
|
260
|
+
};
|
|
261
|
+
routes["knowledge.comment"] = {
|
|
262
|
+
schema: Type.Object({ agent: KnowledgeAgent, file: KnowledgeFile, ref: EntryRef, text: Type.String({ minLength: 1, maxLength: 2000 }) }, { additionalProperties: false }),
|
|
263
|
+
run: (body, ctx) => knowledgeComment(body as { agent: "master" | "designer" | "backend" | "qa"; file: string; ref: { text: string; occurrence: number }; text: string }, ctx),
|
|
264
|
+
};
|
|
265
|
+
routes["knowledge.unnote"] = {
|
|
266
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
267
|
+
run: (body, ctx) => knowledgeUnnote(body as { id: string }, ctx),
|
|
268
|
+
};
|
|
269
|
+
routes["excalidraw.list"] = { schema: Empty, run: (_body, ctx) => excalidrawList(ctx) };
|
|
270
|
+
routes["excalidraw.add"] = {
|
|
271
|
+
schema: Type.Object({ link: Type.String({ minLength: 1, maxLength: 2000 }), name: Type.Optional(Type.String({ maxLength: 40 })) }, { additionalProperties: false }),
|
|
272
|
+
run: (body, ctx) => excalidrawAdd(body as { link: string; name?: string }, ctx),
|
|
273
|
+
};
|
|
274
|
+
routes["excalidraw.create"] = {
|
|
275
|
+
schema: Type.Object({ name: Type.Optional(Type.String({ maxLength: 40 })) }, { additionalProperties: false }),
|
|
276
|
+
run: (body, ctx) => excalidrawCreate(body as { name?: string }, ctx),
|
|
277
|
+
};
|
|
278
|
+
routes["excalidraw.remove"] = {
|
|
279
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
280
|
+
run: (body, ctx) => excalidrawRemove(body as { id: string }, ctx),
|
|
281
|
+
};
|
|
282
|
+
routes["excalidraw.rename"] = {
|
|
283
|
+
schema: Type.Object({ id: TaskId, name: Type.String({ maxLength: 40 }) }, { additionalProperties: false }),
|
|
284
|
+
run: (body, ctx) => excalidrawRename(body as { id: string; name: string }, ctx),
|
|
285
|
+
};
|
|
286
|
+
routes["excalidraw.toggleAgent"] = {
|
|
287
|
+
schema: Type.Object({ id: TaskId, agent: ExcalidrawAgent }, { additionalProperties: false }),
|
|
288
|
+
run: (body, ctx) => excalidrawToggleAgent(body as { id: string; agent: "master" | "designer" | "backend" | "qa" | "scout" | "researcher" | "quickfix" | "planner" }, ctx),
|
|
289
|
+
};
|
|
290
|
+
routes["excalidraw.toggleAll"] = {
|
|
291
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
292
|
+
run: (body, ctx) => excalidrawToggleAll(body as { id: string }, ctx),
|
|
293
|
+
};
|
|
294
|
+
routes["excalidraw.toggleContribute"] = {
|
|
295
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
296
|
+
run: (body, ctx) => excalidrawToggleContribute(body as { id: string }, ctx),
|
|
297
|
+
};
|
|
298
|
+
routes["excalidraw.check"] = {
|
|
299
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
300
|
+
run: (body, ctx) => excalidrawCheck(body as { id: string }, ctx),
|
|
301
|
+
};
|
|
302
|
+
routes["excalidraw.reveal"] = {
|
|
303
|
+
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
304
|
+
run: (body, ctx) => excalidrawReveal(body as { id: string }, ctx),
|
|
305
|
+
};
|
|
306
|
+
routes["git.pulls"] = {
|
|
307
|
+
schema: Type.Object({ refresh: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
308
|
+
run: (body, ctx) => gitPulls(body as { refresh?: boolean }, ctx),
|
|
309
|
+
};
|
|
310
|
+
routes["git.pull"] = {
|
|
311
|
+
schema: Type.Object({ number: GhNumber }, { additionalProperties: false }),
|
|
312
|
+
run: (body, ctx) => gitPull(body as { number: number }, ctx),
|
|
313
|
+
};
|
|
314
|
+
routes["git.review"] = {
|
|
315
|
+
schema: Type.Object({ number: GhNumber, focus: Type.Optional(Type.String({ maxLength: 2000 })) }, { additionalProperties: false }),
|
|
316
|
+
run: (body, ctx) => gitReview(body as { number: number; focus?: string }, ctx),
|
|
317
|
+
};
|
|
318
|
+
routes["git.cancelReview"] = {
|
|
319
|
+
schema: Type.Object({ number: GhNumber }, { additionalProperties: false }),
|
|
320
|
+
run: (body, ctx) => gitCancelReview(body as { number: number }, ctx),
|
|
321
|
+
};
|
|
322
|
+
routes["git.jev"] = {
|
|
323
|
+
schema: Type.Object({ number: GhNumber }, { additionalProperties: false }),
|
|
324
|
+
run: (body, ctx) => gitJev(body as { number: number }, ctx),
|
|
325
|
+
};
|
|
326
|
+
routes["issues.list"] = {
|
|
327
|
+
schema: Type.Object({ refresh: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
328
|
+
run: (body, ctx) => issuesList(body as { refresh?: boolean }, ctx),
|
|
329
|
+
};
|
|
330
|
+
routes["issues.get"] = {
|
|
331
|
+
schema: Type.Object({ number: GhNumber }, { additionalProperties: false }),
|
|
332
|
+
run: (body, ctx) => issuesGet(body as { number: number }, ctx),
|
|
333
|
+
};
|
|
334
|
+
routes["issues.create"] = {
|
|
335
|
+
schema: Type.Object({ text: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
|
|
336
|
+
run: (body, ctx) => issuesCreate(body as { text: string }, ctx),
|
|
337
|
+
};
|
|
338
|
+
routes["settings.get"] = { schema: Empty, run: (_body, ctx) => settingsGet(ctx) };
|
|
339
|
+
routes["settings.set"] = {
|
|
340
|
+
// The patch's shape is the normaliser's job (`resolveConfig`); only a plain object is required here.
|
|
341
|
+
schema: Type.Object({ patch: Type.Object({}, { additionalProperties: true }) }, { additionalProperties: false }),
|
|
342
|
+
run: (body, ctx) => settingsSet(body as { patch: Record<string, unknown> }, ctx),
|
|
343
|
+
};
|
|
344
|
+
return routes;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const ROUTES = buildRoutes();
|
|
348
|
+
|
|
349
|
+
/** Whether the router knows a call (defined names only; never inherited ones). */
|
|
350
|
+
export function isKnownCall(name: string): boolean {
|
|
351
|
+
return Object.hasOwn(ROUTES, name);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Run `POST /api/<name>` with an already-parsed body. Throws `HttpError` for
|
|
356
|
+
* refusals (unknown call, a body that is no object, a schema mismatch, or a
|
|
357
|
+
* handler's own refusal); anything else is a 500.
|
|
358
|
+
*/
|
|
359
|
+
export async function routeApiCall(name: string, body: unknown, ctx: ApiContext): Promise<unknown> {
|
|
360
|
+
const route = Object.hasOwn(ROUTES, name) ? ROUTES[name] : undefined;
|
|
361
|
+
if (!route) fail(404, "not_found", `no call ${name}`);
|
|
362
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) fail(400, "bad_request", "the body must be a JSON object");
|
|
363
|
+
const checked = route as Route;
|
|
364
|
+
if (!Compile(checked.schema).Check(body)) fail(400, "bad_request", firstError(checked.schema, body));
|
|
365
|
+
const result = await checked.run(body as Record<string, unknown>, ctx);
|
|
366
|
+
const notice = noticeText(result);
|
|
367
|
+
if (notice) pushNotice(notice);
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Read the call name from `POST /api/<group>.<action>` (undefined for anything else). */
|
|
372
|
+
export function callName(url: URL): string | undefined {
|
|
373
|
+
const path = url.pathname;
|
|
374
|
+
if (!path.startsWith("/api/")) return undefined;
|
|
375
|
+
const name = path.slice("/api/".length);
|
|
376
|
+
return /^[a-z]+\.[a-zA-Z]+$/.test(name) ? name : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export type { ApiName };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Issues tab over HTTP: the open issues, one issue with its comments, and
|
|
3
|
+
* filing a new one, all through the lobby's `IssuesState` (so `gh` owns
|
|
4
|
+
* authentication and the repository). With `lobby.issues` off every call is
|
|
5
|
+
* a 404, like the tab being absent from the terminal.
|
|
6
|
+
*/
|
|
7
|
+
import type { IssueDetailInfo, IssueInfo, IssuesList } from "../protocol.ts";
|
|
8
|
+
import type { ApiContext } from "./index.ts";
|
|
9
|
+
import { fail } from "./index.ts";
|
|
10
|
+
import { ghRefusal } from "./gh-error.ts";
|
|
11
|
+
|
|
12
|
+
function requireIssues(ctx: ApiContext): void {
|
|
13
|
+
if (!ctx.service.issuesEnabled()) fail(404, "not_found", "issues are off (lobby.issues)");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function issueInfo(issue: IssueInfo): IssueInfo {
|
|
17
|
+
return { ...issue, labels: [...issue.labels] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The open issues; read from `gh` the first time or when `refresh` asks (a failure is not retried until asked). */
|
|
21
|
+
export async function issuesList(body: { refresh?: boolean }, ctx: ApiContext): Promise<IssuesList> {
|
|
22
|
+
requireIssues(ctx);
|
|
23
|
+
const state = ctx.service.issues;
|
|
24
|
+
if (body.refresh || (!state.loaded && !state.error)) {
|
|
25
|
+
state.details.clear();
|
|
26
|
+
await state.refresh();
|
|
27
|
+
}
|
|
28
|
+
return { issues: state.issues.map(issueInfo), loading: state.loading, loaded: state.loaded, ...(state.error ? { error: state.error } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One issue with its description and comments. */
|
|
32
|
+
export async function issuesGet(body: { number: number }, ctx: ApiContext): Promise<{ issue: IssueDetailInfo }> {
|
|
33
|
+
requireIssues(ctx);
|
|
34
|
+
const detail = await ctx.service.issues.detail(body.number);
|
|
35
|
+
if (!detail) ghRefusal(ctx.service.issues.error, `issue #${body.number}`);
|
|
36
|
+
const found = detail!;
|
|
37
|
+
return { issue: { ...issueInfo(found), body: found.body, ...(found.state ? { state: found.state } : {}), comments: found.comments.map((comment) => ({ ...comment })) } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** File an issue: the first line is the title, the rest the body. */
|
|
41
|
+
export async function issuesCreate(body: { text: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
42
|
+
requireIssues(ctx);
|
|
43
|
+
if (!body.text.trim()) return { notice: "type something first" };
|
|
44
|
+
const state = ctx.service.issues;
|
|
45
|
+
state.notice = undefined;
|
|
46
|
+
state.error = undefined;
|
|
47
|
+
await state.create(body.text);
|
|
48
|
+
if (!state.notice) fail(500, "failed", state.error ?? "could not file the issue");
|
|
49
|
+
return { notice: state.notice! };
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Knowledge tab over HTTP: every agent's files, one file read as entries
|
|
3
|
+
* with its notes, and the tab's edits (edit, add, delete, whole-file replace,
|
|
4
|
+
* comment, take a note back). Actions answer with the same notice text the
|
|
5
|
+
* terminal shows; an edit whose entry changed on disk since it was drawn is
|
|
6
|
+
* refused with a 409, like the tab's reload hint.
|
|
7
|
+
*/
|
|
8
|
+
import type { KnowledgeAgent } from "../../knowledge/paths.ts";
|
|
9
|
+
import type { EntryRef } from "../../lobby/knowledge.ts";
|
|
10
|
+
import type { KnowledgeFileInfo, KnowledgeViewData } from "../protocol.ts";
|
|
11
|
+
import type { ApiContext } from "./index.ts";
|
|
12
|
+
import { fail } from "./index.ts";
|
|
13
|
+
|
|
14
|
+
/** The terminal's stale-entry refusal, as `KnowledgeBook` words it. */
|
|
15
|
+
function isStale(notice: string): boolean {
|
|
16
|
+
return notice.startsWith("that entry changed on disk");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Refuse a stale entry with the terminal's own words. */
|
|
20
|
+
function stale(notice: string): never {
|
|
21
|
+
fail(409, "conflict", notice);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FileBody {
|
|
25
|
+
agent: KnowledgeAgent;
|
|
26
|
+
file: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RefBody extends FileBody {
|
|
30
|
+
ref: EntryRef;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every agent's files with their size and note counts, in tab order. */
|
|
34
|
+
export function knowledgeFiles(ctx: ApiContext): { files: KnowledgeFileInfo[] } {
|
|
35
|
+
return { files: ctx.service.knowledge.files() };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One file as entries with their notes. */
|
|
39
|
+
export function knowledgeOpen(body: FileBody, ctx: ApiContext): { view: KnowledgeViewData } {
|
|
40
|
+
return { view: ctx.service.knowledge.open(body.agent, body.file) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Replace one entry with `text`, exactly as written. */
|
|
44
|
+
export function knowledgeEdit(body: RefBody & { text: string }, ctx: ApiContext): { notice: string } {
|
|
45
|
+
const notice = ctx.service.knowledge.edit(body.agent, body.file, body.ref, body.text);
|
|
46
|
+
if (isStale(notice)) stale(notice);
|
|
47
|
+
return { notice };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Add a bullet after an entry (at the end when there is none). */
|
|
51
|
+
export function knowledgeAdd(body: FileBody & { ref?: EntryRef; text: string }, ctx: ApiContext): { notice: string } {
|
|
52
|
+
const notice = ctx.service.knowledge.add(body.agent, body.file, body.ref, body.text);
|
|
53
|
+
if (isStale(notice)) stale(notice);
|
|
54
|
+
return { notice };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Delete an entry, and the notes on it. */
|
|
58
|
+
export function knowledgeRemove(body: RefBody, ctx: ApiContext): { notice: string } {
|
|
59
|
+
const notice = ctx.service.knowledge.remove(body.agent, body.file, body.ref);
|
|
60
|
+
if (isStale(notice)) stale(notice);
|
|
61
|
+
return { notice };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Replace a whole file. */
|
|
65
|
+
export function knowledgeReplaceFile(body: FileBody & { text: string }, ctx: ApiContext): { notice: string } {
|
|
66
|
+
return { notice: ctx.service.knowledge.replaceFile(body.agent, body.file, body.text) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Leave a note on an entry. */
|
|
70
|
+
export function knowledgeComment(body: RefBody & { text: string }, ctx: ApiContext): { notice: string } {
|
|
71
|
+
const notice = ctx.service.knowledge.comment(body.agent, body.file, body.ref, body.text);
|
|
72
|
+
if (isStale(notice)) stale(notice);
|
|
73
|
+
return { notice };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Take a note back. */
|
|
77
|
+
export function knowledgeUnnote(body: { id: string }, ctx: ApiContext): { notice: string } {
|
|
78
|
+
return { notice: ctx.service.knowledge.unnote(body.id) };
|
|
79
|
+
}
|