@rse/ase 1.0.7 → 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.
Files changed (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +228 -111
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -0,0 +1,804 @@
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 crypto from "node:crypto";
7
+ import { DateTime } from "luxon";
8
+ import * as TaskFormat from "./ase-task-format.js";
9
+ /* the RFC 9457 problem titles per status code */
10
+ export const problemTitles = {
11
+ 400: "Bad Request",
12
+ 401: "Unauthorized",
13
+ 404: "Not Found",
14
+ 409: "Conflict",
15
+ 412: "Precondition Failed",
16
+ 413: "Content Too Large",
17
+ 415: "Unsupported Media Type",
18
+ 422: "Unprocessable Content",
19
+ 426: "Upgrade Required",
20
+ 429: "Too Many Requests",
21
+ 500: "Internal Server Error"
22
+ };
23
+ /* an error carrying its HTTP status, rendered as RFC 9457 problem details */
24
+ export class Problem extends Error {
25
+ status;
26
+ constructor(status, detail) {
27
+ super(detail);
28
+ this.status = status;
29
+ }
30
+ }
31
+ /* create a problem and render its RFC 9457 problem details body */
32
+ export const problem = (status, detail) => new Problem(status, detail);
33
+ export const problemBody = (status, detail, instance) => ({
34
+ type: "about:blank",
35
+ title: problemTitles[status] ?? "Error",
36
+ status,
37
+ detail,
38
+ instance
39
+ });
40
+ /* the entity tag of a task plan (the SHA-1 of its text format, without its usage
41
+ attachments, so a booking does not count as a change), for detecting a change in the
42
+ meantime on a conditional save (which keeps the stored usage attachments), and the
43
+ entity tag of the complete task plan, for a conditional save of its usage attachments
44
+ by a booking (which therefore also detects a concurrent booking), where the latter
45
+ is the SHA-1 of a prefixed text format, so both never coincide (not even for a plan
46
+ without usage attachments), and only a booking can save usage attachments */
47
+ export const taskTag = (plan) => crypto.createHash("sha1").update(TaskFormat.formatTaskText(TaskFormat.withoutUsage(plan))).digest("hex");
48
+ export const taskTagFull = (plan) => crypto.createHash("sha1").update("usage\n" + TaskFormat.formatTaskText(plan)).digest("hex");
49
+ /* the plain-object check */
50
+ export const isObject = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
51
+ /* the key pattern of header and attachment keys, as required by the task plan text format */
52
+ const KEY_RE = /^[A-Za-z]+$/;
53
+ /* the transport-agnostic REST API functionality: validation, lifecycle
54
+ model checks, the part operations (as read-modify-write cycles on
55
+ entire plans), purge, and event notification, with all operations
56
+ of a project strictly serialized (hence not nestable),
57
+ delegating the persistence into the storage plugin behind the
58
+ "TaskStore"; every failure is a "Problem" carrying its HTTP status */
59
+ export class TaskStoreCore {
60
+ store;
61
+ listener;
62
+ /* the "Effort" keys bound by the header listings (see taskList), per task
63
+ plan, valid as long as the plan keeps its modification time and raw key */
64
+ efforts = new Map();
65
+ constructor(store, listener = () => { }) {
66
+ this.store = store;
67
+ this.listener = listener;
68
+ store.watch((prjId, change) => this.external(prjId, change));
69
+ }
70
+ /* emit the changes of task plans detected by the storage plugin itself (made
71
+ outside of this core), serialized with the operations of the project */
72
+ external(prjId, change) {
73
+ this.serialize(prjId, async () => {
74
+ const { lifecycle } = await this.project(prjId);
75
+ const entry = (e) => ({ status: TaskFormat.taskStatus(e.header, lifecycle), title: e.title });
76
+ const added = change.added ?? [];
77
+ const updated = change.updated ?? [];
78
+ const deleted = change.deleted ?? [];
79
+ this.listener(prjId, {
80
+ ...(added.length > 0 ? { added: Object.fromEntries(added.map((e) => [e.id, entry(e)])) } : {}),
81
+ ...(updated.length > 0 ? { updated: Object.fromEntries(updated.map((e) => [e.id, { ...entry(e), parts: ["header", "body", "attachment"] }])) } : {}),
82
+ ...(deleted.length > 0 ? { deleted } : {})
83
+ });
84
+ }).catch(() => {
85
+ /* a project unregistered meanwhile has nothing to notify */
86
+ });
87
+ }
88
+ /* ==== validation helpers ==== */
89
+ /* validate a project or task id */
90
+ validateId(kind, id) {
91
+ if (!(kind === "task" ? TaskFormat.TASK_ID_RE : TaskFormat.ID_RE).test(id))
92
+ throw problem(422, `invalid ${kind} id "${id}" (expected: [A-Za-z0-9${kind === "task" ? "#" : ""}_-]+)`);
93
+ return id;
94
+ }
95
+ /* resolve a registered project, its lifecycle model, its task id scheme,
96
+ and the high-water mark of its sequence numbers */
97
+ async project(prjId) {
98
+ this.validateId("project", prjId);
99
+ const entry = await this.store.projectGet(prjId);
100
+ if (entry === null)
101
+ throw problem(404, `no project "${prjId}" registered`);
102
+ const lifecycle = Object.hasOwn(TaskFormat.taskLifecycles, entry.lifecycle) ? TaskFormat.taskLifecycles[entry.lifecycle] : undefined;
103
+ if (lifecycle === undefined)
104
+ throw problem(500, `project "${prjId}" carries unknown lifecycle model "${entry.lifecycle}"`);
105
+ const idscheme = entry.idscheme ?? "slug";
106
+ if (TaskFormat.checkIdScheme(idscheme) !== "")
107
+ throw problem(500, `project "${prjId}" carries invalid task id scheme "${idscheme}"`);
108
+ return { id: entry.id, lifecycle, idscheme, seqmark: entry.seqmark ?? 0 };
109
+ }
110
+ /* raise the high-water mark of the sequence numbers to the highest one of removed
111
+ or allocated task ids, so scheme "seq" never hands out their numbers again */
112
+ async retire(prjId, project, ids) {
113
+ const scheme = TaskFormat.parseIdScheme(project.idscheme);
114
+ const seqmark = ids.reduce((max, id) => Math.max(max, TaskFormat.seqNumber(scheme, id)), project.seqmark);
115
+ if (seqmark > project.seqmark)
116
+ await this.store.projectMark(prjId, seqmark);
117
+ }
118
+ /* load a task plan through the storage plugin, with its "Effort" key bound to its
119
+ body (see bindEffort), so a plan changed outside of this core (like an issue edited
120
+ in the issue tracker) never carries a stale effort estimation */
121
+ async load(prjId, taskId) {
122
+ const plan = await this.store.taskLoad(prjId, taskId);
123
+ return plan !== null ? TaskFormat.bindEffort(plan) : null;
124
+ }
125
+ /* the header of a listed task plan with its "Effort" key bound to its body,
126
+ loading the plan only if its modification time or its raw key changed
127
+ since the last listing, so repeated listings stay cheap for issue trackers */
128
+ async boundHeader(prjId, entry) {
129
+ if (entry.header.Effort === undefined)
130
+ return entry.header;
131
+ const key = `${prjId}/${entry.id}`;
132
+ const mtime = entry.mtime.getTime();
133
+ const raw = String(entry.header.Effort);
134
+ const memo = this.efforts.get(key);
135
+ let effort;
136
+ if (memo !== undefined && memo.mtime === mtime && memo.raw === raw)
137
+ effort = memo.effort;
138
+ else {
139
+ effort = (await this.load(prjId, entry.id))?.header.Effort;
140
+ this.efforts.set(key, { mtime, raw, effort });
141
+ }
142
+ const header = { ...entry.header };
143
+ if (effort !== undefined)
144
+ header.Effort = effort;
145
+ else
146
+ Reflect.deleteProperty(header, "Effort");
147
+ return header;
148
+ }
149
+ /* load an existing task plan of a registered project */
150
+ async plan(prjId, taskId) {
151
+ const { lifecycle } = await this.project(prjId);
152
+ this.validateId("task", taskId);
153
+ const plan = await this.load(prjId, taskId);
154
+ if (plan === null)
155
+ throw problem(404, `no task "${taskId}" in project "${prjId}"`);
156
+ return { lifecycle, plan };
157
+ }
158
+ /* run an operation of a project strictly serialized, and load
159
+ an existing task plan as such an operation of its own */
160
+ serialize(prjId, op) {
161
+ this.validateId("project", prjId);
162
+ return this.store.serialize(prjId, op);
163
+ }
164
+ read(prjId, taskId) {
165
+ return this.serialize(prjId, () => this.plan(prjId, taskId));
166
+ }
167
+ /* validate a header value against the type defined for its key, normalized
168
+ like the task plan text does (values trimmed, empty array items dropped) */
169
+ validateHeaderValue(key, value) {
170
+ if (!KEY_RE.test(key))
171
+ throw problem(422, `invalid header key "${key}" (expected: [A-Za-z]+)`);
172
+ if (TaskFormat.arrayKeys.includes(key)) {
173
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && !/[\r\n,]/.test(item)))
174
+ throw problem(422, `header key "${key}" requires an array of single-line strings without commas`);
175
+ return value.map((item) => item.trim()).filter((item) => item !== "");
176
+ }
177
+ if (typeof value !== "string" || /[\r\n]/.test(value))
178
+ throw problem(422, `header key "${key}" requires a single-line string value`);
179
+ if (key === "Effort" && value.trim() !== "" && TaskFormat.parseEffort(value) === null)
180
+ throw problem(422, "header key \"Effort\" requires a value \"<size> [<hash>]\" " +
181
+ `(with size one of: ${TaskFormat.effortSizes.join(", ")})`);
182
+ return value.trim();
183
+ }
184
+ /* validate an entire header: the fixed "Type" and "Id" keys are
185
+ filled in if absent and rejected if mismatching, every other key
186
+ is checked against its type, and "Status" is upper-cased */
187
+ validateHeader(taskId, raw) {
188
+ if (!isObject(raw))
189
+ throw problem(400, "\"header\" has to be a key/value object");
190
+ const header = {};
191
+ for (const [key, value] of Object.entries(raw))
192
+ header[key] = this.validateHeaderValue(key, value);
193
+ if (header.Type === undefined)
194
+ header.Type = TaskFormat.TASK_TYPE;
195
+ else if (header.Type !== TaskFormat.TASK_TYPE)
196
+ throw problem(422, `header key "Type" has to be "${TaskFormat.TASK_TYPE}"`);
197
+ if (header.Id === undefined)
198
+ header.Id = taskId;
199
+ else if (header.Id !== taskId)
200
+ throw problem(422, `header key "Id" has to be "${taskId}"`);
201
+ if (typeof header.Status === "string")
202
+ header.Status = header.Status.trim().toUpperCase();
203
+ return header;
204
+ }
205
+ /* validate a body: a string surviving a round-trip through the task plan text,
206
+ normalized like the task plan text does (LF line endings, exactly one trailing newline) */
207
+ validateBody(raw) {
208
+ if (typeof raw !== "string")
209
+ throw problem(400, "\"body\" has to be a string");
210
+ const conflict = TaskFormat.bodyConflict(raw);
211
+ if (conflict !== null)
212
+ throw problem(422, `invalid "body": ${conflict}`);
213
+ const body = raw.replace(/\r\n/g, "\n").replace(/\n+$/, "");
214
+ return body !== "" ? `${body}\n` : "";
215
+ }
216
+ /* validate an attachment: a flat string-valued object with a "Type"
217
+ other than the task type and exactly one of "Data" or "File",
218
+ normalized like the task plan text does ("Data" with LF line endings,
219
+ all other values trimmed) */
220
+ validateAttachment(raw) {
221
+ if (!isObject(raw))
222
+ throw problem(422, "attachment has to be a key/value object");
223
+ const attachment = {};
224
+ for (const [key, value] of Object.entries(raw)) {
225
+ if (!KEY_RE.test(key))
226
+ throw problem(422, `invalid attachment key "${key}" (expected: [A-Za-z]+)`);
227
+ if (typeof value !== "string" || (key !== "Data" && /[\r\n]/.test(value)))
228
+ throw problem(422, `attachment key "${key}" requires a${key !== "Data" ? " single-line" : ""} string value`);
229
+ attachment[key] = key === "Data" ? value.replace(/\r\n/g, "\n") : value.trim();
230
+ }
231
+ if (attachment.Type === undefined || attachment.Type.trim() === "")
232
+ throw problem(422, "attachment requires a \"Type\" key");
233
+ if (attachment.Type.trim().toLowerCase() === TaskFormat.TASK_TYPE)
234
+ throw problem(422, `attachment key "Type" must not be "${TaskFormat.TASK_TYPE}"`);
235
+ if ((attachment.Data === undefined) === (attachment.File === undefined))
236
+ throw problem(422, "attachment requires exactly one of the keys \"Data\" or \"File\"");
237
+ return attachment;
238
+ }
239
+ /* reject a change of the usage attachment, which only a booking may change */
240
+ guardUsage(attachment) {
241
+ if (TaskFormat.isUsage(attachment))
242
+ throw problem(422, "the usage attachment of a task plan can be changed by a booking only");
243
+ }
244
+ /* validate an entire task plan structure */
245
+ validatePlan(taskId, raw) {
246
+ if (!isObject(raw))
247
+ throw problem(400, "request body has to be a task plan object");
248
+ if (raw.header === undefined)
249
+ throw problem(400, "task plan lacks the \"header\" field");
250
+ if (raw.body === undefined)
251
+ throw problem(400, "task plan lacks the \"body\" field");
252
+ let attachment = [];
253
+ if (raw.attachment !== undefined) {
254
+ if (!Array.isArray(raw.attachment))
255
+ throw problem(422, "\"attachment\" has to be an array");
256
+ attachment = raw.attachment.map((item) => this.validateAttachment(item));
257
+ }
258
+ return {
259
+ header: this.validateHeader(taskId, raw.header),
260
+ body: this.validateBody(raw.body),
261
+ attachment
262
+ };
263
+ }
264
+ /* validate an attachment index */
265
+ validateIndex(plan, raw) {
266
+ if (!/^\d+$/.test(raw))
267
+ throw problem(422, `invalid attachment index "${raw}" (expected: a non-negative integer)`);
268
+ const index = Number.parseInt(raw, 10);
269
+ if (index >= plan.attachment.length)
270
+ throw problem(404, `no attachment at index ${index}`);
271
+ return index;
272
+ }
273
+ /* resolve a "status" input against the lifecycle model: a state
274
+ of the model (case-insensitively), else a 422 problem */
275
+ validateState(lifecycle, raw) {
276
+ if (typeof raw !== "string")
277
+ throw problem(400, "\"status\" has to be a string");
278
+ const state = raw.trim().toUpperCase();
279
+ if (!lifecycle.states.includes(state))
280
+ throw problem(422, `invalid status "${raw}" ` +
281
+ `(expected one of: ${lifecycle.states.join(", ")})`);
282
+ return state;
283
+ }
284
+ /* check a status change against the lifecycle model, else a 422 problem */
285
+ checkStatus(lifecycle, from, to) {
286
+ const error = TaskFormat.checkStatus(lifecycle, from, to);
287
+ if (error !== "")
288
+ throw problem(422, error);
289
+ }
290
+ /* parse the "include"/"exclude" lists into the effective state set
291
+ a task plan has to be in to be listed */
292
+ states(lifecycle, include, exclude) {
293
+ try {
294
+ return TaskFormat.resolveStates(lifecycle, include, exclude);
295
+ }
296
+ catch (err) {
297
+ throw problem(422, err instanceof Error ? err.message : String(err));
298
+ }
299
+ }
300
+ /* the event entry of a plan and the "updated" event emission */
301
+ entry(plan, lifecycle) {
302
+ return { status: TaskFormat.taskStatus(plan.header, lifecycle), title: TaskFormat.taskTitle(plan.body) };
303
+ }
304
+ emitUpdated(prjId, taskId, plan, lifecycle, parts) {
305
+ this.listener(prjId, { updated: { [taskId]: { ...this.entry(plan, lifecycle), parts } } });
306
+ }
307
+ /* run a read-modify-write cycle on an existing plan as a serialized operation,
308
+ re-binding its "Effort" key to its possibly changed body before saving */
309
+ async modify(prjId, taskId, part, op) {
310
+ return this.serialize(prjId, async () => {
311
+ const { lifecycle, plan } = await this.plan(prjId, taskId);
312
+ const result = op(plan, lifecycle);
313
+ TaskFormat.bindEffort(plan);
314
+ await this.store.taskSave(prjId, taskId, plan);
315
+ this.emitUpdated(prjId, taskId, plan, lifecycle, [part]);
316
+ return result;
317
+ });
318
+ }
319
+ /* ==== project operations ==== */
320
+ async projectList() {
321
+ const projects = await this.store.projectList();
322
+ projects.sort((a, b) => a.id.localeCompare(b.id));
323
+ return projects;
324
+ }
325
+ async projectGet(prjId) {
326
+ return this.serialize(prjId, () => this.project(prjId));
327
+ }
328
+ async projectExists(prjId) {
329
+ return TaskFormat.ID_RE.test(prjId) && await this.serialize(prjId, () => this.store.projectGet(prjId)) !== null;
330
+ }
331
+ /* register a project or change its lifecycle model and/or task id scheme
332
+ (without one, a registered project keeps its model resp. scheme and a new
333
+ one gets "solo" resp. "slug"), mapping the status of its plans onto a
334
+ changed model; in "createOnly" mode an already registered project is
335
+ left alone and reported as 412 */
336
+ async projectSet(prjId, raw, createOnly = false) {
337
+ this.validateId("project", prjId);
338
+ if (raw.lifecycle !== undefined && typeof raw.lifecycle !== "string")
339
+ throw problem(400, "\"lifecycle\" has to be a string");
340
+ if (raw.idscheme !== undefined && typeof raw.idscheme !== "string")
341
+ throw problem(400, "\"idscheme\" has to be a string");
342
+ const rawLifecycle = raw.lifecycle;
343
+ const rawIdscheme = raw.idscheme;
344
+ return this.serialize(prjId, async () => {
345
+ const entry = await this.store.projectGet(prjId);
346
+ if (createOnly && entry !== null)
347
+ throw problem(412, `project "${prjId}" already registered`);
348
+ const name = rawLifecycle ?? entry?.lifecycle ?? "solo";
349
+ const lifecycle = Object.hasOwn(TaskFormat.taskLifecycles, name) ? TaskFormat.taskLifecycles[name] : undefined;
350
+ if (lifecycle === undefined)
351
+ throw problem(422, `unknown lifecycle model "${name}" ` +
352
+ `(expected one of: ${Object.keys(TaskFormat.taskLifecycles).join(", ")})`);
353
+ const idscheme = rawIdscheme ?? entry?.idscheme ?? "slug";
354
+ const error = TaskFormat.checkIdScheme(idscheme);
355
+ if (error !== "")
356
+ throw problem(422, error);
357
+ /* map the status of the plans before switching the model, so an
358
+ interrupted switch can be repeated (mapped states being foreign
359
+ to the old model are kept) */
360
+ const prev = entry !== null && entry.lifecycle !== name && Object.hasOwn(TaskFormat.taskLifecycles, entry.lifecycle) ?
361
+ TaskFormat.taskLifecycles[entry.lifecycle] : undefined;
362
+ if (prev !== undefined) {
363
+ for (const { id, header } of await this.store.taskList(prjId)) {
364
+ if (typeof header.Status !== "string" || header.Status.trim() === "")
365
+ continue;
366
+ const from = TaskFormat.taskStatus(header, prev);
367
+ const to = TaskFormat.mapStatus(prev, lifecycle, from);
368
+ if (to === from)
369
+ continue;
370
+ const plan = await this.load(prjId, id);
371
+ if (plan === null)
372
+ continue;
373
+ plan.header.Status = to;
374
+ await this.store.taskSave(prjId, id, plan);
375
+ this.emitUpdated(prjId, id, plan, lifecycle, ["header"]);
376
+ }
377
+ }
378
+ const result = await this.store.projectSet(prjId, name, idscheme);
379
+ if (entry !== null && (entry.lifecycle !== name || (entry.idscheme ?? "slug") !== idscheme))
380
+ this.listener(prjId, {
381
+ ...(entry.lifecycle !== name ? { lifecycle: name } : {}),
382
+ ...((entry.idscheme ?? "slug") !== idscheme ? { idscheme } : {})
383
+ });
384
+ return { created: result === "created", project: { id: prjId, lifecycle, idscheme, seqmark: entry?.seqmark ?? 0 } };
385
+ });
386
+ }
387
+ async projectDelete(prjId) {
388
+ if (!await this.serialize(prjId, () => this.store.projectDelete(prjId)))
389
+ throw problem(404, `no project "${prjId}" registered`);
390
+ }
391
+ /* ==== task plan operations ==== */
392
+ /* list the task plans passing the "include"/"exclude" state filter,
393
+ optionally with their entire header (loading each plan carrying an
394
+ "Effort" key changed since the last listing, to bind it to its body
395
+ (see boundHeader)), resp. with the AI usage accounting
396
+ of their usage attachment and its period (loading each plan) */
397
+ async taskList(prjId, include = "none", exclude = "none", fields = "none") {
398
+ return this.serialize(prjId, async () => {
399
+ const { lifecycle } = await this.project(prjId);
400
+ const states = this.states(lifecycle, include, exclude);
401
+ if (fields !== "none" && fields !== "header" && fields !== "usage")
402
+ throw problem(422, `invalid "fields" value "${fields}" (expected: "header", "usage", or "none")`);
403
+ const entries = await this.store.taskList(prjId);
404
+ entries.sort((a, b) => TaskFormat.compareIds(a.id, b.id));
405
+ const result = [];
406
+ for (const entry of entries) {
407
+ const status = TaskFormat.taskStatus(entry.header, lifecycle);
408
+ if (lifecycle.states.includes(status) && !states.includes(status))
409
+ continue;
410
+ const att = fields === "usage" ? (await this.store.taskLoad(prjId, entry.id))?.attachment
411
+ .find((att) => TaskFormat.isUsage(att) && att.Data !== undefined) : undefined;
412
+ const header = fields === "header" ? await this.boundHeader(prjId, entry) : entry.header;
413
+ result.push({
414
+ id: entry.id,
415
+ status,
416
+ title: entry.title,
417
+ mtime: DateTime.fromJSDate(entry.mtime).toFormat("yyyy-LL-dd HH:mm"),
418
+ ...(fields === "header" ? { header } : {}),
419
+ ...(att?.Data !== undefined ? {
420
+ usage: TaskFormat.usageOf(att.Data),
421
+ usagePeriod: { created: att.Created ?? "", modified: att.Modified ?? "" }
422
+ } : {})
423
+ });
424
+ }
425
+ return result;
426
+ });
427
+ }
428
+ /* delete all task plans last modified longer ago than "age" (e.g. "31d") */
429
+ async taskPurge(prjId, age) {
430
+ return this.serialize(prjId, async () => {
431
+ const project = await this.project(prjId);
432
+ const m = age !== undefined ? /^(\d+)([hdmy])$/.exec(age) : null;
433
+ if (m === null)
434
+ throw problem(400, "missing or malformed \"age\" (expected: <number><unit> with unit h, d, m, or y)");
435
+ const hour = 60 * 60 * 1000;
436
+ const factors = { h: hour, d: 24 * hour, m: 30 * 24 * hour, y: 365 * 24 * hour };
437
+ const cutoff = Date.now() - Number.parseInt(m[1], 10) * factors[m[2]];
438
+ const purged = [];
439
+ for (const entry of await this.store.taskList(prjId))
440
+ if (entry.mtime.getTime() < cutoff)
441
+ if (await this.store.taskDelete(prjId, entry.id))
442
+ purged.push(entry.id);
443
+ await this.retire(prjId, project, purged);
444
+ if (purged.length > 0)
445
+ this.listener(prjId, { deleted: purged });
446
+ return purged;
447
+ });
448
+ }
449
+ async taskLoad(prjId, taskId) {
450
+ return (await this.read(prjId, taskId)).plan;
451
+ }
452
+ /* allocate the next free task id according to the task id scheme, derived
453
+ from a title (scheme "slug") or a proposed id (scheme "any"), considering
454
+ all existing ids plus the additionally taken ones; for scheme "seq" the
455
+ allocated number is reserved by raising the high-water mark, so a
456
+ concurrent allocation never yields the same id */
457
+ async taskNewId(prjId, raw) {
458
+ if (raw.title !== undefined && typeof raw.title !== "string")
459
+ throw problem(400, "\"title\" has to be a string");
460
+ if (raw.proposal !== undefined && typeof raw.proposal !== "string")
461
+ throw problem(400, "\"proposal\" has to be a string");
462
+ if (raw.taken !== undefined && !(Array.isArray(raw.taken) && raw.taken.every((id) => typeof id === "string")))
463
+ throw problem(400, "\"taken\" has to be an array of strings");
464
+ const title = (raw.title ?? "");
465
+ const proposal = (raw.proposal ?? "");
466
+ const taken = (raw.taken ?? []);
467
+ return this.serialize(prjId, async () => {
468
+ const project = await this.project(prjId);
469
+ const ids = [...(await this.store.taskList(prjId)).map((entry) => entry.id), ...taken];
470
+ let id;
471
+ try {
472
+ id = TaskFormat.nextTaskId(TaskFormat.parseIdScheme(project.idscheme), ids, title, proposal, project.seqmark);
473
+ }
474
+ catch (err) {
475
+ throw problem(422, err instanceof Error ? err.message : String(err));
476
+ }
477
+ await this.retire(prjId, project, [id]);
478
+ return { scheme: project.idscheme, id };
479
+ });
480
+ }
481
+ /* create or overwrite an entire task plan, rejecting an unreachable status,
482
+ on a conditional save (with the entity tag of the plan it is based
483
+ on) also rejecting a plan which was changed or deleted in the meantime,
484
+ and on a create-only save also rejecting an already existing plan,
485
+ where an overwrite keeps the stored usage attachments, unless it is
486
+ conditional on the entity tag of the complete plan (i.e., a booking),
487
+ and a create drops any usage attachments, and where the "Effort" key is
488
+ bound to the body (see bindEffort) before determining any entity tag */
489
+ async taskSave(prjId, taskId, raw, ifMatch, createOnly = false) {
490
+ return this.serialize(prjId, async () => {
491
+ const { lifecycle } = await this.project(prjId);
492
+ this.validateId("task", taskId);
493
+ const prev = await this.load(prjId, taskId);
494
+ const full = ifMatch !== undefined && prev !== null && taskTagFull(prev) === ifMatch;
495
+ if (ifMatch !== undefined && !full && (prev === null || taskTag(prev) !== ifMatch))
496
+ throw problem(412, `task "${taskId}" was ${prev === null ? "deleted" : "changed"} meanwhile in project "${prjId}"`);
497
+ if (createOnly && prev !== null)
498
+ throw problem(412, `task "${taskId}" already exists in project "${prjId}"`);
499
+ let plan = TaskFormat.bindEffort(this.validatePlan(taskId, raw));
500
+ if (!full)
501
+ plan = prev !== null ? TaskFormat.keepUsage(plan, prev) : TaskFormat.withoutUsage(plan);
502
+ const from = prev !== null ? TaskFormat.taskStatus(prev.header, lifecycle) : lifecycle.initial;
503
+ const status = TaskFormat.taskStatus(plan.header, lifecycle);
504
+ this.checkStatus(lifecycle, from, status);
505
+ const result = await this.store.taskSave(prjId, taskId, plan);
506
+ if (result === "created")
507
+ this.listener(prjId, { added: { [taskId]: this.entry(plan, lifecycle) } });
508
+ else
509
+ this.emitUpdated(prjId, taskId, plan, lifecycle, ["header", "body", "attachment"]);
510
+ /* determine the entity tag of the just-saved plan (as stored, since a store
511
+ not storing it verbatim may normalize it, e.g. an issue tracker) still
512
+ within the serialized operation, so it can never be the one of a subsequent
513
+ save of somebody else, and so any save (conditional or not) can be the
514
+ base of a next conditional save */
515
+ const saved = this.store.verbatim ? plan : await this.load(prjId, taskId);
516
+ return { created: result === "created", id: taskId, status, ...(saved !== null ? { tag: taskTag(saved) } : {}) };
517
+ });
518
+ }
519
+ /* change the status of a task plan and/or rename it to a new id */
520
+ async taskPatch(prjId, taskId, raw) {
521
+ return this.serialize(prjId, async () => {
522
+ const project = await this.project(prjId);
523
+ const lifecycle = project.lifecycle;
524
+ this.validateId("task", taskId);
525
+ if (!isObject(raw))
526
+ throw problem(400, "request body has to be an object");
527
+ if (raw.status === undefined && raw.id === undefined)
528
+ throw problem(400, "request body requires at least one of the fields \"status\" or \"id\"");
529
+ const status = raw.status !== undefined ? this.validateState(lifecycle, raw.status) : null;
530
+ let newId = null;
531
+ if (raw.id !== undefined) {
532
+ if (typeof raw.id !== "string")
533
+ throw problem(400, "\"id\" has to be a string");
534
+ this.validateId("task", raw.id);
535
+ if (raw.id !== taskId)
536
+ newId = raw.id;
537
+ }
538
+ const plan = await this.load(prjId, taskId);
539
+ if (plan === null)
540
+ throw problem(404, `no task "${taskId}" in project "${prjId}"`);
541
+ const from = TaskFormat.taskStatus(plan.header, lifecycle);
542
+ if (status !== null) {
543
+ this.checkStatus(lifecycle, from, status);
544
+ plan.header.Status = status;
545
+ }
546
+ if (newId !== null) {
547
+ if (await this.store.taskLoad(prjId, newId) !== null)
548
+ throw problem(409, `task "${newId}" already exists in project "${prjId}"`);
549
+ /* determine whether the plan is an explicit epic (its "Group" equals
550
+ its old "Id", or already its new one, as an edit of the task text
551
+ changes both and saves the text before renaming), as only then the
552
+ group name follows the rename, too, while otherwise the group names
553
+ are a namespace of their own */
554
+ const epic = plan.header.Group === taskId || plan.header.Group === newId;
555
+ /* determine all other plans referencing the old id in their "After"
556
+ keys or, for an explicit epic, its members referencing the old id
557
+ in their "Group" keys, rewritten to the new id (header only, so
558
+ their "Modified" key stays untouched) */
559
+ const referrers = [];
560
+ for (const { id } of await this.store.taskList(prjId)) {
561
+ if (id === taskId)
562
+ continue;
563
+ const other = await this.load(prjId, id);
564
+ if (other === null)
565
+ continue;
566
+ const { Group: group, After: after } = other.header;
567
+ const inGroup = epic && group === taskId;
568
+ const inAfter = Array.isArray(after) && after.includes(taskId);
569
+ if (!inGroup && !inAfter)
570
+ continue;
571
+ if (inGroup)
572
+ other.header.Group = newId;
573
+ if (inAfter)
574
+ other.header.After = after.map((ref) => ref === taskId ? newId : ref);
575
+ referrers.push({ id, plan: other });
576
+ }
577
+ /* always rename (atomically) first and only then persist a status
578
+ change or the group of an explicit epic under the new id, so a
579
+ crash never leaves both ids behind */
580
+ plan.header.Id = newId;
581
+ if (epic)
582
+ plan.header.Group = newId;
583
+ if (!await this.store.taskRename(prjId, taskId, newId))
584
+ throw problem(404, `no task "${taskId}" in project "${prjId}"`);
585
+ if (status !== null || epic)
586
+ await this.store.taskSave(prjId, newId, plan);
587
+ await this.retire(prjId, project, [taskId]);
588
+ this.listener(prjId, { deleted: [taskId], added: { [newId]: this.entry(plan, lifecycle) } });
589
+ /* rewrite the references, failing clearly with the plans left behind */
590
+ for (let i = 0; i < referrers.length; i++) {
591
+ try {
592
+ await this.store.taskSave(prjId, referrers[i].id, referrers[i].plan);
593
+ }
594
+ catch (err) {
595
+ const pending = referrers.slice(i).map((referrer) => `"${referrer.id}"`).join(", ");
596
+ throw problem(500, `task "${taskId}" renamed to "${newId}", but failed to update ` +
597
+ `the references of ${pending}: ${err instanceof Error ? err.message : String(err)}`);
598
+ }
599
+ this.emitUpdated(prjId, referrers[i].id, referrers[i].plan, lifecycle, ["header"]);
600
+ }
601
+ return {
602
+ id: newId,
603
+ status: TaskFormat.taskStatus(plan.header, lifecycle),
604
+ ...(status !== null ? { from } : {}),
605
+ references: referrers.length
606
+ };
607
+ }
608
+ else {
609
+ await this.store.taskSave(prjId, taskId, plan);
610
+ this.emitUpdated(prjId, taskId, plan, lifecycle, ["header"]);
611
+ }
612
+ return {
613
+ id: newId ?? taskId,
614
+ status: TaskFormat.taskStatus(plan.header, lifecycle),
615
+ ...(status !== null ? { from } : {})
616
+ };
617
+ });
618
+ }
619
+ async taskDelete(prjId, taskId) {
620
+ await this.serialize(prjId, async () => {
621
+ const project = await this.project(prjId);
622
+ this.validateId("task", taskId);
623
+ if (!await this.store.taskDelete(prjId, taskId))
624
+ throw problem(404, `no task "${taskId}" in project "${prjId}"`);
625
+ await this.retire(prjId, project, [taskId]);
626
+ this.listener(prjId, { deleted: [taskId] });
627
+ });
628
+ }
629
+ /* ==== header part operations ==== */
630
+ async headerGet(prjId, taskId) {
631
+ return (await this.read(prjId, taskId)).plan.header;
632
+ }
633
+ /* get the entire header in its textual form of the task plan text */
634
+ async headerContent(prjId, taskId) {
635
+ return TaskFormat.formatTaskHeader((await this.read(prjId, taskId)).plan.header);
636
+ }
637
+ async headerSet(prjId, taskId, raw) {
638
+ return this.modify(prjId, taskId, "header", (plan, lifecycle) => {
639
+ const header = this.validateHeader(taskId, raw);
640
+ const from = TaskFormat.taskStatus(plan.header, lifecycle);
641
+ const status = TaskFormat.taskStatus(header, lifecycle);
642
+ this.checkStatus(lifecycle, from, status);
643
+ plan.header = header;
644
+ return { created: false, id: taskId, status };
645
+ });
646
+ }
647
+ async headerKeyGet(prjId, taskId, key) {
648
+ const { plan } = await this.read(prjId, taskId);
649
+ if (!Object.hasOwn(plan.header, key))
650
+ throw problem(404, `no header key "${key}"`);
651
+ return plan.header[key];
652
+ }
653
+ async headerKeySet(prjId, taskId, key, raw) {
654
+ if (!isObject(raw) || raw.value === undefined)
655
+ throw problem(400, "request body requires the field \"value\"");
656
+ return this.modify(prjId, taskId, "header", (plan, lifecycle) => {
657
+ let value = this.validateHeaderValue(key, raw.value);
658
+ if (key === "Type" && value !== TaskFormat.TASK_TYPE)
659
+ throw problem(422, `header key "Type" has to be "${TaskFormat.TASK_TYPE}"`);
660
+ else if (key === "Id" && value !== taskId)
661
+ throw problem(422, `header key "Id" has to be "${taskId}"`);
662
+ else if (key === "Status") {
663
+ value = this.validateState(lifecycle, value);
664
+ this.checkStatus(lifecycle, TaskFormat.taskStatus(plan.header, lifecycle), value);
665
+ }
666
+ const created = !Object.hasOwn(plan.header, key);
667
+ plan.header[key] = value;
668
+ return { created, key, value };
669
+ });
670
+ }
671
+ async headerKeyDelete(prjId, taskId, key) {
672
+ if (key === "Type" || key === "Id")
673
+ throw problem(422, `header key "${key}" cannot be deleted`);
674
+ await this.modify(prjId, taskId, "header", (plan, lifecycle) => {
675
+ if (!Object.hasOwn(plan.header, key))
676
+ throw problem(404, `no header key "${key}"`);
677
+ /* deleting "Status" implicitly changes the status to the initial state */
678
+ if (key === "Status")
679
+ this.checkStatus(lifecycle, TaskFormat.taskStatus(plan.header, lifecycle), lifecycle.initial);
680
+ Reflect.deleteProperty(plan.header, key);
681
+ });
682
+ }
683
+ /* ==== body part operations ==== */
684
+ async bodyGet(prjId, taskId) {
685
+ return (await this.read(prjId, taskId)).plan.body;
686
+ }
687
+ async bodySet(prjId, taskId, raw) {
688
+ if (!isObject(raw) || typeof raw.body !== "string")
689
+ throw problem(400, "request body requires the string field \"body\"");
690
+ const body = this.validateBody(raw.body);
691
+ await this.modify(prjId, taskId, "body", (plan) => {
692
+ plan.body = body;
693
+ });
694
+ }
695
+ /* ==== attachment part operations ==== */
696
+ async attachmentList(prjId, taskId) {
697
+ return (await this.read(prjId, taskId)).plan.attachment;
698
+ }
699
+ /* find the attachments of a MIME type, matching its parameters only if "type" carries any */
700
+ async attachmentFind(prjId, taskId, type) {
701
+ const { plan } = await this.read(prjId, taskId);
702
+ const wanted = type.trim().toLowerCase();
703
+ const exact = wanted.includes(";");
704
+ return plan.attachment
705
+ .map((attachment, index) => ({ index, attachment }))
706
+ .filter(({ attachment }) => {
707
+ const actual = (attachment.Type ?? "").trim().toLowerCase();
708
+ return exact ? actual === wanted : actual.split(";")[0].trim() === wanted;
709
+ });
710
+ }
711
+ /* add an attachment, or on "append" extend the "Data" of the last attachment
712
+ with the same "Type" by its "Data" (taking over all its other keys except
713
+ "Created"); returns the index and whether the attachment was newly created */
714
+ async attachmentAdd(prjId, taskId, raw, append = false) {
715
+ const attachment = this.validateAttachment(raw);
716
+ this.guardUsage(attachment);
717
+ return this.modify(prjId, taskId, "attachment", (plan) => {
718
+ const index = append && attachment.Data !== undefined ?
719
+ plan.attachment.findLastIndex((a) => a.Type === attachment.Type && a.Data !== undefined) : -1;
720
+ if (index < 0)
721
+ return { index: plan.attachment.push(attachment) - 1, created: true };
722
+ const existing = plan.attachment[index];
723
+ plan.attachment[index] = {
724
+ ...existing, ...attachment,
725
+ ...(existing.Created !== undefined ? { Created: existing.Created } : {}),
726
+ Data: existing.Data + attachment.Data
727
+ };
728
+ return { index, created: false };
729
+ });
730
+ }
731
+ async attachmentGet(prjId, taskId, index) {
732
+ const { plan } = await this.read(prjId, taskId);
733
+ return plan.attachment[this.validateIndex(plan, index)];
734
+ }
735
+ /* get the raw content of an attachment: its embedded "Data", or the
736
+ content of its "File" as read through the storage plugin */
737
+ async attachmentContent(prjId, taskId, index) {
738
+ return this.serialize(prjId, async () => {
739
+ const { plan } = await this.plan(prjId, taskId);
740
+ const attachment = plan.attachment[this.validateIndex(plan, index)];
741
+ if (attachment.Data !== undefined)
742
+ return { type: attachment.Type, content: Buffer.from(attachment.Data, "utf8") };
743
+ if (!this.store.canReadFiles)
744
+ throw problem(404, `storage plugin "${this.store.name}" provides no referenced file content`);
745
+ const content = await this.store.fileRead(prjId, attachment.File);
746
+ if (content === null)
747
+ throw problem(404, `no referenced file "${attachment.File}"`);
748
+ return { type: attachment.Type, content };
749
+ });
750
+ }
751
+ async attachmentSet(prjId, taskId, index, raw) {
752
+ const attachment = this.validateAttachment(raw);
753
+ this.guardUsage(attachment);
754
+ await this.modify(prjId, taskId, "attachment", (plan) => {
755
+ const i = this.validateIndex(plan, index);
756
+ this.guardUsage(plan.attachment[i]);
757
+ plan.attachment[i] = attachment;
758
+ });
759
+ }
760
+ async attachmentDelete(prjId, taskId, index) {
761
+ await this.modify(prjId, taskId, "attachment", (plan) => {
762
+ const i = this.validateIndex(plan, index);
763
+ this.guardUsage(plan.attachment[i]);
764
+ plan.attachment.splice(i, 1);
765
+ });
766
+ }
767
+ async attachmentKeyGet(prjId, taskId, index, key) {
768
+ const { plan } = await this.read(prjId, taskId);
769
+ const attachment = plan.attachment[this.validateIndex(plan, index)];
770
+ if (!Object.hasOwn(attachment, key))
771
+ throw problem(404, `no attachment key "${key}"`);
772
+ return attachment[key];
773
+ }
774
+ /* set an attachment key, where setting "Data" or "File" drops the other one */
775
+ async attachmentKeySet(prjId, taskId, index, key, raw) {
776
+ if (!isObject(raw) || typeof raw.value !== "string")
777
+ throw problem(400, "request body requires the string field \"value\"");
778
+ const value = raw.value;
779
+ return this.modify(prjId, taskId, "attachment", (plan) => {
780
+ const i = this.validateIndex(plan, index);
781
+ const created = !Object.hasOwn(plan.attachment[i], key);
782
+ const attachment = { ...plan.attachment[i], [key]: value };
783
+ if (key === "Data")
784
+ delete attachment.File;
785
+ else if (key === "File")
786
+ delete attachment.Data;
787
+ this.guardUsage(plan.attachment[i]);
788
+ this.guardUsage(attachment);
789
+ plan.attachment[i] = this.validateAttachment(attachment);
790
+ return { created, key, value: plan.attachment[i][key] };
791
+ });
792
+ }
793
+ async attachmentKeyDelete(prjId, taskId, index, key) {
794
+ if (key === "Type" || key === "Data" || key === "File")
795
+ throw problem(422, `attachment key "${key}" cannot be deleted (delete the entire attachment instead)`);
796
+ await this.modify(prjId, taskId, "attachment", (plan) => {
797
+ const attachment = plan.attachment[this.validateIndex(plan, index)];
798
+ this.guardUsage(attachment);
799
+ if (!Object.hasOwn(attachment, key))
800
+ throw problem(404, `no attachment key "${key}"`);
801
+ Reflect.deleteProperty(attachment, key);
802
+ });
803
+ }
804
+ }