@skillcdn/console 0.1.5 → 0.1.7

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.
Files changed (89) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +29 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.css +33 -10
  17. package/dist/console.d.ts +6 -0
  18. package/dist/console.d.ts.map +1 -1
  19. package/dist/console.js +78 -13
  20. package/dist/console.js.map +1 -1
  21. package/dist/data.d.ts.map +1 -1
  22. package/dist/data.js +5 -1
  23. package/dist/data.js.map +1 -1
  24. package/dist/i18n/en.d.ts +4 -0
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +4 -0
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/ko.d.ts.map +1 -1
  29. package/dist/i18n/ko.js +4 -0
  30. package/dist/i18n/ko.js.map +1 -1
  31. package/dist/schemas.d.ts +1 -0
  32. package/dist/schemas.d.ts.map +1 -1
  33. package/dist/schemas.js +5 -0
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/web.d.ts +16 -0
  36. package/dist/web.d.ts.map +1 -0
  37. package/dist/web.js +149 -0
  38. package/dist/web.js.map +1 -0
  39. package/docs/specs/cli.md +92 -0
  40. package/docs/specs/rest.md +220 -0
  41. package/package.json +14 -3
  42. package/skills/building-a-console/SKILL.md +79 -0
  43. package/skills/working-the-board/SKILL.md +78 -0
  44. package/src/api.ts +235 -0
  45. package/src/cli/cli.ts +1114 -0
  46. package/src/cli/credentials.ts +68 -0
  47. package/src/cli/directory.ts +59 -0
  48. package/src/cli/format.ts +284 -0
  49. package/src/cli/help.ts +326 -0
  50. package/src/cli/main.ts +39 -0
  51. package/src/cli/serve.ts +276 -0
  52. package/src/cli/terminal.ts +28 -0
  53. package/src/client.ts +533 -0
  54. package/src/components/board.tsx +112 -0
  55. package/src/components/connect.tsx +126 -0
  56. package/src/components/decision-form.tsx +136 -0
  57. package/src/components/decision-list.tsx +278 -0
  58. package/src/components/documents.tsx +569 -0
  59. package/src/components/event-feed.tsx +129 -0
  60. package/src/components/markdown.tsx +72 -0
  61. package/src/components/people.tsx +59 -0
  62. package/src/components/projects.tsx +364 -0
  63. package/src/components/runs.tsx +175 -0
  64. package/src/components/shell.tsx +284 -0
  65. package/src/components/sign-in.tsx +61 -0
  66. package/src/components/skills.tsx +78 -0
  67. package/src/components/task-form.tsx +229 -0
  68. package/src/components/task-view.tsx +272 -0
  69. package/src/components/tokens.tsx +223 -0
  70. package/src/components/ui.tsx +219 -0
  71. package/src/connect-page.tsx +148 -0
  72. package/src/connect.ts +27 -0
  73. package/src/console.tsx +1166 -0
  74. package/src/data.ts +493 -0
  75. package/src/docs-page.tsx +334 -0
  76. package/src/documents.ts +76 -0
  77. package/src/i18n/en.ts +537 -0
  78. package/src/i18n/index.ts +76 -0
  79. package/src/i18n/ko.ts +511 -0
  80. package/src/index.ts +142 -0
  81. package/src/limits.ts +61 -0
  82. package/src/router.ts +295 -0
  83. package/src/routes.ts +106 -0
  84. package/src/schemas.ts +776 -0
  85. package/src/styles/console.css +2432 -0
  86. package/src/text.ts +33 -0
  87. package/src/use-action.ts +37 -0
  88. package/src/vocabulary.ts +109 -0
  89. package/src/web.ts +170 -0
package/src/client.ts ADDED
@@ -0,0 +1,533 @@
1
+ import {
2
+ AUTH_ROUTES,
3
+ CLAIM_PATH,
4
+ connectPath,
5
+ personTokensPath,
6
+ projectPath,
7
+ REST_ROUTES,
8
+ restPath,
9
+ } from "./routes.js";
10
+ import {
11
+ type RestAnswerInput,
12
+ type RestArtifactInput,
13
+ type RestClaim,
14
+ type RestConnectInput,
15
+ type RestConnection,
16
+ type RestConnectRequest,
17
+ type RestDecision,
18
+ type RestDecisionInput,
19
+ type RestDecisionPatch,
20
+ type RestDecisions,
21
+ type RestDocument,
22
+ type RestDocumentInput,
23
+ type RestDocuments,
24
+ type RestEvents,
25
+ type RestMe,
26
+ type RestMember,
27
+ type RestMemberInput,
28
+ type RestMemberPatch,
29
+ type RestMembers,
30
+ type RestMePatch,
31
+ type RestPeople,
32
+ type RestPerson,
33
+ type RestPersonPatch,
34
+ type RestProject,
35
+ type RestProjectInput,
36
+ type RestProjectPatch,
37
+ type RestProjects,
38
+ type RestReportInput,
39
+ type RestRun,
40
+ type RestRunEndInput,
41
+ type RestRunInput,
42
+ type RestRuns,
43
+ type RestSkills,
44
+ type RestTask,
45
+ type RestTaskInput,
46
+ type RestTaskPatch,
47
+ type RestTasks,
48
+ type RestTokenCreated,
49
+ type RestTokenInput,
50
+ type RestTokens,
51
+ type RestVersion,
52
+ type RestVersions,
53
+ restClaimSchema,
54
+ restConnectionSchema,
55
+ restConnectRequestSchema,
56
+ restDecisionSchema,
57
+ restDecisionsSchema,
58
+ restDocumentSchema,
59
+ restDocumentsSchema,
60
+ restErrorSchema,
61
+ restEventsSchema,
62
+ restMemberSchema,
63
+ restMembersSchema,
64
+ restMeSchema,
65
+ restPeopleSchema,
66
+ restPersonSchema,
67
+ restProjectSchema,
68
+ restProjectsSchema,
69
+ restRunSchema,
70
+ restRunsSchema,
71
+ restSkillsSchema,
72
+ restTaskSchema,
73
+ restTasksSchema,
74
+ restTokenCreatedSchema,
75
+ restTokensSchema,
76
+ restVersionSchema,
77
+ restVersionsSchema,
78
+ } from "./schemas.js";
79
+ import type { TaskState } from "./vocabulary.js";
80
+
81
+ // The client of the console's REST API: what the default console and a custom one talk to the
82
+ // server with, and what the command line, a script or a console of a person's own talks to it
83
+ // with, holding a token of theirs. It takes a `fetch` and a base URL and reads nothing else;
84
+ // every answer is parsed with the schemas the server is tested against. The board is a
85
+ // project's (ADR-0008): `client.project(key)` is the client of one project.
86
+
87
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
88
+
89
+ /** A file to hand in: its bytes, what to call it, and what it is. */
90
+ export interface FileUpload {
91
+ /** The file's name, without a path. */
92
+ readonly name: string;
93
+ readonly bytes: Uint8Array | Blob;
94
+ /** The media type; `application/octet-stream` when left out. */
95
+ readonly contentType?: string | undefined;
96
+ readonly label?: string | undefined;
97
+ }
98
+
99
+ export interface ClientOptions {
100
+ /** The origin of the console, without a path. Empty: the origin of the page. */
101
+ readonly baseUrl?: string;
102
+ /** The platform's `fetch` when left out. Tests hand in one that never leaves the process. */
103
+ readonly fetch?: FetchLike;
104
+ /**
105
+ * A token a person made, for an agent, a script or a console of their own: sent as
106
+ * `Authorization: Bearer`, and the browser's cookies left out. Left out, the session cookie of
107
+ * the page's own origin is what the console answers to.
108
+ */
109
+ readonly token?: string;
110
+ }
111
+
112
+ export class ApiError extends Error {
113
+ /** HTTP status, or 0 when there was no response at all. */
114
+ readonly status: number;
115
+ /** The stable code from the error body, `network` or `invalid_response`. */
116
+ readonly code: string;
117
+
118
+ constructor(status: number, code: string, message: string) {
119
+ super(message);
120
+ this.name = "ApiError";
121
+ this.status = status;
122
+ this.code = code;
123
+ }
124
+ }
125
+
126
+ interface Schema<T> {
127
+ readonly safeParse: (data: unknown) => { success: true; data: T } | { success: false };
128
+ }
129
+
130
+ /** What the events of a project are filtered by: those about one task, one run, one decision or one document. */
131
+ export interface EventFilter {
132
+ readonly task?: string | undefined;
133
+ readonly run?: string | undefined;
134
+ readonly decision?: string | undefined;
135
+ readonly document?: string | undefined;
136
+ }
137
+
138
+ /** What a folder's listing, or a search, of the documents is narrowed to. */
139
+ export interface DocumentFilter {
140
+ /** The folder to list, `""` or left out for the root. */
141
+ readonly folder?: string | undefined;
142
+ /** Words to find pages by; given, the folder is not looked at. */
143
+ readonly q?: string | undefined;
144
+ /** Whether archived pages are listed too. */
145
+ readonly archived?: boolean | undefined;
146
+ }
147
+
148
+ /** The board of one project, as the asker may see and change it. */
149
+ export interface ProjectClient {
150
+ /** The key the project is reached by. */
151
+ readonly key: string;
152
+ /** The project, with what the asker is in it. */
153
+ get(signal?: AbortSignal): Promise<RestProject>;
154
+ /** Changes the project's settings; for an owner. */
155
+ update(patch: RestProjectPatch): Promise<RestProject>;
156
+ /** Those listed in the project, by login. */
157
+ members(signal?: AbortSignal): Promise<RestMembers>;
158
+ /** Lists a person of the workspace in the project; for an owner. */
159
+ addMember(input: RestMemberInput): Promise<RestMember>;
160
+ updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
161
+ removeMember(personId: string): Promise<void>;
162
+ tasks(filter?: { readonly state?: TaskState }, signal?: AbortSignal): Promise<RestTasks>;
163
+ /** One task, by its id or by its number. */
164
+ task(ref: string, signal?: AbortSignal): Promise<RestTask>;
165
+ createTask(input: RestTaskInput): Promise<RestTask>;
166
+ updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
167
+ /** The decisions, or only those that wait, or only those about one task. */
168
+ decisions(
169
+ filter?: { readonly open?: boolean; readonly task?: string },
170
+ signal?: AbortSignal,
171
+ ): Promise<RestDecisions>;
172
+ decision(id: string, signal?: AbortSignal): Promise<RestDecision>;
173
+ /**
174
+ * One decision, after waiting up to `waitSeconds` for its answer: the server holds the request
175
+ * while the decision waits, for as long as it allows, and answers the decision as it stands.
176
+ */
177
+ awaitDecision(id: string, waitSeconds: number, signal?: AbortSignal): Promise<RestDecision>;
178
+ raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
179
+ answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
180
+ /** Grows a decision's record: its context, or what followed. */
181
+ updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
182
+ /** The runs, newest first; or only those on one task, only the open ones, only the asker's own. */
183
+ runs(
184
+ filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean },
185
+ signal?: AbortSignal,
186
+ ): Promise<RestRuns>;
187
+ run(id: string, signal?: AbortSignal): Promise<RestRun>;
188
+ /** Takes a task: a run begins, for the person the asker is or acts for. */
189
+ startRun(input: RestRunInput): Promise<RestRun>;
190
+ /** Says how the work goes, on a run of the asker's. */
191
+ report(runId: string, input: RestReportInput): Promise<RestRun>;
192
+ /** Hands in a link, on a run of the asker's. */
193
+ handIn(runId: string, input: RestArtifactInput): Promise<RestRun>;
194
+ /** Hands in a file, on a run of the asker's: the console keeps the bytes. */
195
+ handInFile(runId: string, input: FileUpload): Promise<RestRun>;
196
+ /** Where the bytes of a file handed in are read, by the artifact's id. */
197
+ fileUrl(artifactId: string): string;
198
+ /** Ends a run: finished or failed by the agent; abandoned by the person it is for, or an administrator. */
199
+ endRun(runId: string, input: RestRunEndInput): Promise<RestRun>;
200
+ /** What happened in the project after event number `after`; `0` for the beginning. Narrowed by `filter`. */
201
+ events(after: number, filter?: EventFilter, signal?: AbortSignal): Promise<RestEvents>;
202
+ /** Where an `EventSource` subscribes to what happens in the project after event number `after`. */
203
+ eventStreamUrl(after: number): string;
204
+ /** The project's skills, as SkillCDN serves them at its address, or the organization's. */
205
+ skills(signal?: AbortSignal): Promise<RestSkills>;
206
+ /** A folder's pages and folders, or the pages a search finds. */
207
+ documents(filter?: DocumentFilter, signal?: AbortSignal): Promise<RestDocuments>;
208
+ /** One document, by its path: its latest version, its links both ways, its files. */
209
+ document(path: string, signal?: AbortSignal): Promise<RestDocument>;
210
+ /** Writes a document at its path, as a new version, or the first one. */
211
+ writeDocument(path: string, input: RestDocumentInput): Promise<RestDocument>;
212
+ /** Puts a document away: kept out of the folders and the search, readable, not written to. */
213
+ archiveDocument(path: string): Promise<RestDocument>;
214
+ /** Brings an archived document back. */
215
+ restoreDocument(path: string): Promise<RestDocument>;
216
+ /** The versions of a document, newest first, each with who wrote it and when. */
217
+ documentVersions(path: string, signal?: AbortSignal): Promise<RestVersions>;
218
+ /** One version of a document, with its body as it was. */
219
+ documentVersion(path: string, number: number, signal?: AbortSignal): Promise<RestVersion>;
220
+ /** Attaches a file to a document: the console keeps the bytes. */
221
+ attachFile(path: string, input: FileUpload): Promise<RestDocument>;
222
+ /** Where the bytes of a file attached to a document are read, by the file's id. */
223
+ documentFileUrl(path: string, fileId: string): string;
224
+ }
225
+
226
+ export interface ConsoleClient {
227
+ me(signal?: AbortSignal): Promise<RestMe>;
228
+ /** Keeps the person's choice of language, or none; for a person signed in. */
229
+ updateMe(patch: RestMePatch): Promise<RestMe>;
230
+ people(signal?: AbortSignal): Promise<RestPeople>;
231
+ /** Changes what a person is; for an administrator. */
232
+ updatePerson(id: string, patch: RestPersonPatch): Promise<RestPerson>;
233
+ /** The projects the asker may see, with what they are in each. */
234
+ projects(signal?: AbortSignal): Promise<RestProjects>;
235
+ /** Makes a project, whose owner the asker becomes. */
236
+ createProject(input: RestProjectInput): Promise<RestProject>;
237
+ /** The board of one project. */
238
+ project(key: string): ProjectClient;
239
+ /** The workspace's own events, the ones about no project, after event number `after`. */
240
+ events(after: number, signal?: AbortSignal): Promise<RestEvents>;
241
+ /** The tokens of whoever asks. */
242
+ tokens(signal?: AbortSignal): Promise<RestTokens>;
243
+ /** The tokens of another person: for an administrator. */
244
+ personTokens(personId: string, signal?: AbortSignal): Promise<RestTokens>;
245
+ /** Takes another person's token away: for an administrator. */
246
+ revokePersonToken(personId: string, tokenId: string): Promise<void>;
247
+ /**
248
+ * Begins connecting an agent (ADR-0011), as nobody: answered with a code for a person to
249
+ * approve and a secret for the command to claim the token with.
250
+ */
251
+ connect(input: RestConnectInput): Promise<RestConnection>;
252
+ /** What asks to connect under a code, for the person who approves it. */
253
+ connectRequest(code: string, signal?: AbortSignal): Promise<RestConnectRequest>;
254
+ /** Approves a connection as oneself: the agent's name, and for how long its token is good. */
255
+ approveConnection(code: string, input: RestTokenInput): Promise<RestConnectRequest>;
256
+ /** Says the connection is not one's own: it goes. */
257
+ denyConnection(code: string): Promise<void>;
258
+ /** Claims the token of a connection with its secret: pending until approved, then the token this once. */
259
+ claimConnection(secret: string): Promise<RestClaim>;
260
+ /** Makes a token; the answer carries the secret, this once. */
261
+ createToken(input: RestTokenInput): Promise<RestTokenCreated>;
262
+ /** Takes a token away, whoever holds it. */
263
+ revokeToken(id: string): Promise<void>;
264
+ /** Ends the session on this browser. */
265
+ signOut(): Promise<void>;
266
+ }
267
+
268
+ const INVALID = "The server answered unexpectedly.";
269
+
270
+ export function createClient(options: ClientOptions = {}): ConsoleClient {
271
+ const base = (options.baseUrl ?? "").replace(/\/+$/, "");
272
+ const send = options.fetch ?? ((input, init) => fetch(input, init));
273
+ const { token } = options;
274
+
275
+ const withQuery = (path: string, params: Record<string, string | undefined>): string => {
276
+ const query = new URLSearchParams();
277
+ for (const [name, value] of Object.entries(params)) {
278
+ if (value !== undefined) {
279
+ query.set(name, value);
280
+ }
281
+ }
282
+ return query.size === 0 ? `${base}${path}` : `${base}${path}?${query}`;
283
+ };
284
+
285
+ /** What a response says when it is not what was asked for. */
286
+ const failure = async (response: Response): Promise<ApiError> => {
287
+ const parsed = restErrorSchema.safeParse(await response.json().catch(() => undefined));
288
+ return parsed.success
289
+ ? new ApiError(response.status, parsed.data.error.code, parsed.data.error.message)
290
+ : new ApiError(response.status, "invalid_response", INVALID);
291
+ };
292
+
293
+ const request = async <T>(
294
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
295
+ url: string,
296
+ body: unknown,
297
+ schema: Schema<T> | undefined,
298
+ signal?: AbortSignal,
299
+ ): Promise<T> => {
300
+ const form = body instanceof FormData;
301
+ let response: Response;
302
+ try {
303
+ response = await send(url, {
304
+ method,
305
+ ...(signal === undefined ? {} : { signal }),
306
+ // Same origin, so the session cookie goes with it; the browser names the origin of a
307
+ // request that changes something, which the server checks. With a token, the token is
308
+ // the credential and no cookie travels.
309
+ credentials: token === undefined ? "same-origin" : "omit",
310
+ headers: {
311
+ accept: "application/json",
312
+ // A form names its own type, boundary included; everything else is JSON.
313
+ ...(body === undefined || form ? {} : { "content-type": "application/json" }),
314
+ ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
315
+ },
316
+ ...(body === undefined ? {} : { body: form ? body : JSON.stringify(body) }),
317
+ });
318
+ } catch (error) {
319
+ if (signal?.aborted === true) {
320
+ throw error;
321
+ }
322
+ throw new ApiError(0, "network", "The server could not be reached.");
323
+ }
324
+ if (!response.ok) {
325
+ throw await failure(response);
326
+ }
327
+ if (schema === undefined) {
328
+ return undefined as T;
329
+ }
330
+ const parsed = schema.safeParse(await response.json().catch(() => undefined));
331
+ if (!parsed.success) {
332
+ throw new ApiError(response.status, "invalid_response", INVALID);
333
+ }
334
+ return parsed.data;
335
+ };
336
+
337
+ const flag = (value: boolean | undefined): string | undefined =>
338
+ value === undefined ? undefined : String(value);
339
+
340
+ /** A file as the parts of a form: the bytes with their name and type, and the label. */
341
+ const formOf = (input: FileUpload): FormData => {
342
+ const form = new FormData();
343
+ const bytes =
344
+ input.bytes instanceof Blob ? input.bytes : new Blob([Uint8Array.from(input.bytes)]);
345
+ form.set(
346
+ "file",
347
+ new File([bytes], input.name, { type: input.contentType ?? "application/octet-stream" }),
348
+ input.name,
349
+ );
350
+ if (input.label !== undefined) {
351
+ form.set("label", input.label);
352
+ }
353
+ return form;
354
+ };
355
+
356
+ const projectClient = (key: string): ProjectClient => {
357
+ const at = (collection?: Parameters<typeof projectPath>[1], id?: string) =>
358
+ projectPath(key, collection, id);
359
+ return {
360
+ key,
361
+ get: (signal) => request("GET", `${base}${at()}`, undefined, restProjectSchema, signal),
362
+ update: (patch) => request("PATCH", `${base}${at()}`, patch, restProjectSchema),
363
+ members: (signal) =>
364
+ request("GET", `${base}${at("members")}`, undefined, restMembersSchema, signal),
365
+ addMember: (input) => request("POST", `${base}${at("members")}`, input, restMemberSchema),
366
+ updateMember: (personId, patch) =>
367
+ request("PATCH", `${base}${at("members", personId)}`, patch, restMemberSchema),
368
+ removeMember: (personId) =>
369
+ request("DELETE", `${base}${at("members", personId)}`, undefined, undefined),
370
+ tasks: (filter = {}, signal) =>
371
+ request(
372
+ "GET",
373
+ withQuery(at("tasks"), { state: filter.state }),
374
+ undefined,
375
+ restTasksSchema,
376
+ signal,
377
+ ),
378
+ task: (ref, signal) =>
379
+ request("GET", `${base}${at("tasks", ref)}`, undefined, restTaskSchema, signal),
380
+ createTask: (input) => request("POST", `${base}${at("tasks")}`, input, restTaskSchema),
381
+ updateTask: (id, patch) =>
382
+ request("PATCH", `${base}${at("tasks", id)}`, patch, restTaskSchema),
383
+ decisions: (filter = {}, signal) =>
384
+ request(
385
+ "GET",
386
+ withQuery(at("decisions"), { open: flag(filter.open), task: filter.task }),
387
+ undefined,
388
+ restDecisionsSchema,
389
+ signal,
390
+ ),
391
+ decision: (id, signal) =>
392
+ request("GET", `${base}${at("decisions", id)}`, undefined, restDecisionSchema, signal),
393
+ awaitDecision: (id, waitSeconds, signal) =>
394
+ request(
395
+ "GET",
396
+ withQuery(at("decisions", id), { wait: String(waitSeconds) }),
397
+ undefined,
398
+ restDecisionSchema,
399
+ signal,
400
+ ),
401
+ raiseDecision: (input) =>
402
+ request("POST", `${base}${at("decisions")}`, input, restDecisionSchema),
403
+ answerDecision: (id, input) =>
404
+ request("POST", `${base}${at("decisions", id)}/answer`, input, restDecisionSchema),
405
+ updateDecision: (id, patch) =>
406
+ request("PATCH", `${base}${at("decisions", id)}`, patch, restDecisionSchema),
407
+ runs: (filter = {}, signal) =>
408
+ request(
409
+ "GET",
410
+ withQuery(at("runs"), {
411
+ task: filter.task,
412
+ open: flag(filter.open),
413
+ mine: flag(filter.mine),
414
+ }),
415
+ undefined,
416
+ restRunsSchema,
417
+ signal,
418
+ ),
419
+ run: (id, signal) =>
420
+ request("GET", `${base}${at("runs", id)}`, undefined, restRunSchema, signal),
421
+ startRun: (input) => request("POST", `${base}${at("runs")}`, input, restRunSchema),
422
+ report: (runId, input) =>
423
+ request("POST", `${base}${at("runs", runId)}/reports`, input, restRunSchema),
424
+ handIn: (runId, input) =>
425
+ request("POST", `${base}${at("runs", runId)}/artifacts`, input, restRunSchema),
426
+ handInFile: (runId, input) =>
427
+ request("POST", `${base}${at("runs", runId)}/files`, formOf(input), restRunSchema),
428
+ fileUrl: (artifactId) => `${base}${at("files", artifactId)}`,
429
+ endRun: (runId, input) =>
430
+ request("POST", `${base}${at("runs", runId)}/end`, input, restRunSchema),
431
+ events: (after, filter = {}, signal) =>
432
+ request(
433
+ "GET",
434
+ withQuery(at("events"), {
435
+ after: String(after),
436
+ task: filter.task,
437
+ run: filter.run,
438
+ decision: filter.decision,
439
+ document: filter.document,
440
+ }),
441
+ undefined,
442
+ restEventsSchema,
443
+ signal,
444
+ ),
445
+ eventStreamUrl: (after) => withQuery(`${at("events")}/stream`, { after: String(after) }),
446
+ skills: (signal) =>
447
+ request("GET", `${base}${at("skills")}`, undefined, restSkillsSchema, signal),
448
+ documents: (filter = {}, signal) =>
449
+ request(
450
+ "GET",
451
+ withQuery(at("docs"), {
452
+ folder: filter.q === undefined ? filter.folder : undefined,
453
+ q: filter.q,
454
+ archived: flag(filter.archived),
455
+ }),
456
+ undefined,
457
+ restDocumentsSchema,
458
+ signal,
459
+ ),
460
+ document: (path, signal) =>
461
+ request("GET", `${base}${at("docs", path)}`, undefined, restDocumentSchema, signal),
462
+ writeDocument: (path, input) =>
463
+ request("PUT", `${base}${at("docs", path)}`, input, restDocumentSchema),
464
+ archiveDocument: (path) =>
465
+ request("POST", `${base}${at("docs", path)}/archive`, undefined, restDocumentSchema),
466
+ restoreDocument: (path) =>
467
+ request("POST", `${base}${at("docs", path)}/restore`, undefined, restDocumentSchema),
468
+ documentVersions: (path, signal) =>
469
+ request(
470
+ "GET",
471
+ `${base}${at("docs", path)}/versions`,
472
+ undefined,
473
+ restVersionsSchema,
474
+ signal,
475
+ ),
476
+ documentVersion: (path, number, signal) =>
477
+ request(
478
+ "GET",
479
+ `${base}${at("docs", path)}/versions/${number}`,
480
+ undefined,
481
+ restVersionSchema,
482
+ signal,
483
+ ),
484
+ attachFile: (path, input) =>
485
+ request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
486
+ documentFileUrl: (path, fileId) =>
487
+ `${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
488
+ };
489
+ };
490
+
491
+ return {
492
+ me: (signal) => request("GET", `${base}${REST_ROUTES.me}`, undefined, restMeSchema, signal),
493
+ people: (signal) =>
494
+ request("GET", `${base}${REST_ROUTES.people}`, undefined, restPeopleSchema, signal),
495
+ updatePerson: (id, patch) =>
496
+ request("PATCH", `${base}${restPath("people", id)}`, patch, restPersonSchema),
497
+ projects: (signal) =>
498
+ request("GET", `${base}${REST_ROUTES.projects}`, undefined, restProjectsSchema, signal),
499
+ createProject: (input) =>
500
+ request("POST", `${base}${REST_ROUTES.projects}`, input, restProjectSchema),
501
+ project: projectClient,
502
+ events: (after, signal) =>
503
+ request(
504
+ "GET",
505
+ withQuery(REST_ROUTES.events, { after: String(after) }),
506
+ undefined,
507
+ restEventsSchema,
508
+ signal,
509
+ ),
510
+ tokens: (signal) =>
511
+ request("GET", `${base}${REST_ROUTES.tokens}`, undefined, restTokensSchema, signal),
512
+ createToken: (input) =>
513
+ request("POST", `${base}${REST_ROUTES.tokens}`, input, restTokenCreatedSchema),
514
+ revokeToken: (id) =>
515
+ request("DELETE", `${base}${restPath("tokens", id)}`, undefined, undefined),
516
+ updateMe: (patch) => request("PATCH", `${base}${REST_ROUTES.me}`, patch, restMeSchema),
517
+ personTokens: (personId, signal) =>
518
+ request("GET", `${base}${personTokensPath(personId)}`, undefined, restTokensSchema, signal),
519
+ revokePersonToken: (personId, tokenId) =>
520
+ request("DELETE", `${base}${personTokensPath(personId, tokenId)}`, undefined, undefined),
521
+ connect: (input) =>
522
+ request("POST", `${base}${REST_ROUTES.connect}`, input, restConnectionSchema),
523
+ connectRequest: (code, signal) =>
524
+ request("GET", `${base}${connectPath(code)}`, undefined, restConnectRequestSchema, signal),
525
+ approveConnection: (code, input) =>
526
+ request("POST", `${base}${connectPath(code, "approve")}`, input, restConnectRequestSchema),
527
+ denyConnection: (code) =>
528
+ request("POST", `${base}${connectPath(code, "deny")}`, undefined, undefined),
529
+ claimConnection: (secret) =>
530
+ request("POST", `${base}${CLAIM_PATH}`, { secret }, restClaimSchema),
531
+ signOut: () => request("POST", `${base}${AUTH_ROUTES.logout}`, undefined, undefined),
532
+ };
533
+ }
@@ -0,0 +1,112 @@
1
+ import type { ReactNode } from "react";
2
+ import { type RestTask, TASK_STATES, type TaskState } from "../api.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { Avatar, Badge, cx, PriorityBadge } from "./ui.js";
5
+
6
+ // The board: one column per state, the tasks in it, newest first. It takes its data as props
7
+ // and nothing from the network; what it is told on a click goes back up.
8
+
9
+ export interface BoardProps {
10
+ readonly tasks: readonly RestTask[];
11
+ /** The link to a task's own page, as the composition routes it. */
12
+ readonly taskHref: (task: RestTask) => string;
13
+ readonly onOpen: (task: RestTask) => void;
14
+ /** Called when a person moves a task to another column. Left out, the board is read-only. */
15
+ readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
16
+ /** What a column shows when it has nothing; the way to write the first task, for example. */
17
+ readonly empty?: ReactNode;
18
+ }
19
+
20
+ export function TaskCard(props: {
21
+ readonly task: RestTask;
22
+ readonly href: string;
23
+ readonly onOpen: (task: RestTask) => void;
24
+ readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
25
+ }) {
26
+ const words = useWords();
27
+ const { task } = props;
28
+ return (
29
+ <article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
30
+ <a
31
+ className="sc-card-title"
32
+ href={props.href}
33
+ onClick={(event) => {
34
+ if (event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
35
+ event.preventDefault();
36
+ props.onOpen(task);
37
+ }
38
+ }}
39
+ >
40
+ <span className="sc-card-number">#{task.number}</span> {task.title}
41
+ </a>
42
+ <div className="sc-card-meta">
43
+ <PriorityBadge priority={task.priority} />
44
+ {task.openDecisions > 0 && (
45
+ <Badge tone="warning" title={words.board.decisionsTitle}>
46
+ {words.board.decisions(task.openDecisions)}
47
+ </Badge>
48
+ )}
49
+ {task.openRuns > 0 && (
50
+ <Badge tone="accent" title={words.board.agentAtWorkTitle}>
51
+ {words.board.agentAtWork}
52
+ </Badge>
53
+ )}
54
+ {task.links.length > 0 && (
55
+ <Badge tone="neutral">{words.board.links(task.links.length)}</Badge>
56
+ )}
57
+ <span className="sc-card-spacer" />
58
+ {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
59
+ </div>
60
+ {props.onMove !== undefined && (
61
+ <label className="sc-card-move">
62
+ <span className="sc-visually-hidden">{words.board.moveTo}</span>
63
+ <select
64
+ value={task.state}
65
+ onChange={(event) => props.onMove?.(task, event.target.value as TaskState)}
66
+ >
67
+ {TASK_STATES.map((state) => (
68
+ <option key={state} value={state}>
69
+ {words.vocabulary.state[state]}
70
+ </option>
71
+ ))}
72
+ </select>
73
+ </label>
74
+ )}
75
+ </article>
76
+ );
77
+ }
78
+
79
+ export function Board(props: BoardProps) {
80
+ const words = useWords();
81
+ return (
82
+ <div className="sc-board">
83
+ {TASK_STATES.map((state) => {
84
+ const tasks = props.tasks.filter((task) => task.state === state);
85
+ return (
86
+ <section
87
+ key={state}
88
+ className={cx("sc-column", `sc-column-${state}`)}
89
+ aria-label={words.vocabulary.state[state]}
90
+ >
91
+ <header className="sc-column-header">
92
+ <h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
93
+ <span className="sc-column-count">{tasks.length}</span>
94
+ </header>
95
+ <div className="sc-column-body">
96
+ {tasks.map((task) => (
97
+ <TaskCard
98
+ key={task.id}
99
+ task={task}
100
+ href={props.taskHref(task)}
101
+ onOpen={props.onOpen}
102
+ onMove={props.onMove}
103
+ />
104
+ ))}
105
+ {tasks.length === 0 && state === "idea" && props.empty}
106
+ </div>
107
+ </section>
108
+ );
109
+ })}
110
+ </div>
111
+ );
112
+ }