@rse/ase 1.0.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +351 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +230 -116
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +3 -0
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +251 -76
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +225 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +79 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { PassThrough } from "node:stream";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { Task } from "./ase-task.js";
|
|
12
|
+
import { userId } from "./ase-service-otel.js";
|
|
13
|
+
import { Config } from "./ase-config-core.js";
|
|
14
|
+
import { configSchema, webColorDefaults, webColorNames } from "./ase-config-schema.js";
|
|
15
|
+
import { buildBoard, watchTasks, toneOf, laneMoves, cardMoves, BoardState, attachmentTabs, isDiff, diffTones, dataLang, dataLoad, dataSpans, newTaskText, createTask, saveTask, TaskConflict, watchAgents } from "./ase-task-board-core.js";
|
|
16
|
+
import { layoutGraph, drawGraphSVG } from "./ase-task-board-graph.js";
|
|
17
|
+
import { flowOf, layoutFlow, drawFlowSVG } from "./ase-task-board-flow.js";
|
|
18
|
+
import { filterBoard, dropStandalone } from "./ase-task-board-filter.js";
|
|
19
|
+
import { statsOf, statsRanges, effortScale } from "./ase-task-board-stats.js";
|
|
20
|
+
import * as TaskFormat from "./ase-task-format.js";
|
|
21
|
+
import { editProbe, editRefused, requestEdit, requestsOf, RequestError } from "./ase-task-request.js";
|
|
22
|
+
import pkg from "../package.json" with { type: "json" };
|
|
23
|
+
/* the build stamp of the loaded code (version and content hash of the board modules
|
|
24
|
+
and page assets), reported by the ping route to let the CLI detect a stale running service
|
|
25
|
+
(content, not mtime, as rebuilds rewrite unchanged modules, e.g. under "build-watch") */
|
|
26
|
+
export const BOARD_BUILD = (() => {
|
|
27
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const hash = crypto.createHash("sha1");
|
|
29
|
+
for (const f of ["core.js", "filter.js", "graph.js", "stats.js", "web-server.js", "web-client.html", "web-client.css", "web-client.js"])
|
|
30
|
+
hash.update(fs.readFileSync(path.join(dir, `ase-task-board-${f}`)));
|
|
31
|
+
return `${pkg.version}:${hash.digest("hex")}`;
|
|
32
|
+
})();
|
|
33
|
+
/* the static assets of the browser client of the web board: its HTML page,
|
|
34
|
+
its stylesheet, and its code (all built by Vite out of the Vue client),
|
|
35
|
+
installed next to this module and loaded once on first use */
|
|
36
|
+
const clientAssets = new Map();
|
|
37
|
+
const clientAsset = (ext) => {
|
|
38
|
+
let asset = clientAssets.get(ext);
|
|
39
|
+
if (asset === undefined) {
|
|
40
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
asset = fs.readFileSync(path.join(dir, `ase-task-board-web-client.${ext}`), "utf8");
|
|
42
|
+
clientAssets.set(ext, asset);
|
|
43
|
+
}
|
|
44
|
+
return asset;
|
|
45
|
+
};
|
|
46
|
+
/* escape a text for embedding into HTML */
|
|
47
|
+
const escapeHTML = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
48
|
+
/* the pattern of the image sources: always local ones, and with remote images also HTTPS ones */
|
|
49
|
+
const imageSource = (remote) => remote ? /^(https:|data:image\/|\.{1,2}\/|\/(?![/\\]))/ : /^(data:image\/|\.{1,2}\/|\/(?![/\\]))/;
|
|
50
|
+
/* escape raw HTML, except (with remote images) its "<img>" tags, which are
|
|
51
|
+
re-assembled out of their harmless attributes only (as e.g. used by GitHub
|
|
52
|
+
for the images of issue comments) */
|
|
53
|
+
const rawHTML = (text, remote) => {
|
|
54
|
+
if (!remote)
|
|
55
|
+
return escapeHTML(text);
|
|
56
|
+
return text.split(/(<img\b[^>]*>)/i).map((part, i) => {
|
|
57
|
+
if (i % 2 === 0)
|
|
58
|
+
return escapeHTML(part);
|
|
59
|
+
const attrs = new Map();
|
|
60
|
+
for (const m of part.matchAll(/([a-z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi))
|
|
61
|
+
attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4]);
|
|
62
|
+
const src = attrs.get("src") ?? "";
|
|
63
|
+
if (!imageSource(remote).test(src))
|
|
64
|
+
return escapeHTML(attrs.get("alt") ?? "");
|
|
65
|
+
return `<img src="${escapeHTML(src)}"` + ["alt", "title", "width", "height"]
|
|
66
|
+
.filter((key) => attrs.has(key) && (!/^(width|height)$/.test(key) || /^\d+%?$/.test(attrs.get(key))))
|
|
67
|
+
.map((key) => ` ${key}="${escapeHTML(attrs.get(key))}"`).join("") + ">";
|
|
68
|
+
}).join("");
|
|
69
|
+
};
|
|
70
|
+
/* the Markdown renderers for task plans: raw HTML is escaped instead of
|
|
71
|
+
passed through, links are restricted to harmless schemes, images to
|
|
72
|
+
local sources (with remote images also to HTTPS sources, including raw
|
|
73
|
+
"<img>" tags), and Mermaid code blocks are rendered as diagrams
|
|
74
|
+
("marked" and "beautiful-mermaid" are loaded on first use only) */
|
|
75
|
+
const marked = new Map();
|
|
76
|
+
const markedLoad = async (remote) => {
|
|
77
|
+
const [{ Marked }, { renderMermaidSVG }] = await Promise.all([
|
|
78
|
+
import("marked"), import("beautiful-mermaid")
|
|
79
|
+
]);
|
|
80
|
+
return new Marked({
|
|
81
|
+
gfm: true,
|
|
82
|
+
renderer: {
|
|
83
|
+
html({ text }) {
|
|
84
|
+
return rawHTML(text, remote);
|
|
85
|
+
},
|
|
86
|
+
link({ href, tokens }) {
|
|
87
|
+
const label = this.parser.parseInline(tokens);
|
|
88
|
+
return /^(https?:|mailto:|#|\.{1,2}\/|\/(?![/\\]))/.test(href) ?
|
|
89
|
+
`<a href="${escapeHTML(href)}" target="_blank" rel="noopener">${label}</a>` : label;
|
|
90
|
+
},
|
|
91
|
+
image({ href, title, text }) {
|
|
92
|
+
return imageSource(remote).test(href) ?
|
|
93
|
+
`<img src="${escapeHTML(href)}" alt="${escapeHTML(text)}"` +
|
|
94
|
+
(title ? ` title="${escapeHTML(title)}"` : "") + ">" : escapeHTML(text);
|
|
95
|
+
},
|
|
96
|
+
code({ text, lang }) {
|
|
97
|
+
if (lang === "mermaid") {
|
|
98
|
+
try {
|
|
99
|
+
return `<div class="diagram">${renderMermaidSVG(text)}</div>`;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* fall through to the plain rendering of the source */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return `<pre class="textart"><code>${escapeHTML(text)}</code></pre>`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
/* render the Markdown body of a task plan (optionally with remote images), including
|
|
111
|
+
its title, and render its checkboxes (all line rewrites skip the content of fenced code blocks), where
|
|
112
|
+
the checkbox placeholders carry the box state name instead of the
|
|
113
|
+
box character, as Markdown rendering would escape e.g. ">" */
|
|
114
|
+
const boxes = { "x": "done", "/": "part", "?": "open", "-": "cancel", ">": "defer", " ": "todo" };
|
|
115
|
+
const boxChars = Object.fromEntries(Object.entries(boxes).map(([char, state]) => [state, char]));
|
|
116
|
+
const renderPlan = async (body, remote = false) => {
|
|
117
|
+
let fence = "";
|
|
118
|
+
const prepared = body.split(/\r?\n/).map((line) => {
|
|
119
|
+
const outside = fence === "";
|
|
120
|
+
fence = TaskFormat.fenceTrack(fence, line);
|
|
121
|
+
return !outside || fence !== "" ? line : line
|
|
122
|
+
.replace(/^(\s*(?:[-*]|\d+[.)])\s+)\[([ x/?\->])\]/, (_m, lead, box) => `${lead}⟦box:${boxes[box]}⟧`);
|
|
123
|
+
}).join("\n");
|
|
124
|
+
if (!marked.has(remote))
|
|
125
|
+
marked.set(remote, markedLoad(remote));
|
|
126
|
+
const html = (await marked.get(remote)).parse(prepared, { async: false });
|
|
127
|
+
return html.replace(/⟦box:([a-z]+)⟧/g, (_m, state) => `<span class="box ${state}" title="[${escapeHTML(boxChars[state] ?? " ")}]"></span>`);
|
|
128
|
+
};
|
|
129
|
+
const colorRoles = Object.keys(webColorDefaults);
|
|
130
|
+
const colorBases = (log) => {
|
|
131
|
+
const cfg = new Config("config", configSchema, log);
|
|
132
|
+
cfg.read();
|
|
133
|
+
const bases = {};
|
|
134
|
+
for (const role of colorRoles) {
|
|
135
|
+
const value = cfg.get(`board.web.color.${role}`);
|
|
136
|
+
const color = typeof value !== "string" || value === "default" ?
|
|
137
|
+
webColorDefaults[role] : (webColorNames[value] ?? value);
|
|
138
|
+
bases[role] = color.replace(/^#(.)(.)(.)$/, "#$1$1$2$2$3$3").toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
return bases;
|
|
141
|
+
};
|
|
142
|
+
/* expand each base color (via MRCS) into a spread of 64 colors over the entire
|
|
143
|
+
lightness range (1: black, 64: white), rendered into the ":root" CSS variables
|
|
144
|
+
"--color-<role>-{1..64}", onto which the page stylesheet maps its semantic colors */
|
|
145
|
+
const colorSheet = async (bases) => {
|
|
146
|
+
const { parse, generate } = await import("@rse/mrcs");
|
|
147
|
+
return ":root {\n" + colorRoles.map((role) => generate(parse(`${bases[role]}+0-0/64`)).map((color, i) => ` --color-${role}-${i + 1}: ${color};\n`).join("")).join("") + "}\n";
|
|
148
|
+
};
|
|
149
|
+
/* the key bindings of the task plan editor, resolved from the
|
|
150
|
+
"board.web.editor.keymap" configuration (on each start of editing) */
|
|
151
|
+
const editorKeymap = (log) => {
|
|
152
|
+
const cfg = new Config("config", configSchema, log);
|
|
153
|
+
cfg.read();
|
|
154
|
+
const value = cfg.get("board.web.editor.keymap");
|
|
155
|
+
return value === "vim" || value === "emacs" ? value : "default";
|
|
156
|
+
};
|
|
157
|
+
/* render a standalone document (styled by the client stylesheet) out of
|
|
158
|
+
key/value fields, separated by a line from the content */
|
|
159
|
+
const fieldsDocument = (fields, content) => {
|
|
160
|
+
const rows = fields.map(([k, v]) => `<tr><td class="k">${escapeHTML(k)}</td><td>${escapeHTML(v)}</td></tr>`).join("");
|
|
161
|
+
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" +
|
|
162
|
+
"<link rel=\"stylesheet\" href=\"/task-board/colors.css\">" +
|
|
163
|
+
"<link rel=\"stylesheet\" href=\"/task-board/ase-task-board-web-client.css\"></head><body class=\"plan\"><article>" +
|
|
164
|
+
(rows !== "" ? `<table class="fields">${rows}</table><hr class="fields">` : "") + content +
|
|
165
|
+
"</article></body></html>";
|
|
166
|
+
};
|
|
167
|
+
/* render a task plan as a standalone document: frontmatter fields and the numbered plan sections */
|
|
168
|
+
const taskDocument = async (keys, body) => fieldsDocument([...keys].filter(([k]) => k !== "Type"), await renderPlan(body));
|
|
169
|
+
/* render a task plan attachment as a standalone document: its fields and
|
|
170
|
+
its content, with images embedded, Markdown rendered, YAML or JSON data syntax-highlighted, a diff
|
|
171
|
+
colored per line, binary content as a placeholder, and else verbatim text */
|
|
172
|
+
const attachmentDocument = async (log, id, n) => {
|
|
173
|
+
const att = (await Task.attachments(log, id))[n];
|
|
174
|
+
if (att === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
const fields = [
|
|
177
|
+
["Type", att.type], ["Desc", att.desc], ["Created", att.created ?? ""], ["Modified", att.modified ?? ""]
|
|
178
|
+
].filter(([, v]) => v !== "");
|
|
179
|
+
const url = `/task-board/api/task/${encodeURIComponent(id)}/attachment/${n}`;
|
|
180
|
+
const type = att.type.split(";")[0].trim().toLowerCase();
|
|
181
|
+
/* embed images (including SVG, as an image context never runs
|
|
182
|
+
its scripts and never loads its external resources, marked
|
|
183
|
+
to fit the tab width, as it may lack an intrinsic size) */
|
|
184
|
+
if (type.startsWith("image/"))
|
|
185
|
+
return fieldsDocument(fields, `<div class="embedding"><img${type === "image/svg+xml" ? " class=\"svg\"" : ""} ` +
|
|
186
|
+
`src="${url}" alt="${escapeHTML(att.desc || att.type)}"></div>`);
|
|
187
|
+
let text = att.data;
|
|
188
|
+
if (text === undefined) {
|
|
189
|
+
const a = await Task.attachmentContent(log, id, n);
|
|
190
|
+
if (a === null)
|
|
191
|
+
return fieldsDocument(fields, "<p class=\"warn\">no such attachment content</p>");
|
|
192
|
+
try {
|
|
193
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(a.content);
|
|
194
|
+
}
|
|
195
|
+
catch (_err) {
|
|
196
|
+
text = "\0";
|
|
197
|
+
}
|
|
198
|
+
if (text.includes("\0"))
|
|
199
|
+
return fieldsDocument(fields, `<p class="mute">(binary content: ${escapeHTML(att.type)}, ${a.content.length} bytes, ` +
|
|
200
|
+
`<a href="${url}" target="_blank" rel="noopener">download</a>)</p>`);
|
|
201
|
+
}
|
|
202
|
+
if (type === "text/markdown")
|
|
203
|
+
return fieldsDocument(fields, await renderPlan(text, true));
|
|
204
|
+
if (isDiff(att.type)) {
|
|
205
|
+
const lines = text.split(/\r?\n/);
|
|
206
|
+
const tones = diffTones(lines);
|
|
207
|
+
return fieldsDocument(fields, "<pre class=\"textart diff\"><code>" +
|
|
208
|
+
lines.map((line, i) => `<span class="${tones[i]}">${escapeHTML(line)}</span>`).join("\n") + "</code></pre>");
|
|
209
|
+
}
|
|
210
|
+
const lang = dataLang(att.type);
|
|
211
|
+
if (lang !== null) {
|
|
212
|
+
await dataLoad();
|
|
213
|
+
return fieldsDocument(fields, "<pre class=\"textart data\"><code>" +
|
|
214
|
+
dataSpans(text, lang).map((spans) => spans.map((span) => span.tone === "str" || span.tone === "text" ? escapeHTML(span.text) :
|
|
215
|
+
`<span class="${span.tone}">${escapeHTML(span.text)}</span>`).join("")).join("\n") + "</code></pre>");
|
|
216
|
+
}
|
|
217
|
+
return fieldsDocument(fields, `<pre class="textart"><code>${escapeHTML(text)}</code></pre>`);
|
|
218
|
+
};
|
|
219
|
+
/* serialize the board for the browser */
|
|
220
|
+
const boardJSON = (board, lifecycle, users) => ({
|
|
221
|
+
mode: board.mode,
|
|
222
|
+
project: Task.projectIdOf(Task.projectRoot()),
|
|
223
|
+
version: pkg.version,
|
|
224
|
+
warnings: board.warnings,
|
|
225
|
+
users,
|
|
226
|
+
self: userId(),
|
|
227
|
+
surface: BoardState.load().web,
|
|
228
|
+
moves: laneMoves(board, lifecycle),
|
|
229
|
+
groups: board.groups.map((g) => ({
|
|
230
|
+
title: g.title,
|
|
231
|
+
lanes: g.lanes.map((l) => ({
|
|
232
|
+
status: l.status, active: l.active, weight: l.weight, dashed: l.dashed, kind: l.kind,
|
|
233
|
+
cards: (board.lanes.get(l.status) ?? []).map((c) => ({
|
|
234
|
+
id: c.id, title: c.title, group: c.group, tone: toneOf(board, c), moves: cardMoves(board, lifecycle, c),
|
|
235
|
+
edit: c.edit, finished: editRefused(lifecycle, c.actual), assignee: c.assignee, effort: c.effort
|
|
236
|
+
}))
|
|
237
|
+
}))
|
|
238
|
+
}))
|
|
239
|
+
});
|
|
240
|
+
const clients = new Set();
|
|
241
|
+
let stopWatch = null;
|
|
242
|
+
/* the agents passed by the agent relay (null without an agent relay), and its watcher,
|
|
243
|
+
running alongside the change watcher */
|
|
244
|
+
let agentsState = null;
|
|
245
|
+
let stopAgents = null;
|
|
246
|
+
const AGENTS_PUSH_MS = 300;
|
|
247
|
+
let agentsTimer = null;
|
|
248
|
+
let agentsPushed = 0;
|
|
249
|
+
let pushedPresence = "";
|
|
250
|
+
let pushedLogs = new Map();
|
|
251
|
+
/* the presence of the agents (without their recent processing log lines) */
|
|
252
|
+
const agentsPresence = (state) => state === null ? null : { connected: state.connected, agents: state.agents.map(({ recent: _recent, ...agent }) => agent) };
|
|
253
|
+
/* whether two processing log lines are the same */
|
|
254
|
+
const sameLine = (a, b) => a.time === b.time && a.level === b.level && a.task === b.task && a.msg === b.msg;
|
|
255
|
+
/* push the changes of the agents since the previous push to all event stream clients */
|
|
256
|
+
const pushAgents = () => {
|
|
257
|
+
agentsPushed = Date.now();
|
|
258
|
+
const presence = JSON.stringify(agentsPresence(agentsState));
|
|
259
|
+
if (presence !== pushedPresence) {
|
|
260
|
+
pushedPresence = presence;
|
|
261
|
+
emitRaw("agents", presence);
|
|
262
|
+
}
|
|
263
|
+
const logs = {};
|
|
264
|
+
const next = new Map();
|
|
265
|
+
for (const agent of agentsState?.agents ?? []) {
|
|
266
|
+
const sent = pushedLogs.get(agent.id) ?? [];
|
|
267
|
+
const last = sent.at(-1);
|
|
268
|
+
const at = last !== undefined ? agent.recent.findLastIndex((line) => sameLine(line, last)) : -1;
|
|
269
|
+
if (at >= 0) {
|
|
270
|
+
if (at < agent.recent.length - 1)
|
|
271
|
+
logs[agent.id] = { reset: false, lines: agent.recent.slice(at + 1) };
|
|
272
|
+
}
|
|
273
|
+
else if (agent.recent.length > 0 || sent.length > 0)
|
|
274
|
+
logs[agent.id] = { reset: true, lines: agent.recent };
|
|
275
|
+
next.set(agent.id, agent.recent);
|
|
276
|
+
}
|
|
277
|
+
pushedLogs = next;
|
|
278
|
+
if (Object.keys(logs).length > 0)
|
|
279
|
+
emit("logs", logs);
|
|
280
|
+
};
|
|
281
|
+
/* schedule a coalesced push of the changes of the agents */
|
|
282
|
+
const scheduleAgents = (state) => {
|
|
283
|
+
agentsState = state;
|
|
284
|
+
if (agentsTimer !== null)
|
|
285
|
+
return;
|
|
286
|
+
agentsTimer = setTimeout(() => {
|
|
287
|
+
agentsTimer = null;
|
|
288
|
+
pushAgents();
|
|
289
|
+
}, Math.max(0, agentsPushed + AGENTS_PUSH_MS - Date.now()));
|
|
290
|
+
};
|
|
291
|
+
/* the pushed agents for a newly connected event stream client */
|
|
292
|
+
const pushedAgents = () => ({
|
|
293
|
+
presence: pushedPresence,
|
|
294
|
+
logs: Object.fromEntries([...pushedLogs].map(([id, lines]) => [id, { reset: true, lines }]))
|
|
295
|
+
});
|
|
296
|
+
/* the kind and connection state of the task store, as reported by the change watcher */
|
|
297
|
+
let storeState = null;
|
|
298
|
+
/* the memoized board, valid only while the change watcher runs */
|
|
299
|
+
let cached = null;
|
|
300
|
+
/* build the board at most once per task store change, sharing
|
|
301
|
+
the build between concurrent requests */
|
|
302
|
+
const currentBoard = (log) => {
|
|
303
|
+
if (cached === null || stopWatch === null) {
|
|
304
|
+
const build = buildBoard(log);
|
|
305
|
+
build.catch(() => {
|
|
306
|
+
if (cached === build)
|
|
307
|
+
cached = null;
|
|
308
|
+
});
|
|
309
|
+
cached = build;
|
|
310
|
+
}
|
|
311
|
+
return cached;
|
|
312
|
+
};
|
|
313
|
+
/* send an event (with its data, resp. its already serialized data) to all event stream clients */
|
|
314
|
+
const emitRaw = (event, data) => {
|
|
315
|
+
for (const c of clients) {
|
|
316
|
+
c.stream.write(`event: ${event}\ndata: ${data}\n\n`);
|
|
317
|
+
c.compressor?.flush();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const emit = (event, data) => {
|
|
321
|
+
emitRaw(event, JSON.stringify(data));
|
|
322
|
+
};
|
|
323
|
+
/* drop the memoized board and broadcast a change notification to all event stream clients */
|
|
324
|
+
const broadcast = () => {
|
|
325
|
+
cached = null;
|
|
326
|
+
emit("change", {});
|
|
327
|
+
};
|
|
328
|
+
const guarded = (handler) => async (request, h) => {
|
|
329
|
+
try {
|
|
330
|
+
return await handler(request, h);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
return h.response({ error: err instanceof Error ? err.message : String(err) }).code(500);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
/* register the page routes of the web board: its client assets and its colors */
|
|
337
|
+
const registerPageRoutes = (server, log) => {
|
|
338
|
+
/* the single page of the web board, with its stylesheet and browser-side code */
|
|
339
|
+
server.route({
|
|
340
|
+
method: "GET",
|
|
341
|
+
path: "/task-board",
|
|
342
|
+
handler: (_request, h) => h.response(clientAsset("html")).type("text/html; charset=utf-8")
|
|
343
|
+
});
|
|
344
|
+
server.route({
|
|
345
|
+
method: "GET",
|
|
346
|
+
path: "/task-board/ase-task-board-web-client.css",
|
|
347
|
+
handler: (_request, h) => h.response(clientAsset("css")).type("text/css; charset=utf-8")
|
|
348
|
+
.etag(BOARD_BUILD).header("Cache-Control", "no-cache")
|
|
349
|
+
});
|
|
350
|
+
server.route({
|
|
351
|
+
method: "GET",
|
|
352
|
+
path: "/task-board/ase-task-board-web-client.js",
|
|
353
|
+
handler: (_request, h) => h.response(clientAsset("js")).type("text/javascript; charset=utf-8")
|
|
354
|
+
.etag(BOARD_BUILD).header("Cache-Control", "no-cache")
|
|
355
|
+
});
|
|
356
|
+
/* the color spreads of the page (layer 1), onto which its stylesheet
|
|
357
|
+
maps its semantic colors (layer 2), resolved on each page load */
|
|
358
|
+
server.route({
|
|
359
|
+
method: "GET",
|
|
360
|
+
path: "/task-board/colors.css",
|
|
361
|
+
handler: async (_request, h) => h.response(await colorSheet(colorBases(log)))
|
|
362
|
+
.type("text/css; charset=utf-8").header("Cache-Control", "no-cache")
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
/* the filter query of a request (its optional "filter" query parameter) */
|
|
366
|
+
const filterQuery = (request) => typeof request.query.filter === "string" ? request.query.filter : "";
|
|
367
|
+
/* register the view routes of the web board: the board, the graph, the statistics, and the tasks with their attachments */
|
|
368
|
+
const registerViewRoutes = (server, log) => {
|
|
369
|
+
/* the board model, reduced onto the tasks matching the filter query */
|
|
370
|
+
server.route({
|
|
371
|
+
method: "GET",
|
|
372
|
+
path: "/task-board/api/board",
|
|
373
|
+
handler: guarded(async (request, h) => h.response(boardJSON(filterBoard(await currentBoard(log), filterQuery(request)), await Task.lifecycle(log), Task.users(log))))
|
|
374
|
+
});
|
|
375
|
+
/* the dependency graph as SVG, reduced onto the tasks matching the
|
|
376
|
+
filter query plus their direct predecessors and successors
|
|
377
|
+
(and optionally without the standalone tasks), plus its statistics */
|
|
378
|
+
server.route({
|
|
379
|
+
method: "GET",
|
|
380
|
+
path: "/task-board/api/graph",
|
|
381
|
+
handler: guarded(async (request, h) => {
|
|
382
|
+
const state = BoardState.load().web;
|
|
383
|
+
const found = filterBoard(await currentBoard(log), filterQuery(request), true);
|
|
384
|
+
const board = state.standalone ? found : dropStandalone(found);
|
|
385
|
+
const svg = board.cards.size === 0 ? "" : drawGraphSVG(board, await layoutGraph(board, "px", state.titles));
|
|
386
|
+
const stats = {
|
|
387
|
+
tasks: board.cards.size,
|
|
388
|
+
dependencies: [...board.pred.values()].reduce((n, ps) => n + ps.length, 0)
|
|
389
|
+
};
|
|
390
|
+
return h.response({ svg, stats });
|
|
391
|
+
})
|
|
392
|
+
});
|
|
393
|
+
/* the agent graph of the running agents as SVG (empty without any agents), with the
|
|
394
|
+
lifecycle states placed as in the lanes, plus its statistics */
|
|
395
|
+
server.route({
|
|
396
|
+
method: "GET",
|
|
397
|
+
path: "/task-board/api/flow",
|
|
398
|
+
handler: guarded(async (_request, h) => {
|
|
399
|
+
const flow = flowOf(agentsState?.agents ?? [], (await currentBoard(log)).groups);
|
|
400
|
+
const svg = flow.cards.size === 0 ? "" : drawFlowSVG(flow, await layoutFlow(flow, "px"));
|
|
401
|
+
return h.response({ svg, stats: { agents: agentsState?.agents.length ?? 0, states: flow.states.length } });
|
|
402
|
+
})
|
|
403
|
+
});
|
|
404
|
+
/* the statistics of the tasks matching the filter query within the time range: the tasks
|
|
405
|
+
per lifecycle state, their progress, their effort (on the configured scale, on each
|
|
406
|
+
request), and their total AI usage (loading all plans) */
|
|
407
|
+
server.route({
|
|
408
|
+
method: "GET",
|
|
409
|
+
path: "/task-board/api/stats",
|
|
410
|
+
handler: guarded(async (request, h) => {
|
|
411
|
+
const range = typeof request.query.range === "string" ? request.query.range : "total";
|
|
412
|
+
if (!statsRanges.includes(range))
|
|
413
|
+
return h.response({ error: "invalid statistics range" }).code(400);
|
|
414
|
+
const all = await currentBoard(log);
|
|
415
|
+
return h.response(statsOf(filterBoard(all, filterQuery(request)), all, await Task.listUsage(log), range, effortScale(log)));
|
|
416
|
+
})
|
|
417
|
+
});
|
|
418
|
+
/* one task plan, rendered in the appearance of SpecBook */
|
|
419
|
+
server.route({
|
|
420
|
+
method: "GET",
|
|
421
|
+
path: "/task-board/api/task/{id}",
|
|
422
|
+
handler: guarded(async (request, h) => {
|
|
423
|
+
const id = String(request.params.id);
|
|
424
|
+
const board = await currentBoard(log);
|
|
425
|
+
const card = board.cards.get(id);
|
|
426
|
+
const parts = card !== undefined ? await Task.parts(log, id) : null;
|
|
427
|
+
if (card === undefined || parts === null)
|
|
428
|
+
return h.response({ error: `no task "${id}"` }).code(404);
|
|
429
|
+
const tone = (x) => {
|
|
430
|
+
const c = board.cards.get(x);
|
|
431
|
+
return c !== undefined ? toneOf(board, c) : "idle";
|
|
432
|
+
};
|
|
433
|
+
const ref = (ids) => ids.map((x) => ({ id: x, tone: tone(x) }));
|
|
434
|
+
return h.response({
|
|
435
|
+
id,
|
|
436
|
+
title: card.title,
|
|
437
|
+
tone: toneOf(board, card),
|
|
438
|
+
status: card.status,
|
|
439
|
+
group: board.groups.find((g) => g.lanes.some((l) => l.status === card.status))?.title ?? "",
|
|
440
|
+
epic: card.group,
|
|
441
|
+
doc: await taskDocument(parts.keys, parts.body),
|
|
442
|
+
tabs: attachmentTabs(await Task.attachments(log, id), parts.keys.get("Modified") ?? ""),
|
|
443
|
+
pred: ref(board.pred.get(id) ?? []),
|
|
444
|
+
succ: ref(board.succ.get(id) ?? [])
|
|
445
|
+
});
|
|
446
|
+
})
|
|
447
|
+
});
|
|
448
|
+
/* one task plan as text for editing, with the entity tag it is based on and the editor key bindings */
|
|
449
|
+
server.route({
|
|
450
|
+
method: "GET",
|
|
451
|
+
path: "/task-board/api/task/{id}/source",
|
|
452
|
+
handler: guarded(async (request, h) => {
|
|
453
|
+
const id = String(request.params.id);
|
|
454
|
+
if (!TaskFormat.TASK_ID_RE.test(id))
|
|
455
|
+
return h.response({ error: `invalid task id "${id}"` }).code(400);
|
|
456
|
+
const src = await Task.source(log, id);
|
|
457
|
+
if (src === null)
|
|
458
|
+
return h.response({ error: `no task "${id}"` }).code(404);
|
|
459
|
+
return h.response({ text: src.text, base: src.tag, keymap: editorKeymap(log) });
|
|
460
|
+
})
|
|
461
|
+
});
|
|
462
|
+
/* the pre-filled text of a new task, with the editor key bindings */
|
|
463
|
+
server.route({
|
|
464
|
+
method: "GET",
|
|
465
|
+
path: "/task-board/api/new",
|
|
466
|
+
handler: guarded(async (_request, h) => h.response({
|
|
467
|
+
text: await newTaskText(log, await currentBoard(log), await Task.lifecycle(log)),
|
|
468
|
+
keymap: editorKeymap(log)
|
|
469
|
+
}))
|
|
470
|
+
});
|
|
471
|
+
/* one attachment of a task plan, rendered as a document for its tab of the task dialog */
|
|
472
|
+
server.route({
|
|
473
|
+
method: "GET",
|
|
474
|
+
path: "/task-board/api/task/{id}/attachment/{n}/doc",
|
|
475
|
+
handler: guarded(async (request, h) => {
|
|
476
|
+
const id = String(request.params.id);
|
|
477
|
+
const n = String(request.params.n);
|
|
478
|
+
if (!TaskFormat.TASK_ID_RE.test(id))
|
|
479
|
+
return h.response({ error: `invalid task id "${id}"` }).code(400);
|
|
480
|
+
const doc = /^\d+$/.test(n) ? await attachmentDocument(log, id, Number(n)) : null;
|
|
481
|
+
if (doc === null)
|
|
482
|
+
return h.response({ error: "no such attachment" }).code(404);
|
|
483
|
+
return h.response({ doc });
|
|
484
|
+
})
|
|
485
|
+
});
|
|
486
|
+
/* one attachment of a task plan, fetched through the task interface */
|
|
487
|
+
server.route({
|
|
488
|
+
method: "GET",
|
|
489
|
+
path: "/task-board/api/task/{id}/attachment/{n}",
|
|
490
|
+
handler: guarded(async (request, h) => {
|
|
491
|
+
const id = String(request.params.id);
|
|
492
|
+
const n = String(request.params.n);
|
|
493
|
+
if (!TaskFormat.TASK_ID_RE.test(id))
|
|
494
|
+
return h.response({ error: `invalid task id "${id}"` }).code(400);
|
|
495
|
+
const a = /^\d+$/.test(n) ? await Task.attachmentContent(log, id, Number(n)) : null;
|
|
496
|
+
if (a === null)
|
|
497
|
+
return h.response({ error: "no such attachment" }).code(404);
|
|
498
|
+
const type = a.type.split(";")[0].trim().toLowerCase();
|
|
499
|
+
/* confine the content: no scripts, forms, or origin access (sandbox)
|
|
500
|
+
and no external resources, except for inline styles and data URLs,
|
|
501
|
+
so even a directly displayed SVG can neither act nor track */
|
|
502
|
+
const res = h.response(a.content).type(type)
|
|
503
|
+
.header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:")
|
|
504
|
+
.header("X-Content-Type-Options", "nosniff");
|
|
505
|
+
/* never render non-image attachments on the service address */
|
|
506
|
+
if (!type.startsWith("image/"))
|
|
507
|
+
res.header("Content-Disposition", `attachment; filename="attachment-${n}"`);
|
|
508
|
+
return res;
|
|
509
|
+
})
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
/* register the update routes of the web board: task moves, assignments, saves, creations, and deletions */
|
|
513
|
+
const registerUpdateRoutes = (server, log) => {
|
|
514
|
+
/* move a task to another lane by changing its status (the change
|
|
515
|
+
watcher then pushes the changed board to all open web boards) */
|
|
516
|
+
server.route({
|
|
517
|
+
method: "POST",
|
|
518
|
+
path: "/task-board/api/move",
|
|
519
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
520
|
+
handler: guarded(async (request, h) => {
|
|
521
|
+
const p = request.payload;
|
|
522
|
+
if (p === null || typeof p.id !== "string" || typeof p.status !== "string")
|
|
523
|
+
return h.response({ error: "invalid move request" }).code(400);
|
|
524
|
+
const board = await currentBoard(log);
|
|
525
|
+
const card = board.cards.get(p.id);
|
|
526
|
+
if (card === undefined)
|
|
527
|
+
return h.response({ error: `no task "${p.id}"` }).code(404);
|
|
528
|
+
/* accept only moves to states reachable in the lifecycle model */
|
|
529
|
+
const lifecycle = await Task.lifecycle(log);
|
|
530
|
+
const reason = TaskFormat.checkStatus(lifecycle, card.actual, p.status);
|
|
531
|
+
if (reason !== "")
|
|
532
|
+
return h.response({ error: `task "${p.id}": ${reason}` }).code(400);
|
|
533
|
+
return h.response(await Task.setStatus(log, p.id, p.status));
|
|
534
|
+
})
|
|
535
|
+
});
|
|
536
|
+
/* assign a task to an assignee ("me" for the current user, and empty or "none"
|
|
537
|
+
to unassign it), where an unknown assignee is assigned with a warning */
|
|
538
|
+
server.route({
|
|
539
|
+
method: "POST",
|
|
540
|
+
path: "/task-board/api/assign",
|
|
541
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
542
|
+
handler: guarded(async (request, h) => {
|
|
543
|
+
const p = request.payload;
|
|
544
|
+
if (p === null || typeof p.id !== "string" || typeof p.assignee !== "string")
|
|
545
|
+
return h.response({ error: "invalid assign request" }).code(400);
|
|
546
|
+
const to = p.assignee.trim() === "me" ? userId() : p.assignee.trim();
|
|
547
|
+
if (/[\r\n,]/.test(to))
|
|
548
|
+
return h.response({ error: "assignee must be a single-line name without commas" }).code(400);
|
|
549
|
+
const warning = await Task.checkAssignee(log, to);
|
|
550
|
+
const from = await Task.assign(log, p.id, to === "" || to === "none" ? null : to);
|
|
551
|
+
return h.response({ from, to: to === "none" ? "" : to, warning });
|
|
552
|
+
})
|
|
553
|
+
});
|
|
554
|
+
/* save an edited task plan, but only if it was not changed in the meantime
|
|
555
|
+
(answering 409 with the current entity tag, or null if deleted meanwhile),
|
|
556
|
+
and answering 400 if the text is invalid or its status is not reachable;
|
|
557
|
+
a changed "Id:" key renames the task, answered with the resulting id */
|
|
558
|
+
server.route({
|
|
559
|
+
method: "POST",
|
|
560
|
+
path: "/task-board/api/task/{id}/source",
|
|
561
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
562
|
+
handler: guarded(async (request, h) => {
|
|
563
|
+
const id = String(request.params.id);
|
|
564
|
+
const p = request.payload;
|
|
565
|
+
if (p === null || typeof p.text !== "string" || typeof p.base !== "string")
|
|
566
|
+
return h.response({ error: "invalid save request" }).code(400);
|
|
567
|
+
let next;
|
|
568
|
+
try {
|
|
569
|
+
next = await saveTask(log, id, p.text, p.base);
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
if (err instanceof TaskConflict)
|
|
573
|
+
return h.response({ error: err.message, base: err.tag }).code(409);
|
|
574
|
+
return h.response({ error: err instanceof Error ? err.message : String(err) }).code(400);
|
|
575
|
+
}
|
|
576
|
+
if (next.warning !== "")
|
|
577
|
+
log.write("warning", `board: ${next.warning}`);
|
|
578
|
+
return h.response({ ok: true, id: next.id });
|
|
579
|
+
})
|
|
580
|
+
});
|
|
581
|
+
/* create a new task plan, but only if no task of its id exists yet
|
|
582
|
+
(answering 409), and answering 400 if the text or id is invalid */
|
|
583
|
+
server.route({
|
|
584
|
+
method: "PUT",
|
|
585
|
+
path: "/task-board/api/task/{id}/source",
|
|
586
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
587
|
+
handler: guarded(async (request, h) => {
|
|
588
|
+
const id = String(request.params.id);
|
|
589
|
+
const p = request.payload;
|
|
590
|
+
if (p === null || typeof p.text !== "string")
|
|
591
|
+
return h.response({ error: "invalid create request" }).code(400);
|
|
592
|
+
if (TaskFormat.TASK_ID_RE.test(id) && await Task.source(log, id) !== null)
|
|
593
|
+
return h.response({ error: `task "${id}" already exists` }).code(409);
|
|
594
|
+
let warning;
|
|
595
|
+
try {
|
|
596
|
+
warning = await createTask(log, id, p.text);
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
return h.response({ error: err instanceof Error ? err.message : String(err) }).code(400);
|
|
600
|
+
}
|
|
601
|
+
if (warning !== "")
|
|
602
|
+
log.write("warning", `board: ${warning}`);
|
|
603
|
+
return h.response({ ok: true });
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
/* delete a task plan (answering 404 if it does not exist) */
|
|
607
|
+
server.route({
|
|
608
|
+
method: "DELETE",
|
|
609
|
+
path: "/task-board/api/task/{id}",
|
|
610
|
+
handler: guarded(async (request, h) => {
|
|
611
|
+
const id = String(request.params.id);
|
|
612
|
+
if (!TaskFormat.TASK_ID_RE.test(id))
|
|
613
|
+
return h.response({ error: `invalid task id "${id}"` }).code(400);
|
|
614
|
+
if (!await Task.delete(log, id))
|
|
615
|
+
return h.response({ error: `no task "${id}"` }).code(404);
|
|
616
|
+
return h.response({ ok: true });
|
|
617
|
+
})
|
|
618
|
+
});
|
|
619
|
+
/* get the prompt of the most recent failed edit request of a task
|
|
620
|
+
(for its re-request), else an empty one, answering 404 if the task is missing
|
|
621
|
+
and 409 if it is finished or an edit of it is in progress (for a web board which
|
|
622
|
+
cannot check the task itself, as it is not on its possibly filtered board) */
|
|
623
|
+
server.route({
|
|
624
|
+
method: "GET",
|
|
625
|
+
path: "/task-board/api/task/{id}/edit",
|
|
626
|
+
handler: guarded(async (request, h) => {
|
|
627
|
+
const id = String(request.params.id);
|
|
628
|
+
if (!TaskFormat.TASK_ID_RE.test(id))
|
|
629
|
+
return h.response({ error: `invalid task id "${id}"` }).code(400);
|
|
630
|
+
const probe = await editProbe(log, id);
|
|
631
|
+
if ("refused" in probe) {
|
|
632
|
+
if (probe.refused === "missing")
|
|
633
|
+
return h.response({ error: `no task "${id}"` }).code(404);
|
|
634
|
+
const reason = probe.refused === "finished" ? "task is finished" : "edit in progress";
|
|
635
|
+
return h.response({ error: `editing task "${id}" by prompt refused: ${reason}` }).code(409);
|
|
636
|
+
}
|
|
637
|
+
return h.response({ prompt: probe.prompt });
|
|
638
|
+
})
|
|
639
|
+
});
|
|
640
|
+
/* request the edit of a task by a prompt (processed by "ase agent editor"), answering
|
|
641
|
+
400 if the prompt is empty, 404 if the task is missing, and 409 if it is finished or
|
|
642
|
+
an edit of it is in progress (as checked atomically with the request) */
|
|
643
|
+
server.route({
|
|
644
|
+
method: "POST",
|
|
645
|
+
path: "/task-board/api/task/{id}/edit",
|
|
646
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
647
|
+
handler: guarded(async (request, h) => {
|
|
648
|
+
const id = String(request.params.id);
|
|
649
|
+
const p = request.payload;
|
|
650
|
+
if (!TaskFormat.TASK_ID_RE.test(id) || p === null || typeof p.prompt !== "string")
|
|
651
|
+
return h.response({ error: "invalid edit request" }).code(400);
|
|
652
|
+
try {
|
|
653
|
+
await requestEdit(log, id, p.prompt);
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
const code = !(err instanceof RequestError) || err.reason === "empty" ? 400 :
|
|
657
|
+
err.reason === "missing" ? 404 : 409;
|
|
658
|
+
return h.response({ error: err instanceof Error ? err.message : String(err) }).code(code);
|
|
659
|
+
}
|
|
660
|
+
return h.response({ ok: true });
|
|
661
|
+
})
|
|
662
|
+
});
|
|
663
|
+
/* request a task by a prompt under a request name (processed by a request agent of
|
|
664
|
+
a definition, resp. by "ase agent editor" for "edit"), answering 400 if the
|
|
665
|
+
request name is invalid or the prompt of an "edit" request is empty (as the prompts
|
|
666
|
+
of all other requests may be empty), 404 if the task is missing, and 409
|
|
667
|
+
if it is finished or a request of the name is in progress (as checked atomically
|
|
668
|
+
with the request) */
|
|
669
|
+
server.route({
|
|
670
|
+
method: "POST",
|
|
671
|
+
path: "/task-board/api/task/{id}/request",
|
|
672
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
673
|
+
handler: guarded(async (request, h) => {
|
|
674
|
+
const id = String(request.params.id);
|
|
675
|
+
const p = request.payload;
|
|
676
|
+
if (!TaskFormat.TASK_ID_RE.test(id) || p === null || typeof p.name !== "string" || typeof p.prompt !== "string")
|
|
677
|
+
return h.response({ error: "invalid request" }).code(400);
|
|
678
|
+
try {
|
|
679
|
+
await requestsOf(p.name).request(log, id, p.prompt);
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
const code = !(err instanceof RequestError) || err.reason === "empty" ? 400 :
|
|
683
|
+
err.reason === "missing" ? 404 : 409;
|
|
684
|
+
return h.response({ error: err instanceof Error ? err.message : String(err) }).code(code);
|
|
685
|
+
}
|
|
686
|
+
return h.response({ ok: true });
|
|
687
|
+
})
|
|
688
|
+
});
|
|
689
|
+
};
|
|
690
|
+
/* register the surface routes of the web board: view changes, surface toggles, keep-alive, and change events */
|
|
691
|
+
const registerSurfaceRoutes = (server, log) => {
|
|
692
|
+
/* set the shown view (lanes, graph, stats, agents, or flow) of the web surface, as the view of newly opened web boards */
|
|
693
|
+
server.route({
|
|
694
|
+
method: "POST",
|
|
695
|
+
path: "/task-board/api/view",
|
|
696
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
697
|
+
handler: guarded(async (request, h) => {
|
|
698
|
+
const p = request.payload;
|
|
699
|
+
if (p === null || (p.view !== "lanes" && p.view !== "graph" && p.view !== "stats" && p.view !== "agents" && p.view !== "flow"))
|
|
700
|
+
return h.response({ error: "invalid view request" }).code(400);
|
|
701
|
+
return h.response((await BoardState.setView("web", p.view)).web);
|
|
702
|
+
})
|
|
703
|
+
});
|
|
704
|
+
/* toggle a minimized lane, a collapsed phase, or the showing of task titles,
|
|
705
|
+
key hints, or standalone tasks of the web surface */
|
|
706
|
+
server.route({
|
|
707
|
+
method: "POST",
|
|
708
|
+
path: "/task-board/api/toggle",
|
|
709
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
710
|
+
handler: guarded(async (request, h) => {
|
|
711
|
+
const p = request.payload;
|
|
712
|
+
if (p !== null && (p.list === "titles" || p.list === "keys" || p.list === "standalone")) {
|
|
713
|
+
const surface = (await BoardState.toggleFlag("web", p.list)).web;
|
|
714
|
+
emit("surface", surface);
|
|
715
|
+
return h.response(surface);
|
|
716
|
+
}
|
|
717
|
+
if (p === null || (p.list !== "minimized" && p.list !== "collapsed")
|
|
718
|
+
|| !(typeof p.entry === "string" || (Array.isArray(p.entry) && p.entry.every((e) => typeof e === "string"))))
|
|
719
|
+
return h.response({ error: "invalid toggle request" }).code(400);
|
|
720
|
+
/* accept only lanes or groups of the current board layout */
|
|
721
|
+
const board = await currentBoard(log);
|
|
722
|
+
const names = p.list === "minimized" ?
|
|
723
|
+
board.groups.flatMap((g) => g.lanes.map((l) => l.status)) :
|
|
724
|
+
board.groups.map((g) => g.title);
|
|
725
|
+
const entries = p.entry;
|
|
726
|
+
const unknown = (Array.isArray(entries) ? entries : [entries]).find((e) => !names.includes(e));
|
|
727
|
+
if (unknown !== undefined)
|
|
728
|
+
return h.response({ error: `unknown ${p.list === "minimized" ? "lane" : "group"} "${unknown}"` }).code(400);
|
|
729
|
+
const surface = (await BoardState.toggle("web", p.list, entries)).web;
|
|
730
|
+
/* push the shared surface state into all open web boards */
|
|
731
|
+
emit("surface", surface);
|
|
732
|
+
return h.response(surface);
|
|
733
|
+
})
|
|
734
|
+
});
|
|
735
|
+
/* keep-alive of the service while a board page is open */
|
|
736
|
+
server.route({
|
|
737
|
+
method: "GET",
|
|
738
|
+
path: "/task-board/api/ping",
|
|
739
|
+
handler: (_request, h) => h.response({ ok: true, build: BOARD_BUILD })
|
|
740
|
+
});
|
|
741
|
+
/* the change event stream: the watcher (which also notices changes
|
|
742
|
+
of the lifecycle mode) and the agents watcher run only while at least one client
|
|
743
|
+
is connected, and every client gets the task store state and the agents on connect
|
|
744
|
+
and on each change */
|
|
745
|
+
server.route({
|
|
746
|
+
method: "GET",
|
|
747
|
+
path: "/task-board/events",
|
|
748
|
+
handler: (request, h) => {
|
|
749
|
+
const stream = new PassThrough();
|
|
750
|
+
const client = { stream, compressor: null };
|
|
751
|
+
/* hapi hands a (gzip/deflate) compressor to streams providing this hook, which
|
|
752
|
+
has to be flushed once attached, as it would else hold back the initial
|
|
753
|
+
writes (and hence the opening of the stream) until the first change event */
|
|
754
|
+
Object.assign(stream, {
|
|
755
|
+
setCompressor: (c) => {
|
|
756
|
+
client.compressor = c;
|
|
757
|
+
setImmediate(() => { c.flush(); });
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
/* release the client, and stop the watchers once no client is connected anymore */
|
|
761
|
+
const release = () => {
|
|
762
|
+
clients.delete(client);
|
|
763
|
+
stream.end();
|
|
764
|
+
if (clients.size === 0 && stopWatch !== null) {
|
|
765
|
+
stopWatch().catch((err) => {
|
|
766
|
+
log.write("warning", `board: stopping watcher failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
767
|
+
});
|
|
768
|
+
stopAgents?.();
|
|
769
|
+
if (agentsTimer !== null)
|
|
770
|
+
clearTimeout(agentsTimer);
|
|
771
|
+
stopWatch = null;
|
|
772
|
+
stopAgents = null;
|
|
773
|
+
agentsTimer = null;
|
|
774
|
+
storeState = null;
|
|
775
|
+
agentsState = null;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
clients.add(client);
|
|
779
|
+
if (stopWatch === null) {
|
|
780
|
+
/* a watcher failing to start (e.g. on a misconfigured task store)
|
|
781
|
+
must not leave the client registered (and the watchers running) */
|
|
782
|
+
try {
|
|
783
|
+
cached = null;
|
|
784
|
+
stopWatch = watchTasks(log, broadcast, (state) => {
|
|
785
|
+
storeState = state;
|
|
786
|
+
emit("store", state);
|
|
787
|
+
});
|
|
788
|
+
agentsPushed = 0;
|
|
789
|
+
pushedPresence = "";
|
|
790
|
+
pushedLogs = new Map();
|
|
791
|
+
stopAgents = watchAgents(log, undefined, scheduleAgents);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
release();
|
|
795
|
+
throw err;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
if (storeState !== null)
|
|
800
|
+
stream.write(`event: store\ndata: ${JSON.stringify(storeState)}\n\n`);
|
|
801
|
+
/* the agents once pushed (else the pending first push reaches this client, too) */
|
|
802
|
+
if (agentsPushed > 0) {
|
|
803
|
+
const { presence, logs } = pushedAgents();
|
|
804
|
+
stream.write(`event: agents\ndata: ${presence}\n\n`);
|
|
805
|
+
stream.write(`event: logs\ndata: ${JSON.stringify(logs)}\n\n`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
request.raw.res.on("close", release);
|
|
809
|
+
stream.write(": connected\n\n");
|
|
810
|
+
return h.response(stream)
|
|
811
|
+
.type("text/event-stream")
|
|
812
|
+
.header("Cache-Control", "no-cache")
|
|
813
|
+
.header("X-Accel-Buffering", "no");
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
};
|
|
817
|
+
/* register the board routes on the ASE service of the project */
|
|
818
|
+
export const registerBoardRoutes = (server, log) => {
|
|
819
|
+
registerPageRoutes(server, log);
|
|
820
|
+
registerViewRoutes(server, log);
|
|
821
|
+
registerUpdateRoutes(server, log);
|
|
822
|
+
registerSurfaceRoutes(server, log);
|
|
823
|
+
};
|