@evanbobo/colco4dsh 0.1.3 → 0.1.4
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 +6 -4
- package/client.js +615 -50
- package/lib/cli.mjs +1 -1
- package/lib/routes.mjs +75 -3
- package/lib/tasks.mjs +158 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A DSH (DeepSeek Harness) plugin for Colco. A **Colco** row in DSH's left sidebar
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- DSH 0.2.x (`engines.dsh: ">=0.2.0 <0.3.0"`), Node 20.3 or later.
|
|
8
|
-
- The `colco` CLI from `@evanbobo/colco` 0.
|
|
8
|
+
- The `colco` CLI from `@evanbobo/colco` 0.23.0 or later on `PATH` (`npm i -g @evanbobo/colco`), logged in: `npx @evanbobo/colco add` saves the server and a read/write API key in `~/.colco/config.json`, or set `COLCO_AGENT_TOKEN`. `colco whoami` should show you. The plugin calls `colco … --json` and nothing else; it never talks to the Colco server itself. The My tasks board (`colco tasks`) and the rules.json dialog (`colco runner rules get|set`) are the reason for that minimum.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -32,8 +32,10 @@ The bundle inserts the row `{ id: colco, name: '@evanbobo/colco4dsh' }`. To chan
|
|
|
32
32
|
|
|
33
33
|
## What it does
|
|
34
34
|
|
|
35
|
-
- Page **Colco** (the left-sidebar row above More; on a narrow window, the icon in the collapsed rail): the runner's state and how many steps run now, with Start (installs the login service) and Stop; one card per active run, with the task link; open decisions with their options in Colco's order, the recommended one marked, an optional note and Answer;
|
|
36
|
-
-
|
|
35
|
+
- Page **Colco** (the left-sidebar row above More; on a narrow window, the icon in the collapsed rail): the runner's state and how many steps run now, with Start (installs the login service) and Stop; one card per active run, with the task link; open decisions with their options in Colco's order, the recommended one marked, an optional note and Answer; a setup form for label → workflow rows and folders, and an **Edit rules.json** button that opens a dialog over the raw `~/.colco/rules.json`. The dialog saves through `colco runner rules set`, which checks the rules and backs up the old file first. Invalid JSON or rules are shown and nothing is replaced. If the file changed after the dialog loaded it, the save is refused and the dialog shows the file as it is now, so you choose between keeping the file on disk and overwriting it. For a folder that is missing, not a git repository or without origin, it shows a git command to copy, built from the Colco repository you pick; it never runs git itself. It refreshes every 10 seconds while the page is open. The copy is English and Chinese and follows DSH's language setting.
|
|
36
|
+
- Page **My tasks** (the section below the decisions on the same page): the tasks assigned to you in every active Colco project you belong to, grouped by status (in progress, blocked, in review, to do, backlog, done), 25 per page, with a project filter, a status filter and a pager. Each row shows the project, the priority and the due date, and its title opens the task in Colco. The board is read-only: change tasks in Colco. Tasks of other people, archived projects and projects you have left never appear. It reloads every minute and when you press Refresh; if `colco tasks` fails, only this section shows the error.
|
|
37
|
+
- A red dot on the Colco icon marks an open decision, also while the page is closed. The icon and the page share one status read, refreshed every 10 seconds while DSH is visible. The dot has an accessible label with the count, clears as soon as you answer, and is not shown when the read fails.
|
|
38
|
+
- The page talks only to the plugin's own routes under `/plugins/colco4dsh/api`, which accept requests only from this machine's DSH page (loopback, DSH's origin, DSH's sign-in cookie, and a custom header on every POST) and run `colco … --json`. `GET tasks?project=&status=&page=` accepts nothing else, so a request cannot name another person.
|
|
37
39
|
- Tool `colco_status`: your runner's state, your active runs (queued, running or uncertain) and your open decisions. Read-only.
|
|
38
40
|
- Tool `colco_answer_decision { id, index?, text? }`: with `index`, answers the decision with that option. Without `index`, it shows the decision as a question card and answers with the option you pick. A skipped card or a free-text reply answers nothing.
|
|
39
41
|
- Skill `colco-decisions`: tells the agent to show each open decision with `ask_user_question` (options in Colco's order, the recommended one marked, not moved) and to answer only with your choice.
|
|
@@ -43,7 +45,7 @@ Colco text (task titles, decision text, options, summaries) reaches the model in
|
|
|
43
45
|
## What it does not do
|
|
44
46
|
|
|
45
47
|
- No question cards without a conversation turn; the Colco page is where decisions show up at any time.
|
|
46
|
-
- No stopping or retrying single runs (use `colco run stop|retry`), and
|
|
48
|
+
- No stopping or retrying single runs (use `colco run stop|retry`), and the setup form does not edit agents or workflows (use Edit rules.json; Save in the form keeps them as they are).
|
|
47
49
|
- The skill is named `colco-decisions` so it does not hide the `colco` Skill that `@evanbobo/colco` installs.
|
|
48
50
|
|
|
49
51
|
## Develop
|
package/client.js
CHANGED
|
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
id: "@evanbobo/colco4dsh",
|
|
7
7
|
factory(require) {
|
|
8
8
|
const React = require("react");
|
|
9
|
-
const { createElement: h, Fragment, useCallback, useEffect,
|
|
9
|
+
const { createElement: h, Fragment, useCallback, useEffect, useState } = React;
|
|
10
10
|
|
|
11
11
|
/** Base path of the Host routes. DSH serves plugin bundles under /plugins; this longer prefix belongs to us. */
|
|
12
12
|
const API = "/plugins/colco4dsh/api";
|
|
@@ -90,6 +90,8 @@ window.__ModuleLoader__.load({
|
|
|
90
90
|
"setup.foldersHint": "The git repositories the runner may work in.",
|
|
91
91
|
"setup.path": "Folder path",
|
|
92
92
|
"setup.addFolder": "Add folder",
|
|
93
|
+
"setup.dsh": "Let workflow steps use DSH",
|
|
94
|
+
"setup.dshHint": "Adds a DSH agent to the rules. A step uses it only when its rules name it; the plugin alone never claims or reassigns tasks.",
|
|
93
95
|
"setup.ok": "Git repository, origin {origin}",
|
|
94
96
|
"setup.unchecked": "Not checked yet; saving checks it.",
|
|
95
97
|
"setup.missingFolder": "The folder does not exist.",
|
|
@@ -109,6 +111,60 @@ window.__ModuleLoader__.load({
|
|
|
109
111
|
"setup.duplicateLabel": "The label {label} appears twice.",
|
|
110
112
|
"setup.incomplete": "Every label row needs both a label and a workflow.",
|
|
111
113
|
"setup.notOnServer": "{label} (not on the server)",
|
|
114
|
+
"panel.openDecision": "{count} open decision waiting for you",
|
|
115
|
+
"panel.openDecisions": "{count} open decisions waiting for you",
|
|
116
|
+
"runner.editRulesFile": "Edit rules.json",
|
|
117
|
+
"file.title": "Edit rules.json",
|
|
118
|
+
"file.loading": "Loading rules.json",
|
|
119
|
+
"file.loadFailed": "Could not load rules.json: {message}",
|
|
120
|
+
"file.path": "File: {path}",
|
|
121
|
+
"file.missing": "There is no rules.json yet; saving creates it.",
|
|
122
|
+
"file.diskInvalid": "The file on disk has these problems:",
|
|
123
|
+
"file.label": "rules.json contents",
|
|
124
|
+
"file.hint": "Saving checks the rules, keeps a backup of the old file and refuses if the file changed after it was loaded.",
|
|
125
|
+
"file.save": "Save",
|
|
126
|
+
"file.saving": "Saving",
|
|
127
|
+
"file.close": "Close",
|
|
128
|
+
"file.saved": "Saved {path}",
|
|
129
|
+
"file.savedBackup": "Saved {path}; the previous file is at {backup}",
|
|
130
|
+
"file.saveFailed": "The rules were not saved: {message}",
|
|
131
|
+
"file.conflict": "rules.json changed outside this dialog. Review the file on disk, then choose which version to keep.",
|
|
132
|
+
"file.diskLabel": "Current file on disk",
|
|
133
|
+
"file.useDisk": "Use the file on disk",
|
|
134
|
+
"file.overwrite": "Overwrite with my version",
|
|
135
|
+
"tasks.title": "My tasks",
|
|
136
|
+
"tasks.loading": "Loading your tasks",
|
|
137
|
+
"tasks.error": "Could not load your tasks: {message}",
|
|
138
|
+
"tasks.refresh": "Refresh",
|
|
139
|
+
"tasks.empty": "No tasks are assigned to you.",
|
|
140
|
+
"tasks.emptyFiltered": "No tasks match these filters.",
|
|
141
|
+
"tasks.pageEmpty": "There are no tasks on this page.",
|
|
142
|
+
"tasks.filterProject": "Project",
|
|
143
|
+
"tasks.filterStatus": "Status",
|
|
144
|
+
"tasks.allProjects": "All projects",
|
|
145
|
+
"tasks.allStatuses": "All statuses",
|
|
146
|
+
"tasks.optionCount": "{name} ({count})",
|
|
147
|
+
"tasks.projectGone": "Project #{id}",
|
|
148
|
+
"tasks.status.in_progress": "In progress",
|
|
149
|
+
"tasks.status.blocked": "Blocked",
|
|
150
|
+
"tasks.status.review": "In review",
|
|
151
|
+
"tasks.status.todo": "To do",
|
|
152
|
+
"tasks.status.backlog": "Backlog",
|
|
153
|
+
"tasks.status.done": "Done",
|
|
154
|
+
"tasks.priority.high": "High",
|
|
155
|
+
"tasks.priority.medium": "Medium",
|
|
156
|
+
"tasks.priority.low": "Low",
|
|
157
|
+
"tasks.priorityTag": "{priority} priority",
|
|
158
|
+
"tasks.due": "Due {date}",
|
|
159
|
+
"tasks.noDue": "No due date",
|
|
160
|
+
"tasks.groupCount": "{shown} of {total}",
|
|
161
|
+
"tasks.range": "{from}–{to} of {total}",
|
|
162
|
+
"tasks.pageOf": "Page {page} of {pages}",
|
|
163
|
+
"tasks.previous": "Previous",
|
|
164
|
+
"tasks.next": "Next",
|
|
165
|
+
"tasks.pager": "Task pages",
|
|
166
|
+
"tasks.untitled": "Task #{id}",
|
|
167
|
+
"tasks.refreshLabel": "Refresh my tasks",
|
|
112
168
|
};
|
|
113
169
|
const zh = {
|
|
114
170
|
"panel.title": "Colco",
|
|
@@ -176,6 +232,8 @@ window.__ModuleLoader__.load({
|
|
|
176
232
|
"setup.foldersHint": "Runner \u53ef\u4ee5\u5de5\u4f5c\u7684 git \u4ed3\u5e93\u76ee\u5f55\u3002",
|
|
177
233
|
"setup.path": "\u6587\u4ef6\u5939\u8def\u5f84",
|
|
178
234
|
"setup.addFolder": "\u6dfb\u52a0\u6587\u4ef6\u5939",
|
|
235
|
+
"setup.dsh": "\u8ba9\u5de5\u4f5c\u6d41\u6b65\u9aa4\u53ef\u4ee5\u4f7f\u7528 DSH",
|
|
236
|
+
"setup.dshHint": "\u5728\u89c4\u5219\u91cc\u52a0\u5165\u4e00\u4e2a DSH agent\u3002\u53ea\u6709\u89c4\u5219\u91cc\u660e\u786e\u6307\u5b9a\u5b83\u7684\u6b65\u9aa4\u624d\u4f1a\u7528\u5230\uff1b\u4ec5\u5b89\u88c5\u63d2\u4ef6\u4e0d\u4f1a\u9886\u53d6\u6216\u6539\u6d3e\u4efb\u52a1\u3002",
|
|
179
237
|
"setup.ok": "git \u4ed3\u5e93\uff0corigin \u4e3a {origin}",
|
|
180
238
|
"setup.unchecked": "\u5c1a\u672a\u68c0\u67e5\uff1b\u4fdd\u5b58\u540e\u4f1a\u68c0\u67e5\u3002",
|
|
181
239
|
"setup.missingFolder": "\u6587\u4ef6\u5939\u4e0d\u5b58\u5728\u3002",
|
|
@@ -195,6 +253,60 @@ window.__ModuleLoader__.load({
|
|
|
195
253
|
"setup.duplicateLabel": "\u6807\u7b7e {label} \u51fa\u73b0\u4e86\u4e24\u6b21\u3002",
|
|
196
254
|
"setup.incomplete": "\u6bcf\u4e00\u884c\u6807\u7b7e\u90fd\u9700\u8981\u540c\u65f6\u9009\u62e9\u6807\u7b7e\u548c\u5de5\u4f5c\u6d41\u3002",
|
|
197
255
|
"setup.notOnServer": "{label}\uff08\u670d\u52a1\u5668\u4e0a\u6ca1\u6709\uff09",
|
|
256
|
+
"panel.openDecision": "{count} \u4e2a\u5f85\u4f60\u51b3\u7b56",
|
|
257
|
+
"panel.openDecisions": "{count} \u4e2a\u5f85\u4f60\u51b3\u7b56",
|
|
258
|
+
"runner.editRulesFile": "\u7f16\u8f91 rules.json",
|
|
259
|
+
"file.title": "\u7f16\u8f91 rules.json",
|
|
260
|
+
"file.loading": "\u6b63\u5728\u52a0\u8f7d rules.json",
|
|
261
|
+
"file.loadFailed": "\u65e0\u6cd5\u52a0\u8f7d rules.json\uff1a{message}",
|
|
262
|
+
"file.path": "\u6587\u4ef6\uff1a{path}",
|
|
263
|
+
"file.missing": "\u8fd8\u6ca1\u6709 rules.json\uff1b\u4fdd\u5b58\u65f6\u4f1a\u521b\u5efa\u5b83\u3002",
|
|
264
|
+
"file.diskInvalid": "\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6\u6709\u4ee5\u4e0b\u95ee\u9898\uff1a",
|
|
265
|
+
"file.label": "rules.json \u5185\u5bb9",
|
|
266
|
+
"file.hint": "\u4fdd\u5b58\u524d\u4f1a\u68c0\u67e5\u89c4\u5219\u5e76\u5907\u4efd\u539f\u6587\u4ef6\uff1b\u5982\u679c\u6587\u4ef6\u5728\u52a0\u8f7d\u540e\u88ab\u6539\u8fc7\uff0c\u4fdd\u5b58\u4f1a\u88ab\u62d2\u7edd\u3002",
|
|
267
|
+
"file.save": "\u4fdd\u5b58",
|
|
268
|
+
"file.saving": "\u6b63\u5728\u4fdd\u5b58",
|
|
269
|
+
"file.close": "\u5173\u95ed",
|
|
270
|
+
"file.saved": "\u5df2\u4fdd\u5b58 {path}",
|
|
271
|
+
"file.savedBackup": "\u5df2\u4fdd\u5b58 {path}\uff1b\u539f\u6587\u4ef6\u5728 {backup}",
|
|
272
|
+
"file.saveFailed": "\u89c4\u5219\u672a\u4fdd\u5b58\uff1a{message}",
|
|
273
|
+
"file.conflict": "rules.json \u5728\u6b64\u5bf9\u8bdd\u6846\u4e4b\u5916\u88ab\u6539\u8fc7\u3002\u8bf7\u5148\u67e5\u770b\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6\uff0c\u518d\u9009\u62e9\u4fdd\u7559\u54ea\u4e2a\u7248\u672c\u3002",
|
|
274
|
+
"file.diskLabel": "\u78c1\u76d8\u4e0a\u7684\u5f53\u524d\u6587\u4ef6",
|
|
275
|
+
"file.useDisk": "\u4f7f\u7528\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6",
|
|
276
|
+
"file.overwrite": "\u7528\u6211\u7684\u7248\u672c\u8986\u76d6",
|
|
277
|
+
"tasks.title": "\u6211\u7684\u4efb\u52a1",
|
|
278
|
+
"tasks.loading": "\u6b63\u5728\u52a0\u8f7d\u4f60\u7684\u4efb\u52a1",
|
|
279
|
+
"tasks.error": "\u65e0\u6cd5\u52a0\u8f7d\u4f60\u7684\u4efb\u52a1\uff1a{message}",
|
|
280
|
+
"tasks.refresh": "\u5237\u65b0",
|
|
281
|
+
"tasks.empty": "\u6ca1\u6709\u5206\u914d\u7ed9\u4f60\u7684\u4efb\u52a1\u3002",
|
|
282
|
+
"tasks.emptyFiltered": "\u6ca1\u6709\u7b26\u5408\u7b5b\u9009\u6761\u4ef6\u7684\u4efb\u52a1\u3002",
|
|
283
|
+
"tasks.pageEmpty": "\u8fd9\u4e00\u9875\u6ca1\u6709\u4efb\u52a1\u3002",
|
|
284
|
+
"tasks.filterProject": "\u9879\u76ee",
|
|
285
|
+
"tasks.filterStatus": "\u72b6\u6001",
|
|
286
|
+
"tasks.allProjects": "\u5168\u90e8\u9879\u76ee",
|
|
287
|
+
"tasks.allStatuses": "\u5168\u90e8\u72b6\u6001",
|
|
288
|
+
"tasks.optionCount": "{name}\uff08{count} \u4e2a\uff09",
|
|
289
|
+
"tasks.projectGone": "\u9879\u76ee #{id}",
|
|
290
|
+
"tasks.status.in_progress": "\u8fdb\u884c\u4e2d",
|
|
291
|
+
"tasks.status.blocked": "\u963b\u585e",
|
|
292
|
+
"tasks.status.review": "\u5f85\u9a8c\u6536",
|
|
293
|
+
"tasks.status.todo": "\u5f85\u529e",
|
|
294
|
+
"tasks.status.backlog": "\u5f85\u6574\u7406",
|
|
295
|
+
"tasks.status.done": "\u5df2\u5b8c\u6210",
|
|
296
|
+
"tasks.priority.high": "\u9ad8",
|
|
297
|
+
"tasks.priority.medium": "\u4e2d",
|
|
298
|
+
"tasks.priority.low": "\u4f4e",
|
|
299
|
+
"tasks.priorityTag": "{priority}\u4f18\u5148\u7ea7",
|
|
300
|
+
"tasks.due": "\u622a\u6b62 {date}",
|
|
301
|
+
"tasks.noDue": "\u65e0\u622a\u6b62\u65e5\u671f",
|
|
302
|
+
"tasks.groupCount": "\u672c\u9875 {shown} \u4e2a\uff0c\u5171 {total} \u4e2a",
|
|
303
|
+
"tasks.range": "\u7b2c {from}\u2013{to} \u4e2a\uff0c\u5171 {total} \u4e2a",
|
|
304
|
+
"tasks.pageOf": "\u7b2c {page} / {pages} \u9875",
|
|
305
|
+
"tasks.previous": "\u4e0a\u4e00\u9875",
|
|
306
|
+
"tasks.next": "\u4e0b\u4e00\u9875",
|
|
307
|
+
"tasks.pager": "\u4efb\u52a1\u5206\u9875",
|
|
308
|
+
"tasks.untitled": "\u4efb\u52a1 #{id}",
|
|
309
|
+
"tasks.refreshLabel": "\u5237\u65b0\u6211\u7684\u4efb\u52a1",
|
|
198
310
|
};
|
|
199
311
|
|
|
200
312
|
/**
|
|
@@ -327,6 +439,31 @@ window.__ModuleLoader__.load({
|
|
|
327
439
|
return { rules: { ...(rules ?? {}), labels, folders } };
|
|
328
440
|
}
|
|
329
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Whether the rules define a DSH agent (an agent whose `use` is `dsh`).
|
|
444
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk.
|
|
445
|
+
*/
|
|
446
|
+
function hasDshAgent(rules) {
|
|
447
|
+
const agents = rules && rules.agents && typeof rules.agents === "object" && !Array.isArray(rules.agents) ? Object.values(rules.agents) : [];
|
|
448
|
+
return agents.some((agent) => agent && typeof agent === "object" && agent.use === "dsh");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The rules with a DSH agent added (`on`) or every DSH agent removed. Nothing else changes: other agents, workflows
|
|
453
|
+
* and labels stay, a DSH agent that already exists keeps its settings, and a name already taken by another agent
|
|
454
|
+
* is never overwritten. A step that still names a removed agent is refused by the CLI's rules check on save.
|
|
455
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk, or null when there is none.
|
|
456
|
+
* @param {boolean} on - whether workflow steps may use DSH.
|
|
457
|
+
*/
|
|
458
|
+
function withDshAgent(rules, on) {
|
|
459
|
+
const agents = rules && rules.agents && typeof rules.agents === "object" && !Array.isArray(rules.agents) ? rules.agents : {};
|
|
460
|
+
if (on === hasDshAgent(rules)) return rules ?? { agents: on ? { dsh: { use: "dsh", permissionMode: "workspace-write" } } : {} };
|
|
461
|
+
if (!on) return { ...rules, agents: Object.fromEntries(Object.entries(agents).filter(([, agent]) => !(agent && typeof agent === "object" && agent.use === "dsh"))) };
|
|
462
|
+
let name = "dsh";
|
|
463
|
+
for (let n = 2; Object.prototype.hasOwnProperty.call(agents, name); n += 1) name = `dsh-${n}`;
|
|
464
|
+
return { ...(rules ?? {}), agents: { ...agents, [name]: { use: "dsh", permissionMode: "workspace-write" } } };
|
|
465
|
+
}
|
|
466
|
+
|
|
330
467
|
/** Page styles: DSH theme tokens with plain fallbacks, scoped to .c4d-page and .c4d and removed with the page. */
|
|
331
468
|
const CSS = `
|
|
332
469
|
.c4d-page{display:flex;flex-direction:column;flex:1;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary,#1f2328);background:var(--dsw-alias-bg-base,transparent)}
|
|
@@ -384,9 +521,9 @@ window.__ModuleLoader__.load({
|
|
|
384
521
|
|
|
385
522
|
/**
|
|
386
523
|
* The header: runner state, steps running now, and Start, Stop, Refresh and Edit rules.
|
|
387
|
-
* @param {{ t: Function, runner: object | null | undefined, loading: boolean, busy: string | undefined, actionError: string | undefined, setupOpen: boolean, onAction: (name: "start" | "stop") => void, onRefresh: () => void, onEditRules: () => void, updatedAt: number | undefined }} props
|
|
524
|
+
* @param {{ t: Function, runner: object | null | undefined, loading: boolean, busy: string | undefined, actionError: string | undefined, setupOpen: boolean, onAction: (name: "start" | "stop") => void, onRefresh: () => void, onEditRules: () => void, onEditRulesFile?: () => void, updatedAt: number | undefined }} props
|
|
388
525
|
*/
|
|
389
|
-
function RunnerHeader({ t, runner, loading, busy, actionError, setupOpen, onAction, onRefresh, onEditRules, updatedAt }) {
|
|
526
|
+
function RunnerHeader({ t, runner, loading, busy, actionError, setupOpen, onAction, onRefresh, onEditRules, onEditRulesFile, updatedAt }) {
|
|
390
527
|
const display = runner ? runner.display : loading ? "loading" : "unknown";
|
|
391
528
|
const running = display === "running";
|
|
392
529
|
const steps = running && typeof runner.busy === "number"
|
|
@@ -406,7 +543,8 @@ window.__ModuleLoader__.load({
|
|
|
406
543
|
h("button", { type: "button", className: "c4d-primary", disabled: busy !== undefined || running || display === "not_configured", "aria-busy": busy === "start", onClick: () => onAction("start"), title: t("runner.startHint") }, t("runner.start")),
|
|
407
544
|
h("button", { type: "button", disabled: busy !== undefined || display === "not_configured", "aria-busy": busy === "stop", onClick: () => onAction("stop") }, t("runner.stop")),
|
|
408
545
|
h("button", { type: "button", onClick: onRefresh }, t("runner.refresh")),
|
|
409
|
-
setupOpen ? null : h("button", { type: "button", onClick: onEditRules }, t("runner.editRules"))
|
|
546
|
+
setupOpen ? null : h("button", { type: "button", onClick: onEditRules }, t("runner.editRules")),
|
|
547
|
+
onEditRulesFile ? h("button", { type: "button", "aria-haspopup": "dialog", onClick: onEditRulesFile }, t("runner.editRulesFile")) : null),
|
|
410
548
|
h(ErrorLine, { message: actionError }),
|
|
411
549
|
updatedAt ? h("p", { className: "c4d-muted" }, t("runner.updated", { time: new Date(updatedAt).toLocaleTimeString() })) : null);
|
|
412
550
|
}
|
|
@@ -517,6 +655,7 @@ window.__ModuleLoader__.load({
|
|
|
517
655
|
function SetupForm({ t, data, options, optionsError, notice, canClose, onSave, onClose }) {
|
|
518
656
|
const [form, setForm] = useState(() => rulesToForm(data.rules));
|
|
519
657
|
const [repos, setRepos] = useState({});
|
|
658
|
+
const [dsh, setDsh] = useState(() => hasDshAgent(data.rules));
|
|
520
659
|
const [saving, setSaving] = useState(false);
|
|
521
660
|
const [error, setError] = useState(undefined);
|
|
522
661
|
const [errors, setErrors] = useState([]);
|
|
@@ -537,7 +676,7 @@ window.__ModuleLoader__.load({
|
|
|
537
676
|
setError(undefined);
|
|
538
677
|
setErrors([]);
|
|
539
678
|
try {
|
|
540
|
-
await onSave(built.rules);
|
|
679
|
+
await onSave(withDshAgent(built.rules, dsh));
|
|
541
680
|
} catch (failure) {
|
|
542
681
|
setError(t("setup.saveFailed", { message: failure instanceof Error ? failure.message : String(failure) }));
|
|
543
682
|
setErrors(failure instanceof ApiError ? failure.errors : []);
|
|
@@ -578,6 +717,10 @@ window.__ModuleLoader__.load({
|
|
|
578
717
|
onRemove: () => setForm((prev) => ({ ...prev, folders: prev.folders.filter((_, at) => at !== index) })),
|
|
579
718
|
}))),
|
|
580
719
|
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, folders: [...prev.folders, { path: "" }] })) }, t("setup.addFolder"))),
|
|
720
|
+
h("label", { className: "c4d-row" },
|
|
721
|
+
h("input", { type: "checkbox", "data-colco4dsh-dsh": true, checked: dsh, onChange: (event) => setDsh(event.target.checked) }),
|
|
722
|
+
h("span", null, t("setup.dsh"))),
|
|
723
|
+
h("p", { className: "c4d-muted" }, t("setup.dshHint")),
|
|
581
724
|
h(ErrorLine, { message: error }),
|
|
582
725
|
errors.length ? h("ul", { className: "c4d-error" }, errors.map((line, index) => h("li", { key: index }, line))) : null,
|
|
583
726
|
h("div", { className: "c4d-row" },
|
|
@@ -625,46 +768,299 @@ window.__ModuleLoader__.load({
|
|
|
625
768
|
return h(SetupForm, { key: revision, t, data: loaded.data, options: loaded.options, optionsError: loaded.optionsError, notice, canClose, onSave: save, onClose });
|
|
626
769
|
}
|
|
627
770
|
|
|
771
|
+
// ---- Edit rules.json dialog -------------------------------------------------------------------------------
|
|
772
|
+
// A native <dialog> over the raw ~/.colco/rules.json. It loads the text with its content hash, saves through the
|
|
773
|
+
// CLI's validation and backup, and sends the hash back: if the file changed in the meantime the save is refused and
|
|
774
|
+
// the dialog shows the file as it is now, so an outside edit is reviewed and never overwritten silently.
|
|
775
|
+
const { useRef } = React;
|
|
776
|
+
|
|
777
|
+
/** Dialog styles: capped by the viewport so the dialog fits narrow screens; the controls wrap. */
|
|
778
|
+
const DIALOG_CSS = `
|
|
779
|
+
.c4d-dialog{box-sizing:border-box;width:min(760px,calc(100vw - 24px));max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);margin:auto;padding:0;overflow:hidden;border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2328)}
|
|
780
|
+
.c4d-dialog::backdrop{background:rgba(0,0,0,.4)}
|
|
781
|
+
.c4d-dialog-body{display:flex;flex-direction:column;gap:10px;max-height:calc(100dvh - 26px);overflow:auto;padding:16px}
|
|
782
|
+
.c4d-dialog textarea.c4d-file{min-height:min(320px,40dvh);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:18px;white-space:pre;overflow:auto}
|
|
783
|
+
.c4d-dialog-actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
784
|
+
@media (max-width:767px){.c4d-dialog{width:calc(100vw - 16px);max-width:calc(100vw - 16px)}.c4d-dialog-body{padding:12px}.c4d-dialog-actions button{flex:1 1 140px}}
|
|
785
|
+
`;
|
|
786
|
+
|
|
787
|
+
/** Read the raw rules.json text, its hash and its check result. */
|
|
788
|
+
function loadRulesFile() {
|
|
789
|
+
return call("rules/file");
|
|
790
|
+
}
|
|
791
|
+
|
|
628
792
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
793
|
+
* Save the text. `baseHash` is the hash of the file as loaded (null when there was no file). A file that changed
|
|
794
|
+
* since then is not written: the result is `{ status: "conflict", current }` with the file as it is now. Invalid
|
|
795
|
+
* text and denied access throw an ApiError.
|
|
796
|
+
* @param {string} text
|
|
797
|
+
* @param {string | null} baseHash
|
|
632
798
|
*/
|
|
633
|
-
function
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
799
|
+
async function saveRulesFile(text, baseHash) {
|
|
800
|
+
try {
|
|
801
|
+
const reply = await call("rules/file", { method: "POST", body: { text, baseHash } });
|
|
802
|
+
return { status: "saved", path: reply.path, backup: reply.backup ?? null, hash: reply.hash ?? null };
|
|
803
|
+
} catch (failure) {
|
|
804
|
+
if (failure instanceof ApiError && failure.status === 409) return { status: "conflict", current: await loadRulesFile() };
|
|
805
|
+
throw failure;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The Edit rules.json dialog. `initial` seeds the state for tests; otherwise it loads the file when it opens.
|
|
811
|
+
* @param {{ t: Function, initial?: { file?: object, loadError?: string, draft?: string, error?: string, errors?: string[], conflict?: object, notice?: string }, onSaved: () => void, onClose: () => void }} props
|
|
812
|
+
*/
|
|
813
|
+
function RulesFileDialog({ t, initial = {}, onSaved, onClose }) {
|
|
814
|
+
const ref = useRef(null);
|
|
815
|
+
const [file, setFile] = useState(initial.file);
|
|
816
|
+
const [loadError, setLoadError] = useState(initial.loadError);
|
|
817
|
+
const [draft, setDraft] = useState(initial.draft ?? initial.file?.text ?? "");
|
|
818
|
+
const [saving, setSaving] = useState(false);
|
|
819
|
+
const [error, setError] = useState(initial.error);
|
|
820
|
+
const [errors, setErrors] = useState(initial.errors ?? []);
|
|
821
|
+
const [conflict, setConflict] = useState(initial.conflict);
|
|
822
|
+
const [notice, setNotice] = useState(initial.notice);
|
|
823
|
+
const message = (failure) => (failure instanceof Error ? failure.message : String(failure));
|
|
824
|
+
// Open as a modal dialog: the browser traps focus, closes on Escape and restores focus to the button.
|
|
825
|
+
useEffect(() => {
|
|
826
|
+
const element = ref.current;
|
|
827
|
+
if (element && typeof element.showModal === "function" && !element.open) element.showModal();
|
|
828
|
+
return () => { if (element && element.open) element.close(); };
|
|
829
|
+
}, []);
|
|
830
|
+
useEffect(() => {
|
|
831
|
+
if (file || loadError) return undefined;
|
|
832
|
+
let live = true;
|
|
833
|
+
loadRulesFile().then((loaded) => {
|
|
834
|
+
if (!live) return;
|
|
835
|
+
setFile(loaded);
|
|
836
|
+
setDraft(loaded.text);
|
|
837
|
+
}, (failure) => { if (live) setLoadError(t("file.loadFailed", { message: message(failure) })); });
|
|
838
|
+
return () => { live = false; };
|
|
839
|
+
}, [file, loadError, t]);
|
|
840
|
+
/** Save the draft against the hash `base`; a conflict opens the review, an error stays in the dialog. */
|
|
841
|
+
const submit = async (base) => {
|
|
842
|
+
setSaving(true);
|
|
843
|
+
setError(undefined);
|
|
844
|
+
setErrors([]);
|
|
845
|
+
setNotice(undefined);
|
|
846
|
+
try {
|
|
847
|
+
const result = await saveRulesFile(draft, base);
|
|
848
|
+
if (result.status === "conflict") {
|
|
849
|
+
setConflict(result.current);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
setConflict(undefined);
|
|
853
|
+
let reloaded;
|
|
854
|
+
try {
|
|
855
|
+
reloaded = await loadRulesFile();
|
|
856
|
+
} catch {
|
|
857
|
+
reloaded = undefined;
|
|
858
|
+
}
|
|
859
|
+
setFile(reloaded ?? { ...file, exists: true, text: draft, hash: result.hash, valid: true, errors: [] });
|
|
860
|
+
if (reloaded) setDraft(reloaded.text);
|
|
861
|
+
setNotice(result.backup ? t("file.savedBackup", { path: result.path, backup: result.backup }) : t("file.saved", { path: result.path ?? "rules.json" }));
|
|
862
|
+
onSaved();
|
|
863
|
+
} catch (failure) {
|
|
864
|
+
setError(t("file.saveFailed", { message: message(failure) }));
|
|
865
|
+
setErrors(failure instanceof ApiError ? failure.errors : []);
|
|
866
|
+
} finally {
|
|
867
|
+
setSaving(false);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
/** Drop the draft and take the file as it is now. */
|
|
871
|
+
const useDisk = () => {
|
|
872
|
+
setFile(conflict);
|
|
873
|
+
setDraft(conflict.text);
|
|
874
|
+
setConflict(undefined);
|
|
875
|
+
};
|
|
876
|
+
/** Save the draft over the file as it is now (its new hash), after the user has reviewed it. */
|
|
877
|
+
const overwrite = () => {
|
|
878
|
+
const current = conflict;
|
|
879
|
+
setFile(current);
|
|
880
|
+
setConflict(undefined);
|
|
881
|
+
void submit(current.hash ?? null);
|
|
882
|
+
};
|
|
883
|
+
const failed = Boolean(error) || errors.length > 0;
|
|
884
|
+
return h("dialog", { ref, className: "c4d-dialog", "aria-labelledby": "c4d-file-title", "data-colco4dsh-rules-file": true, onClose },
|
|
885
|
+
h("style", null, DIALOG_CSS),
|
|
886
|
+
h("div", { className: "c4d-dialog-body" },
|
|
887
|
+
h("h2", { id: "c4d-file-title" }, t("file.title")),
|
|
888
|
+
loadError ? h(ErrorLine, { message: loadError }) : null,
|
|
889
|
+
!file && !loadError ? h("p", { className: "c4d-muted", role: "status" }, t("file.loading")) : null,
|
|
890
|
+
file ? h(Fragment, null,
|
|
891
|
+
h("p", { className: "c4d-muted" }, t("file.path", { path: file.path ?? "rules.json" })),
|
|
892
|
+
!file.exists ? h("p", null, t("file.missing")) : null,
|
|
893
|
+
file.exists && !file.valid && file.errors.length ? h(Fragment, null, h("p", null, t("file.diskInvalid")), h("ul", { className: "c4d-error" }, file.errors.map((line, index) => h("li", { key: index }, line)))) : null,
|
|
894
|
+
notice ? h("p", { role: "status", className: "c4d-ok" }, notice) : null,
|
|
895
|
+
conflict ? h("div", { className: "c4d-section" },
|
|
896
|
+
h("p", { role: "alert", className: "c4d-error" }, t("file.conflict")),
|
|
897
|
+
h("label", { htmlFor: "c4d-file-disk", className: "c4d-muted" }, t("file.diskLabel")),
|
|
898
|
+
h("textarea", { id: "c4d-file-disk", className: "c4d-file", rows: 10, readOnly: true, spellCheck: false, value: conflict.text }),
|
|
899
|
+
conflict.exists && !conflict.valid && conflict.errors.length ? h("ul", { className: "c4d-error" }, conflict.errors.map((line, index) => h("li", { key: index }, line))) : null) : null,
|
|
900
|
+
h("label", { htmlFor: "c4d-file-text", className: "c4d-muted" }, t("file.label")),
|
|
901
|
+
h("textarea", {
|
|
902
|
+
id: "c4d-file-text", className: "c4d-file", rows: 14, spellCheck: false, value: draft, readOnly: Boolean(conflict) || saving,
|
|
903
|
+
"aria-invalid": failed ? "true" : undefined, "aria-describedby": failed ? "c4d-file-errors" : undefined,
|
|
904
|
+
onChange: (event) => setDraft(event.target.value),
|
|
905
|
+
}),
|
|
906
|
+
failed ? h("div", { id: "c4d-file-errors", role: "alert", className: "c4d-error" },
|
|
907
|
+
error ? h("p", null, error) : null,
|
|
908
|
+
errors.length ? h("ul", null, errors.map((line, index) => h("li", { key: index }, line))) : null) : null,
|
|
909
|
+
conflict ? null : h("p", { className: "c4d-muted" }, t("file.hint"))) : null,
|
|
910
|
+
h("div", { className: "c4d-dialog-actions" },
|
|
911
|
+
file && !conflict ? h("button", { type: "button", className: "c4d-primary", disabled: saving, "aria-busy": saving, onClick: () => void submit(file.hash ?? null) }, saving ? t("file.saving") : t("file.save")) : null,
|
|
912
|
+
conflict ? h("button", { type: "button", onClick: useDisk }, t("file.useDisk")) : null,
|
|
913
|
+
conflict ? h("button", { type: "button", className: "c4d-primary", disabled: saving, onClick: overwrite }, t("file.overwrite")) : null,
|
|
914
|
+
h("button", { type: "button", onClick: onClose }, t("file.close")))));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---- Shared status store (open-decision dot) -------------------------------------------------------------
|
|
918
|
+
// One store holds the last `GET status` read and one poller refreshes it, for the sidebar icon and the Colco page
|
|
919
|
+
// alike. It polls while at least one of them is mounted and DSH is visible, so the icon's dot follows the
|
|
920
|
+
// runner's decisions even when the page is closed.
|
|
921
|
+
const { useSyncExternalStore } = React;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Count the open decisions of a status reply. A reply whose decisions section failed counts as none.
|
|
925
|
+
* @param {object | undefined} status
|
|
926
|
+
*/
|
|
927
|
+
function countOpen(status) {
|
|
928
|
+
if (!status || (status.errors && status.errors.decisions) || !Array.isArray(status.decisions)) return 0;
|
|
929
|
+
return status.decisions.filter((decision) => decision.state === undefined || decision.state === "open").length;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Build one status store.
|
|
934
|
+
* `snapshot.openCount` is the number behind the dot; it is 0 after a failed read, so a failure never shows a dot.
|
|
935
|
+
* @param {{ fetchStatus?: (signal: AbortSignal) => Promise<object>, initialStatus?: object, doc?: { visibilityState?: string, addEventListener: Function, removeEventListener: Function } | undefined, timers?: { setInterval: Function, clearInterval: Function }, pollMs?: number }} [options]
|
|
936
|
+
*/
|
|
937
|
+
function createStatusStore({ fetchStatus = (signal) => call("status", { signal }), initialStatus, doc = globalThis.document, timers = globalThis, pollMs = POLL_MS } = {}) {
|
|
938
|
+
const listeners = new Set();
|
|
939
|
+
/** Ids answered here; a read that started earlier may still list them, so they are filtered out of every reply. */
|
|
940
|
+
const answeredIds = new Set();
|
|
941
|
+
let inFlight;
|
|
942
|
+
let timer;
|
|
943
|
+
let stopWatching;
|
|
944
|
+
|
|
945
|
+
/** The snapshot for one status, its last error and update time. */
|
|
946
|
+
const derive = (status, error, updatedAt) => ({ status, error, updatedAt, openCount: error ? 0 : countOpen(status) });
|
|
947
|
+
/** The status without the decisions answered here. */
|
|
948
|
+
const withoutAnswered = (status) => (status && Array.isArray(status.decisions) && answeredIds.size
|
|
949
|
+
? { ...status, decisions: status.decisions.filter((decision) => !answeredIds.has(decision.id)) }
|
|
950
|
+
: status);
|
|
951
|
+
let snapshot = derive(initialStatus, undefined, undefined);
|
|
952
|
+
/** Replace the snapshot and tell every listener. */
|
|
953
|
+
const publish = (next) => {
|
|
954
|
+
snapshot = next;
|
|
955
|
+
for (const listener of [...listeners]) listener();
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
/** Read the status once; a read already in flight is shared. A failure keeps the old status and sets the error. */
|
|
959
|
+
const refresh = () => {
|
|
960
|
+
if (inFlight) return inFlight.promise;
|
|
643
961
|
const controller = new AbortController();
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
962
|
+
let request;
|
|
963
|
+
try {
|
|
964
|
+
request = Promise.resolve(fetchStatus(controller.signal));
|
|
965
|
+
} catch (failure) {
|
|
966
|
+
request = Promise.reject(failure);
|
|
967
|
+
}
|
|
968
|
+
const promise = request.then((reply) => {
|
|
969
|
+
if (!controller.signal.aborted) publish(derive(withoutAnswered(reply), undefined, Date.now()));
|
|
648
970
|
}, (failure) => {
|
|
649
|
-
if (!controller.signal.aborted)
|
|
971
|
+
if (!controller.signal.aborted) publish(derive(snapshot.status, failure instanceof Error ? failure.message : String(failure), snapshot.updatedAt));
|
|
650
972
|
}).finally(() => {
|
|
651
|
-
if (inFlight.
|
|
973
|
+
if (inFlight && inFlight.controller === controller) inFlight = undefined;
|
|
652
974
|
});
|
|
653
|
-
inFlight
|
|
975
|
+
inFlight = { controller, promise };
|
|
654
976
|
return promise;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const tick = () => { if (
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
/** Start polling: one read now, then one per interval while the document is not hidden, and one on becoming visible. */
|
|
980
|
+
const start = () => {
|
|
981
|
+
const tick = () => { if (!doc || doc.visibilityState !== "hidden") void refresh(); };
|
|
660
982
|
tick();
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
983
|
+
timer = timers.setInterval(tick, pollMs);
|
|
984
|
+
if (doc) doc.addEventListener("visibilitychange", tick);
|
|
985
|
+
stopWatching = () => { if (doc) doc.removeEventListener("visibilitychange", tick); };
|
|
986
|
+
};
|
|
987
|
+
/** Stop polling and abort a read in flight. */
|
|
988
|
+
const stop = () => {
|
|
989
|
+
timers.clearInterval(timer);
|
|
990
|
+
stopWatching?.();
|
|
991
|
+
if (inFlight) {
|
|
992
|
+
inFlight.controller.abort();
|
|
993
|
+
inFlight = undefined;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
/** @returns the current snapshot; the same object until something changes. */
|
|
999
|
+
getSnapshot: () => snapshot,
|
|
1000
|
+
/** Listen for changes; the first listener starts the poller and the last one stops it. */
|
|
1001
|
+
subscribe(listener) {
|
|
1002
|
+
listeners.add(listener);
|
|
1003
|
+
if (listeners.size === 1) start();
|
|
1004
|
+
return () => {
|
|
1005
|
+
listeners.delete(listener);
|
|
1006
|
+
if (listeners.size === 0) stop();
|
|
1007
|
+
};
|
|
1008
|
+
},
|
|
1009
|
+
refresh,
|
|
1010
|
+
/** Take an answered decision out of the list at once, so its dot clears before the next read. */
|
|
1011
|
+
answered(id) {
|
|
1012
|
+
answeredIds.add(id);
|
|
1013
|
+
if (snapshot.status) publish(derive(withoutAnswered(snapshot.status), snapshot.error, snapshot.updatedAt));
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** The store the sidebar icon and the page share; created on first use, idle until something subscribes. */
|
|
1019
|
+
let sharedStore;
|
|
1020
|
+
/** The shared store. */
|
|
1021
|
+
function sharedStatusStore() {
|
|
1022
|
+
sharedStore ??= createStatusStore();
|
|
1023
|
+
return sharedStore;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Follow a store from a component.
|
|
1028
|
+
* @param {ReturnType<typeof createStatusStore>} store
|
|
1029
|
+
*/
|
|
1030
|
+
function useStatusSnapshot(store) {
|
|
1031
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Send an answer, clear the decision's dot at once, then refresh. A failed answer throws and leaves the dot.
|
|
1036
|
+
* @param {ReturnType<typeof createStatusStore>} store
|
|
1037
|
+
* @param {number} id - decision id.
|
|
1038
|
+
* @param {number} index - chosen option.
|
|
1039
|
+
* @param {string | undefined} text - optional note.
|
|
1040
|
+
*/
|
|
1041
|
+
async function submitAnswer(store, id, index, text) {
|
|
1042
|
+
await call("answer", { method: "POST", body: text === undefined ? { id, index } : { id, index, text } });
|
|
1043
|
+
store.answered(id);
|
|
1044
|
+
await store.refresh();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* The whole tab: runner header, active runs, open decisions and Setup. It reads the shared status store,
|
|
1049
|
+
* which refreshes every POLL_MS while DSH is visible. `initialStatus` seeds a private store and `initialSetup` seeds
|
|
1050
|
+
* Setup (server rendering in tests performs no request); `store` passes a store in.
|
|
1051
|
+
* @param {{ t: Function, initialStatus?: object, initialSetup?: { data: object, options: object }, store?: object }} props
|
|
1052
|
+
*/
|
|
1053
|
+
function ColcoPanel({ t, initialStatus, initialSetup, store: given }) {
|
|
1054
|
+
const [store] = useState(() => given ?? (initialStatus ? createStatusStore({ initialStatus }) : sharedStatusStore()));
|
|
1055
|
+
const { status, error: loadError, updatedAt } = useStatusSnapshot(store);
|
|
1056
|
+
const [busy, setBusy] = useState(undefined);
|
|
1057
|
+
const [actionError, setActionError] = useState(undefined);
|
|
1058
|
+
const [setupRequested, setSetupRequested] = useState(false);
|
|
1059
|
+
const [fileOpen, setFileOpen] = useState(false);
|
|
1060
|
+
const [rulesVersion, setRulesVersion] = useState(0);
|
|
1061
|
+
const refresh = store.refresh;
|
|
1062
|
+
// Subscribing (above) starts the shared poller; this read makes a page opened later show fresh data at once.
|
|
1063
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
668
1064
|
/** Run Start or Stop, then refresh; a failure shows under the buttons. */
|
|
669
1065
|
const act = async (name) => {
|
|
670
1066
|
setBusy(name);
|
|
@@ -679,10 +1075,7 @@ window.__ModuleLoader__.load({
|
|
|
679
1075
|
await refresh();
|
|
680
1076
|
};
|
|
681
1077
|
/** Answer a decision, then refresh; the card shows a failure itself. */
|
|
682
|
-
const answer =
|
|
683
|
-
await call("answer", { method: "POST", body: text === undefined ? { id, index } : { id, index, text } });
|
|
684
|
-
await refresh();
|
|
685
|
-
};
|
|
1078
|
+
const answer = (id, index, text) => submitAnswer(store, id, index, text);
|
|
686
1079
|
const runner = status?.runner ?? null;
|
|
687
1080
|
const needsSetup = runner !== null && (runner.display === "not_configured" || runner.display === "rules_error");
|
|
688
1081
|
const setupOpen = needsSetup || setupRequested;
|
|
@@ -690,29 +1083,201 @@ window.__ModuleLoader__.load({
|
|
|
690
1083
|
const now = Date.now();
|
|
691
1084
|
return h("div", { className: "c4d", "data-colco4dsh-panel": runner?.display ?? "loading" },
|
|
692
1085
|
h("style", null, CSS),
|
|
693
|
-
h(RunnerHeader, { t, runner, loading: status === undefined && !loadError, busy, actionError, setupOpen, onAction: act, onRefresh: () => void refresh(), onEditRules: () => setSetupRequested(true), updatedAt }),
|
|
1086
|
+
h(RunnerHeader, { t, runner, loading: status === undefined && !loadError, busy, actionError, setupOpen, onAction: act, onRefresh: () => void refresh(), onEditRules: () => setSetupRequested(true), onEditRulesFile: () => setFileOpen(true), updatedAt }),
|
|
694
1087
|
h(ErrorLine, { message: loadError }),
|
|
695
1088
|
["runner", "runs", "decisions"].map((section) => (errors[section] ? h(ErrorLine, { key: section, message: t("error.section", { section: t(`section.${section}`), message: errors[section] }) }) : null)),
|
|
696
|
-
setupOpen ? h(SetupPanel, { t, initial: initialSetup, canClose: !needsSetup, onSaved: () => void refresh(), onClose: () => setSetupRequested(false) }) : null,
|
|
1089
|
+
setupOpen ? h(SetupPanel, { key: rulesVersion, t, initial: initialSetup, canClose: !needsSetup, onSaved: () => void refresh(), onClose: () => setSetupRequested(false) }) : null,
|
|
1090
|
+
fileOpen ? h(RulesFileDialog, { t, onSaved: () => { setRulesVersion((value) => value + 1); void refresh(); }, onClose: () => setFileOpen(false) }) : null,
|
|
697
1091
|
h("section", { className: "c4d-section", "aria-label": t("runs.title") },
|
|
698
1092
|
h("h2", null, t("runs.title")),
|
|
699
1093
|
status && status.runs.length ? h("ul", null, status.runs.map((run) => h(RunCard, { key: run.id, t, run, now }))) : h("p", { className: "c4d-muted" }, status ? t("runs.empty") : t("state.loading"))),
|
|
700
1094
|
h("section", { className: "c4d-section", "aria-label": t("decisions.title") },
|
|
701
1095
|
h("h2", null, t("decisions.title")),
|
|
702
|
-
status && status.decisions.length ? h("ul", null, status.decisions.map((decision) => h(DecisionCard, { key: decision.id, t, decision, onAnswer: answer }))) : h("p", { className: "c4d-muted" }, status ? t("decisions.empty") : t("state.loading")))
|
|
1096
|
+
status && status.decisions.length ? h("ul", null, status.decisions.map((decision) => h(DecisionCard, { key: decision.id, t, decision, onAnswer: answer }))) : h("p", { className: "c4d-muted" }, status ? t("decisions.empty") : t("state.loading"))),
|
|
1097
|
+
h(MyTasksBoard, { t }));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---- My tasks board -------------------------------------------------------------------------------------
|
|
1101
|
+
// The tasks assigned to the signed-in user, read only, grouped by status and paged by the server (GET tasks).
|
|
1102
|
+
// Task changes continue in Colco: every title links there and nothing on the board writes.
|
|
1103
|
+
|
|
1104
|
+
/** How often the board reloads while the page is open. Slower than the status poll, because tasks change in Colco. */
|
|
1105
|
+
const BOARD_POLL_MS = 60000;
|
|
1106
|
+
/** The statuses of a task in the order the server lists them: work in flight first, done last. */
|
|
1107
|
+
const BOARD_STATUSES = ["in_progress", "blocked", "review", "todo", "backlog", "done"];
|
|
1108
|
+
|
|
1109
|
+
/** Board styles: wrapping and clamping so long titles and narrow windows cannot push the page sideways. No angle brackets or quotes, because React escapes them inside style text. */
|
|
1110
|
+
const BOARD_CSS = `
|
|
1111
|
+
.c4d-board{min-width:0}
|
|
1112
|
+
.c4d-board-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;min-width:0}
|
|
1113
|
+
.c4d-board-bar select{flex:1 1 180px;max-width:100%;min-width:0;text-overflow:ellipsis}
|
|
1114
|
+
.c4d-board-group{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
1115
|
+
.c4d-board-head{flex-wrap:nowrap}
|
|
1116
|
+
.c4d-task{min-width:0;gap:4px}
|
|
1117
|
+
.c4d-task-title{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
1118
|
+
.c4d-task-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;min-width:0}
|
|
1119
|
+
.c4d-task-meta .c4d-tag{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1120
|
+
.c4d-board-pager{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
|
1121
|
+
@media (max-width:480px){.c4d-board-bar select{flex-basis:100%}.c4d-board-pager{justify-content:center}}
|
|
1122
|
+
`;
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The route path for a board query: only the filters that are set and a page after the first.
|
|
1126
|
+
* @param {{ page: number, project?: number, status?: string }} query
|
|
1127
|
+
*/
|
|
1128
|
+
function boardPath(query) {
|
|
1129
|
+
const params = [];
|
|
1130
|
+
if (query.project !== undefined) params.push(`project=${query.project}`);
|
|
1131
|
+
if (query.status !== undefined) params.push(`status=${encodeURIComponent(query.status)}`);
|
|
1132
|
+
if (query.page > 1) params.push(`page=${query.page}`);
|
|
1133
|
+
return params.length ? `tasks?${params.join("&")}` : "tasks";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The query after one change from the board. A new project or status goes back to page 1, because the old page
|
|
1138
|
+
* number means nothing for another set of tasks; an empty value clears that filter; a bad page becomes 1.
|
|
1139
|
+
* @param {{ page: number, project?: number, status?: string }} query - the current query.
|
|
1140
|
+
* @param {{ project?: string, status?: string, page?: number | string }} change - one change from a select or the pager.
|
|
1141
|
+
*/
|
|
1142
|
+
function changeBoardQuery(query, change) {
|
|
1143
|
+
const next = { ...query };
|
|
1144
|
+
if ("project" in change) {
|
|
1145
|
+
const id = Number(change.project);
|
|
1146
|
+
next.project = change.project !== "" && Number.isSafeInteger(id) && id >= 1 ? id : undefined;
|
|
1147
|
+
next.page = 1;
|
|
1148
|
+
}
|
|
1149
|
+
if ("status" in change) {
|
|
1150
|
+
next.status = BOARD_STATUSES.includes(change.status) ? change.status : undefined;
|
|
1151
|
+
next.page = 1;
|
|
1152
|
+
}
|
|
1153
|
+
if ("page" in change) {
|
|
1154
|
+
const page = Number(change.page);
|
|
1155
|
+
next.page = Number.isSafeInteger(page) && page >= 1 ? page : 1;
|
|
1156
|
+
}
|
|
1157
|
+
return next;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* One task: its title (a link into Colco when the link is http or https), then project, priority and due date.
|
|
1162
|
+
* The title is clamped to two lines by CSS and kept whole in the title attribute.
|
|
1163
|
+
* @param {{ t: Function, task: object }} props
|
|
1164
|
+
*/
|
|
1165
|
+
function TaskRow({ t, task }) {
|
|
1166
|
+
const title = task.title || t("tasks.untitled", { id: task.id });
|
|
1167
|
+
const linked = typeof task.link === "string" && /^https?:\/\//i.test(task.link);
|
|
1168
|
+
return h("li", { className: "c4d-card c4d-task", "data-colco4dsh-task": task.id, "data-status": task.status },
|
|
1169
|
+
h("p", { className: "c4d-task-title", title }, linked ? h("a", { href: task.link, target: "_blank", rel: "noopener noreferrer" }, title) : title),
|
|
1170
|
+
h("div", { className: "c4d-task-meta" },
|
|
1171
|
+
task.projectName ? h("span", { className: "c4d-tag", title: task.projectName }, task.projectName) : null,
|
|
1172
|
+
task.priority ? h("span", { className: "c4d-tag", "data-priority": task.priority, "data-tone": task.priority === "high" ? "warn" : undefined }, t("tasks.priorityTag", { priority: t(`tasks.priority.${task.priority}`) })) : null,
|
|
1173
|
+
h("span", { className: "c4d-muted" }, task.dueDate ? t("tasks.due", { date: task.dueDate }) : t("tasks.noDue"))));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The tasks of one status on this page. The heading count is the status's total across pages; a group that
|
|
1178
|
+
* continues on another page says how many of its tasks are shown here.
|
|
1179
|
+
* @param {{ t: Function, status: string, tasks: object[], total: number }} props
|
|
1180
|
+
*/
|
|
1181
|
+
function TaskGroup({ t, status, tasks, total }) {
|
|
1182
|
+
const count = Math.max(total, tasks.length);
|
|
1183
|
+
return h("section", { className: "c4d-board-group", "aria-label": t(`tasks.status.${status}`), "data-colco4dsh-group": status },
|
|
1184
|
+
h("div", { className: "c4d-row c4d-board-head" },
|
|
1185
|
+
h("h3", null, t(`tasks.status.${status}`)),
|
|
1186
|
+
h("span", { className: "c4d-tag" }, tasks.length < count ? t("tasks.groupCount", { shown: tasks.length, total: count }) : String(count))),
|
|
1187
|
+
h("ul", null, tasks.map((task) => h(TaskRow, { key: task.id, t, task }))));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The board for one reply of GET tasks: Refresh, the project and status filters, the status groups and the pager.
|
|
1192
|
+
* `reply` is undefined until the first load; a failed refresh keeps the last reply under the alert.
|
|
1193
|
+
* @param {{ t: Function, query: { page: number, project?: number, status?: string }, reply: object | undefined, loading: boolean, error: string | undefined, onQuery: (change: object) => void, onRefresh: () => void }} props
|
|
1194
|
+
*/
|
|
1195
|
+
function TaskBoard({ t, query, reply, loading, error, onQuery, onRefresh }) {
|
|
1196
|
+
const filtered = query.project !== undefined || query.status !== undefined;
|
|
1197
|
+
const byStatus = new Map();
|
|
1198
|
+
if (reply) for (const task of reply.tasks) byStatus.set(task.status, [...byStatus.get(task.status) ?? [], task]);
|
|
1199
|
+
const projectKnown = !reply || query.project === undefined || reply.projects.some((project) => project.id === query.project);
|
|
1200
|
+
const showFilters = reply && (reply.total > 0 || filtered);
|
|
1201
|
+
const first = reply ? (reply.page - 1) * reply.pageSize + 1 : 1;
|
|
1202
|
+
return h("section", { className: "c4d-section c4d-board", "aria-label": t("tasks.title"), "aria-busy": loading, "data-colco4dsh-board": reply ? "ready" : error ? "error" : "loading" },
|
|
1203
|
+
h("style", null, BOARD_CSS),
|
|
1204
|
+
h("div", { className: "c4d-row" },
|
|
1205
|
+
h("h2", null, t("tasks.title")),
|
|
1206
|
+
h("button", { type: "button", "aria-label": t("tasks.refreshLabel"), disabled: loading, onClick: onRefresh }, t("tasks.refresh"))),
|
|
1207
|
+
error ? h(ErrorLine, { message: t("tasks.error", { message: error }) }) : null,
|
|
1208
|
+
!reply && !error ? h("p", { className: "c4d-muted" }, t("tasks.loading")) : null,
|
|
1209
|
+
showFilters ? h("div", { className: "c4d-board-bar" },
|
|
1210
|
+
h("select", { "aria-label": t("tasks.filterProject"), value: query.project === undefined ? "" : String(query.project), onChange: (event) => onQuery({ project: event.target.value }) },
|
|
1211
|
+
h("option", { value: "" }, t("tasks.allProjects")),
|
|
1212
|
+
reply.projects.map((project) => h("option", { key: project.id, value: String(project.id) }, t("tasks.optionCount", { name: project.name, count: project.count }))),
|
|
1213
|
+
projectKnown ? null : h("option", { value: String(query.project) }, t("tasks.projectGone", { id: query.project }))),
|
|
1214
|
+
h("select", { "aria-label": t("tasks.filterStatus"), value: query.status ?? "", onChange: (event) => onQuery({ status: event.target.value }) },
|
|
1215
|
+
h("option", { value: "" }, t("tasks.allStatuses")),
|
|
1216
|
+
reply.counts.map((entry) => h("option", { key: entry.status, value: entry.status }, t("tasks.optionCount", { name: t(`tasks.status.${entry.status}`), count: entry.count }))))) : null,
|
|
1217
|
+
reply && reply.total === 0 ? h("p", { className: "c4d-muted" }, filtered ? t("tasks.emptyFiltered") : t("tasks.empty")) : null,
|
|
1218
|
+
reply && reply.total > 0 && reply.tasks.length === 0 ? h("p", { className: "c4d-muted" }, t("tasks.pageEmpty")) : null,
|
|
1219
|
+
reply ? reply.counts.map((entry) => (byStatus.has(entry.status) ? h(TaskGroup, { key: entry.status, t, status: entry.status, tasks: byStatus.get(entry.status), total: entry.count }) : null)) : null,
|
|
1220
|
+
reply && (reply.pages > 1 || reply.page > 1) ? h("nav", { className: "c4d-board-pager", "aria-label": t("tasks.pager") },
|
|
1221
|
+
h("button", { type: "button", disabled: reply.page <= 1, onClick: () => onQuery({ page: Math.min(reply.page - 1, reply.pages) }) }, t("tasks.previous")),
|
|
1222
|
+
h("span", null, t("tasks.pageOf", { page: reply.page, pages: reply.pages })),
|
|
1223
|
+
reply.tasks.length ? h("span", { className: "c4d-muted" }, t("tasks.range", { from: first, to: first + reply.tasks.length - 1, total: reply.total })) : null,
|
|
1224
|
+
h("button", { type: "button", disabled: reply.page >= reply.pages, onClick: () => onQuery({ page: reply.page + 1 }) }, t("tasks.next"))) : null);
|
|
703
1225
|
}
|
|
704
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* The board with its data: loads GET tasks for the current filters and page, reloads every BOARD_POLL_MS while
|
|
1229
|
+
* the page is visible, and steps back to the last page when the current one has emptied (tasks finished meanwhile).
|
|
1230
|
+
* `initial` seeds the first render (server rendering in tests performs no request).
|
|
1231
|
+
* @param {{ t: Function, initial?: { reply: object } }} props
|
|
1232
|
+
*/
|
|
1233
|
+
function MyTasksBoard({ t, initial }) {
|
|
1234
|
+
const [query, setQuery] = useState({ page: 1 });
|
|
1235
|
+
const [reply, setReply] = useState(initial ? initial.reply : undefined);
|
|
1236
|
+
const [error, setError] = useState(undefined);
|
|
1237
|
+
const [loading, setLoading] = useState(!initial);
|
|
1238
|
+
const [revision, setRevision] = useState(0);
|
|
1239
|
+
useEffect(() => {
|
|
1240
|
+
const controller = new AbortController();
|
|
1241
|
+
setLoading(true);
|
|
1242
|
+
call(boardPath(query), { signal: controller.signal }).then((next) => {
|
|
1243
|
+
if (controller.signal.aborted) return;
|
|
1244
|
+
setReply(next);
|
|
1245
|
+
setError(undefined);
|
|
1246
|
+
setLoading(false);
|
|
1247
|
+
if (next.tasks.length === 0 && next.total > 0 && query.page > next.pages) setQuery((current) => changeBoardQuery(current, { page: next.pages }));
|
|
1248
|
+
}, (failure) => {
|
|
1249
|
+
if (controller.signal.aborted) return;
|
|
1250
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
1251
|
+
setLoading(false);
|
|
1252
|
+
});
|
|
1253
|
+
return () => controller.abort();
|
|
1254
|
+
}, [query, revision]);
|
|
1255
|
+
useEffect(() => {
|
|
1256
|
+
const timer = setInterval(() => { if (document.visibilityState !== "hidden") setRevision((value) => value + 1); }, BOARD_POLL_MS);
|
|
1257
|
+
return () => clearInterval(timer);
|
|
1258
|
+
}, []);
|
|
1259
|
+
return h(TaskBoard, { t, query, reply, loading, error, onQuery: (change) => setQuery((current) => changeBoardQuery(current, change)), onRefresh: () => setRevision((value) => value + 1) });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** What the board adds to the factory's exports, for the tests. */
|
|
1263
|
+
const BOARD_EXPORTS = { TaskBoard, MyTasksBoard, changeBoardQuery, boardPath };
|
|
1264
|
+
|
|
705
1265
|
/** English copy for hosts without DSH's locale service (tests, other hosts). */
|
|
706
1266
|
const fallbackT = translator(en);
|
|
707
1267
|
|
|
708
1268
|
/**
|
|
709
|
-
* Glyph of the left-sidebar row.
|
|
710
|
-
*
|
|
1269
|
+
* Glyph of the left-sidebar row. The sidebar owns the button, its label and the selected state. The glyph is
|
|
1270
|
+
* decorative, except that a red dot inside its 24 by 24 box, with an accessible count, marks open decisions.
|
|
1271
|
+
* It follows the shared status store, so it updates while the Colco page is closed.
|
|
1272
|
+
* @param {{ size: number, active?: boolean, t?: Function, store?: object }} props - the edge the sidebar asks for; `t` and `store` are for tests and hosts.
|
|
711
1273
|
*/
|
|
712
|
-
function ColcoPanelIcon({ size }) {
|
|
713
|
-
|
|
1274
|
+
function ColcoPanelIcon({ size, t, store }) {
|
|
1275
|
+
const { openCount } = useStatusSnapshot(store ?? sharedStatusStore());
|
|
1276
|
+
const label = openCount > 0 ? (t ?? fallbackT)(openCount === 1 ? "panel.openDecision" : "panel.openDecisions", { count: openCount }) : undefined;
|
|
1277
|
+
return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", ...label ? { role: "img", "aria-label": label } : { "aria-hidden": "true" }, focusable: "false" },
|
|
714
1278
|
h("path", { d: "M16.6 7.4a6.5 6.5 0 1 0 0 9.2" }),
|
|
715
|
-
h("circle", { cx: 18.4, cy: 12, r: 1.7, fill: "currentColor", stroke: "none" })
|
|
1279
|
+
h("circle", { cx: 18.4, cy: 12, r: 1.7, fill: "currentColor", stroke: "none" }),
|
|
1280
|
+
openCount > 0 ? h("circle", { cx: 19.5, cy: 4.5, r: 3, stroke: "none", "data-colco4dsh-dot": "true", style: { fill: "var(--dsw-alias-state-error-primary,#cf222e)" } }) : null);
|
|
716
1281
|
}
|
|
717
1282
|
|
|
718
1283
|
/**
|
|
@@ -750,6 +1315,6 @@ window.__ModuleLoader__.load({
|
|
|
750
1315
|
)), "colco4dsh: sidebar row");
|
|
751
1316
|
}
|
|
752
1317
|
|
|
753
|
-
return { inject: ["slots", "locale"], apply, ColcoPage, ColcoPanel, ColcoPanelIcon, SetupForm, translator, call, ApiError, dictionaries: { en, zh }, shellQuote, fixCommand, rulesToForm, formToRules, formatDuration, API, ACTION_HEADER, PANEL_ID, NS };
|
|
1318
|
+
return { inject: ["slots", "locale"], apply, createStatusStore, submitAnswer, loadRulesFile, saveRulesFile, RulesFileDialog, ColcoPage, ColcoPanel, ColcoPanelIcon, SetupForm, translator, call, ApiError, dictionaries: { en, zh }, shellQuote, fixCommand, rulesToForm, formToRules, hasDshAgent, withDshAgent, formatDuration, API, ACTION_HEADER, PANEL_ID, NS, ...BOARD_EXPORTS };
|
|
754
1319
|
},
|
|
755
1320
|
});
|
package/lib/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ const MAX_BUFFER = 4 * 1024 * 1024;
|
|
|
15
15
|
* The first @evanbobo/colco release with the runner commands and their `--json` output that this plugin calls.
|
|
16
16
|
* The plugin and the CLI have separate version lines, so this is the only place that ties them together.
|
|
17
17
|
*/
|
|
18
|
-
export const MIN_COLCO_VERSION = "0.
|
|
18
|
+
export const MIN_COLCO_VERSION = "0.23.0";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* How a colco CLI from before the runner commands answers them: it rejects the command or the --json option.
|
package/lib/routes.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { ColcoCliError, runColco } from "./cli.mjs";
|
|
|
6
6
|
import { decisionCards, runCards, runnerHeader, stringList } from "./dashboard.mjs";
|
|
7
7
|
import { LIMITS, compactDecision, field } from "./format.mjs";
|
|
8
8
|
import { answerArgs } from "./plugin.mjs";
|
|
9
|
+
import { boardArgs, boardReply, parseBoardQuery } from "./tasks.mjs";
|
|
9
10
|
|
|
10
11
|
/** Path prefix of every route. DSH serves plugin bundles under `/plugins`; this longer prefix wins over it. */
|
|
11
12
|
export const ROUTE_PREFIX = "/plugins/colco4dsh/api";
|
|
@@ -272,6 +273,61 @@ async function saveRules(colco, rules) {
|
|
|
272
273
|
}
|
|
273
274
|
}
|
|
274
275
|
|
|
276
|
+
/** Largest rules.json text, in bytes, that the "Edit rules.json" dialog reads or saves; it keeps the JSON body under MAX_BODY_BYTES. */
|
|
277
|
+
export const MAX_RULES_TEXT_BYTES = 48 * 1024;
|
|
278
|
+
|
|
279
|
+
/** A content hash as `colco runner rules get` prints it: 64 lower-case hex digits. */
|
|
280
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The reply of `GET rules/file`: the raw rules.json text and its content hash from `colco runner rules get`.
|
|
284
|
+
* A hash that is not 64 hex digits is dropped (null), and a file over MAX_RULES_TEXT_BYTES is refused instead of cut.
|
|
285
|
+
* @param {Record<string, unknown>} body - `colco runner rules get --json`.
|
|
286
|
+
*/
|
|
287
|
+
function rulesFileReply(body) {
|
|
288
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
289
|
+
if (Buffer.byteLength(text) > MAX_RULES_TEXT_BYTES) throw new RouteError(413, `rules.json is larger than ${MAX_RULES_TEXT_BYTES} bytes; edit it in an editor`);
|
|
290
|
+
return {
|
|
291
|
+
path: typeof body.path === "string" ? body.path : undefined,
|
|
292
|
+
exists: body.exists === true,
|
|
293
|
+
text,
|
|
294
|
+
hash: typeof body.hash === "string" && HASH_PATTERN.test(body.hash) ? body.hash : null,
|
|
295
|
+
valid: body.valid === true,
|
|
296
|
+
errors: stringList(body.errors, LIMITS.warning),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Save the dialog's rules.json text through `colco runner rules set --raw --expect-hash`. The text travels on stdin
|
|
302
|
+
* only. `baseHash` (the hash the dialog loaded, or null for "there was no file") is required, so a save cannot skip
|
|
303
|
+
* the conflict check. Invalid text becomes 422 with colco's errors, and a file changed elsewhere becomes 409.
|
|
304
|
+
* @param {(args: string[], extra?: { stdin?: string }) => Promise<Record<string, unknown>>} colco
|
|
305
|
+
* @param {unknown} input - the request body `{ text, baseHash }`.
|
|
306
|
+
*/
|
|
307
|
+
async function saveRulesFile(colco, input) {
|
|
308
|
+
if (!isObject(input)) throw new RouteError(400, "send { text, baseHash } as a JSON object");
|
|
309
|
+
const { text, baseHash } = input;
|
|
310
|
+
if (typeof text !== "string" || !text.trim()) throw new RouteError(400, "text must be the rules.json content");
|
|
311
|
+
if (Buffer.byteLength(text) > MAX_RULES_TEXT_BYTES) throw new RouteError(400, `text must be at most ${MAX_RULES_TEXT_BYTES} bytes`);
|
|
312
|
+
if (!("baseHash" in input) || !(baseHash === null || (typeof baseHash === "string" && HASH_PATTERN.test(baseHash)))) {
|
|
313
|
+
throw new RouteError(400, "baseHash must be the hash of the file you loaded (64 hex digits), or null when there was no file");
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const body = await colco(["runner", "rules", "set", "--json", "--raw", "--expect-hash", baseHash ?? "none"], { stdin: text });
|
|
317
|
+
return { ok: true, path: typeof body.path === "string" ? body.path : undefined, backup: typeof body.backup === "string" ? body.backup : null, hash: typeof body.hash === "string" && HASH_PATTERN.test(body.hash) ? body.hash : null };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const failure = error instanceof ColcoCliError ? error.body : undefined;
|
|
320
|
+
const code = isObject(failure?.error) ? failure.error.code : undefined;
|
|
321
|
+
const message = isObject(failure?.error) && typeof failure.error.message === "string" ? failure.error.message.slice(0, LIMITS.text) : undefined;
|
|
322
|
+
if (code === "invalid_rules") throw new RouteError(422, message ?? "the rules are invalid", { errors: stringList(failure.errors, LIMITS.warning) });
|
|
323
|
+
if (code === "conflict") {
|
|
324
|
+
const hash = isObject(failure.current) ? failure.current.hash : undefined;
|
|
325
|
+
throw new RouteError(409, message ?? "rules.json changed since it was loaded", { conflict: true, currentHash: typeof hash === "string" && HASH_PATTERN.test(hash) ? hash : null });
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
275
331
|
/**
|
|
276
332
|
* Answer one open decision with the option the user clicked.
|
|
277
333
|
* @param {(args: string[]) => Promise<Record<string, unknown>>} colco
|
|
@@ -319,6 +375,19 @@ async function stop(colco) {
|
|
|
319
375
|
return { ok: true, stopped: body.stopped === true };
|
|
320
376
|
}
|
|
321
377
|
|
|
378
|
+
/**
|
|
379
|
+
* The reply of `GET tasks`: one page of the tasks assigned to the signed-in user, from `colco tasks --json`. The
|
|
380
|
+
* query is checked first and nothing but a project id, a status and a page reaches the command; whose tasks they
|
|
381
|
+
* are is decided by the API key colco holds, never by the request.
|
|
382
|
+
* @param {(args: string[]) => Promise<Record<string, unknown>>} colco
|
|
383
|
+
* @param {URLSearchParams} search - the request's query string.
|
|
384
|
+
*/
|
|
385
|
+
async function myTasks(colco, search) {
|
|
386
|
+
const query = parseBoardQuery(search);
|
|
387
|
+
if (!query.ok) throw new RouteError(400, query.error);
|
|
388
|
+
return boardReply(await colco(boardArgs(query.value)));
|
|
389
|
+
}
|
|
390
|
+
|
|
322
391
|
/**
|
|
323
392
|
* A short message for any thrown value.
|
|
324
393
|
* @param {unknown} error
|
|
@@ -370,10 +439,12 @@ export function createRouteHandler(services) {
|
|
|
370
439
|
return pendingStatus;
|
|
371
440
|
};
|
|
372
441
|
|
|
373
|
-
/** name → method → handler; POST handlers receive the parsed body. */
|
|
442
|
+
/** name → method → handler; POST handlers receive the parsed body, every handler the request's query string. */
|
|
374
443
|
const table = {
|
|
375
444
|
"/status": { GET: () => sharedStatus() },
|
|
445
|
+
"/tasks": { GET: (_body, search) => myTasks(colco, search) },
|
|
376
446
|
"/rules": { GET: () => colco(["runner", "rules", "--json"]).then(rulesReply), POST: (body) => saveRules(colco, body) },
|
|
447
|
+
"/rules/file": { GET: () => colco(["runner", "rules", "get", "--json"]).then(rulesFileReply), POST: (body) => saveRulesFile(colco, body) },
|
|
377
448
|
"/options": { GET: () => colco(["runner", "options", "--json"]).then(optionsReply) },
|
|
378
449
|
"/answer": { POST: (body) => answer(colco, body) },
|
|
379
450
|
"/start": { POST: () => start(colco) },
|
|
@@ -383,7 +454,8 @@ export function createRouteHandler(services) {
|
|
|
383
454
|
return async function handle(req, res) {
|
|
384
455
|
let close = false;
|
|
385
456
|
try {
|
|
386
|
-
const
|
|
457
|
+
const url = new URL(req.url ?? "/", "http://colco4dsh.invalid");
|
|
458
|
+
const pathname = url.pathname;
|
|
387
459
|
const route = table[pathname.slice(ROUTE_PREFIX.length)];
|
|
388
460
|
admit(req, services);
|
|
389
461
|
if (!route) throw new RouteError(404, "no such colco4dsh route");
|
|
@@ -403,7 +475,7 @@ export function createRouteHandler(services) {
|
|
|
403
475
|
throw error;
|
|
404
476
|
}
|
|
405
477
|
}
|
|
406
|
-
reply(req, res, 200, await handler(body));
|
|
478
|
+
reply(req, res, 200, await handler(body, url.searchParams));
|
|
407
479
|
} catch (error) {
|
|
408
480
|
const code = statusOf(error);
|
|
409
481
|
if (code === 500) services.logger?.warn("colco4dsh: route failed", error);
|
package/lib/tasks.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The My Tasks board's side of the plugin: what the browser may ask for, the colco command that asks it, and how
|
|
2
|
+
// colco's reply is shaped before the page sees it. Pure functions, so the route stays a thin wrapper and every rule
|
|
3
|
+
// here is testable without a process or a socket.
|
|
4
|
+
import { field } from "./format.mjs";
|
|
5
|
+
import { safeLink } from "./dashboard.mjs";
|
|
6
|
+
|
|
7
|
+
/** Tasks per page. The plugin sets it; the browser cannot. */
|
|
8
|
+
export const BOARD_PAGE_SIZE = 25;
|
|
9
|
+
|
|
10
|
+
/** Highest page the board asks for; the same cap the Colco server enforces. */
|
|
11
|
+
export const MAX_BOARD_PAGE = 10000;
|
|
12
|
+
|
|
13
|
+
/** Longest task title and project name kept; the server allows 200 and 80 characters. */
|
|
14
|
+
const TITLE_MAX = 200;
|
|
15
|
+
const PROJECT_NAME_MAX = 80;
|
|
16
|
+
|
|
17
|
+
/** Most project choices kept for the filter. */
|
|
18
|
+
const PROJECT_CHOICES_MAX = 200;
|
|
19
|
+
|
|
20
|
+
/** The statuses of a task in board order, as the Colco server lists them: work in flight first, done last. */
|
|
21
|
+
export const BOARD_STATUSES = ["in_progress", "blocked", "review", "todo", "backlog", "done"];
|
|
22
|
+
|
|
23
|
+
/** Task priorities Colco knows. */
|
|
24
|
+
const PRIORITIES = new Set(["high", "medium", "low"]);
|
|
25
|
+
|
|
26
|
+
/** The only query keys the route accepts. */
|
|
27
|
+
const QUERY_KEYS = new Set(["project", "status", "page"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse the query of `GET tasks`. Only a project id, one of the six statuses and a page number pass, written as plain
|
|
31
|
+
* digits or exact words, each at most once. Anything else, including a key that could name a person, is refused, so
|
|
32
|
+
* nothing the browser sends can reach the colco command as an option or as free text.
|
|
33
|
+
* @param {URLSearchParams} search
|
|
34
|
+
* @returns {{ ok: true, value: { project?: number, status?: string, page: number } } | { ok: false, error: string }}
|
|
35
|
+
*/
|
|
36
|
+
export function parseBoardQuery(search) {
|
|
37
|
+
for (const key of new Set(search.keys())) {
|
|
38
|
+
if (!QUERY_KEYS.has(key)) return { ok: false, error: `unknown query parameter: ${key.slice(0, 40)}` };
|
|
39
|
+
if (search.getAll(key).length > 1) return { ok: false, error: `${key} may be given only once` };
|
|
40
|
+
}
|
|
41
|
+
const value = { page: 1 };
|
|
42
|
+
if (search.has("project")) {
|
|
43
|
+
const project = search.get("project");
|
|
44
|
+
if (!/^[1-9]\d{0,14}$/.test(project)) return { ok: false, error: "project must be a project id: digits, starting at 1" };
|
|
45
|
+
value.project = Number(project);
|
|
46
|
+
}
|
|
47
|
+
if (search.has("status")) {
|
|
48
|
+
const status = search.get("status");
|
|
49
|
+
if (!BOARD_STATUSES.includes(status)) return { ok: false, error: `status must be one of ${BOARD_STATUSES.join(", ")}` };
|
|
50
|
+
value.status = status;
|
|
51
|
+
}
|
|
52
|
+
if (search.has("page")) {
|
|
53
|
+
const page = search.get("page");
|
|
54
|
+
if (!/^[1-9]\d{0,4}$/.test(page) || Number(page) > MAX_BOARD_PAGE) return { ok: false, error: `page must be a number from 1 to ${MAX_BOARD_PAGE}` };
|
|
55
|
+
value.page = Number(page);
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, value };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The colco arguments for a parsed query. Each value becomes its own argv element; the page size is the plugin's.
|
|
62
|
+
* @param {{ project?: number, status?: string, page?: number }} query - the value from parseBoardQuery.
|
|
63
|
+
*/
|
|
64
|
+
export function boardArgs(query) {
|
|
65
|
+
return [
|
|
66
|
+
"tasks", "--json",
|
|
67
|
+
...query.project !== undefined ? ["--project-id", String(query.project)] : [],
|
|
68
|
+
...query.status !== undefined ? ["--status", query.status] : [],
|
|
69
|
+
"--page", String(query.page ?? 1),
|
|
70
|
+
"--page-size", String(BOARD_PAGE_SIZE),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A string cut to `max` characters, or undefined when the value is not a non-empty string.
|
|
76
|
+
* @param {unknown} value
|
|
77
|
+
* @param {number} max
|
|
78
|
+
*/
|
|
79
|
+
function text(value, max) {
|
|
80
|
+
return typeof value === "string" && value ? value.slice(0, max) : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A positive safe integer, or undefined.
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
*/
|
|
87
|
+
function positive(value) {
|
|
88
|
+
return Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A non-negative safe integer, or the fallback.
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @param {number} fallback
|
|
95
|
+
*/
|
|
96
|
+
function count(value, fallback = 0) {
|
|
97
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One task for the board, or undefined when it lacks a usable id or status. Only these fields are kept, so a
|
|
102
|
+
* description or assignee a future server adds never reaches the browser; the link must be an http(s) URL.
|
|
103
|
+
* @param {Record<string, unknown>} raw - one entry of `tasks` from `colco tasks --json`.
|
|
104
|
+
*/
|
|
105
|
+
function boardTask(raw) {
|
|
106
|
+
const id = positive(raw.id);
|
|
107
|
+
if (id === undefined || !BOARD_STATUSES.includes(raw.status)) return undefined;
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
projectId: positive(raw.projectId),
|
|
111
|
+
projectName: text(raw.projectName, PROJECT_NAME_MAX),
|
|
112
|
+
title: text(raw.title, TITLE_MAX),
|
|
113
|
+
status: raw.status,
|
|
114
|
+
priority: PRIORITIES.has(raw.priority) ? raw.priority : undefined,
|
|
115
|
+
dueDate: typeof raw.dueDate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw.dueDate) ? raw.dueDate : null,
|
|
116
|
+
updatedAt: text(raw.updatedAt, 40),
|
|
117
|
+
link: safeLink(raw.link),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The reply of `GET tasks`: the page of tasks, the paging numbers, one count per status in board order and the
|
|
123
|
+
* projects to filter by, each type-checked and cut. A reply without a `tasks` array fails like any malformed colco
|
|
124
|
+
* reply instead of showing an empty board.
|
|
125
|
+
* @param {Record<string, unknown>} body - `colco tasks --json`.
|
|
126
|
+
*/
|
|
127
|
+
export function boardReply(body) {
|
|
128
|
+
const raw = field(body, "tasks", "array", "colco tasks");
|
|
129
|
+
const pageSize = positive(body.pageSize) && body.pageSize <= 100 ? body.pageSize : BOARD_PAGE_SIZE;
|
|
130
|
+
const total = count(body.total);
|
|
131
|
+
|
|
132
|
+
const perStatus = new Map();
|
|
133
|
+
if (Array.isArray(body.counts)) {
|
|
134
|
+
for (const entry of body.counts) {
|
|
135
|
+
if (entry !== null && typeof entry === "object" && BOARD_STATUSES.includes(entry.status) && !perStatus.has(entry.status)) perStatus.set(entry.status, count(entry.count));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const projects = [];
|
|
140
|
+
if (Array.isArray(body.projects)) {
|
|
141
|
+
for (const entry of body.projects) {
|
|
142
|
+
if (projects.length === PROJECT_CHOICES_MAX) break;
|
|
143
|
+
const id = entry !== null && typeof entry === "object" ? positive(entry.id) : undefined;
|
|
144
|
+
const name = id === undefined ? undefined : text(entry.name, PROJECT_NAME_MAX);
|
|
145
|
+
if (id !== undefined && name !== undefined) projects.push({ id, name, count: count(entry.count) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
tasks: raw.map(boardTask).filter((task) => task !== undefined).slice(0, pageSize),
|
|
151
|
+
page: positive(body.page) && body.page <= MAX_BOARD_PAGE ? body.page : 1,
|
|
152
|
+
pageSize,
|
|
153
|
+
total,
|
|
154
|
+
pages: Math.max(1, Math.ceil(total / pageSize)),
|
|
155
|
+
counts: BOARD_STATUSES.map((status) => ({ status, count: perStatus.get(status) ?? 0 })),
|
|
156
|
+
projects,
|
|
157
|
+
};
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evanbobo/colco4dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "DSH plugin for Colco: a Colco page in the left sidebar with your runner, active runs and open decisions, plus chat tools to see and answer them",
|
|
6
6
|
"type": "module",
|