@rse/ase 2.0.1 → 2.0.3

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 (155) hide show
  1. package/dst/ase-agent-definition-core.js +111 -87
  2. package/dst/ase-agent-fleet.js +1 -1
  3. package/dst/ase-agent-runtime-claim.js +5 -3
  4. package/dst/ase-agent-runtime-engine.js +1 -1
  5. package/dst/ase-agent-runtime-phase.js +1 -1
  6. package/dst/ase-agent-runtime-request.js +1 -1
  7. package/dst/ase-agent-runtime.js +3 -5
  8. package/dst/ase-artifact.js +3 -3
  9. package/dst/ase-config-core.js +2 -16
  10. package/dst/ase-config-schema.js +1 -3
  11. package/dst/ase-config.js +4 -1
  12. package/dst/ase-hook.js +14 -19
  13. package/dst/ase-lib-daemon-cli.js +117 -4
  14. package/dst/ase-lib-server-cli.js +10 -10
  15. package/dst/ase-mcp.js +4 -4
  16. package/dst/ase-service-getopt.js +2 -2
  17. package/dst/ase-service-otel.js +4 -4
  18. package/dst/ase-service-skills.js +5 -15
  19. package/dst/ase-service.js +87 -210
  20. package/dst/ase-setup-common.js +1 -2
  21. package/dst/ase-setup-settings.js +6 -9
  22. package/dst/ase-setup.js +2 -3
  23. package/dst/ase-statusline.js +170 -170
  24. package/dst/ase-task-board-core.js +1 -1
  25. package/dst/ase-task-board-flow.js +7 -6
  26. package/dst/ase-task-board-graph.js +5 -4
  27. package/dst/ase-task-board-tui-control.js +51 -45
  28. package/dst/ase-task-board-tui-model.js +195 -181
  29. package/dst/ase-task-board-tui-popup.js +2 -3
  30. package/dst/ase-task-board-tui-view.js +88 -79
  31. package/dst/ase-task-board-web-client.css +1 -1
  32. package/dst/ase-task-board-web-client.js +2 -2
  33. package/dst/ase-task-board-web-server.js +4 -27
  34. package/dst/ase-task-board.js +1 -1
  35. package/dst/ase-task-format.js +1 -1
  36. package/dst/ase-task-store-client.js +17 -5
  37. package/dst/ase-task-store-core.js +5 -7
  38. package/dst/ase-task-store-issues.js +91 -0
  39. package/dst/ase-task-store-plugin-ase.js +7 -3
  40. package/dst/ase-task-store-plugin-gitea.js +11 -97
  41. package/dst/ase-task-store-plugin-github.js +5 -77
  42. package/dst/ase-task-store-plugin-gitlab.js +4 -83
  43. package/dst/ase-task.js +51 -23
  44. package/dst/ase-util-compat.js +1 -1
  45. package/dst/ase-util-diagram.js +16 -42
  46. package/dst/ase.js +5 -5
  47. package/dst/man/ase-agent-adhoc.1 +1 -1
  48. package/dst/man/ase-agent-condenser.1 +1 -1
  49. package/dst/man/ase-agent-editor.1 +1 -1
  50. package/dst/man/ase-agent-estimator.1 +1 -1
  51. package/dst/man/ase-agent-fleet.1 +1 -1
  52. package/dst/man/ase-agent-implementer.1 +1 -1
  53. package/dst/man/ase-agent-integrator.1 +1 -1
  54. package/dst/man/ase-agent-qualifier.1 +1 -1
  55. package/dst/man/ase-agent-rebooter.1 +1 -1
  56. package/dst/man/ase-agent-relay.1 +1 -1
  57. package/dst/man/ase-agent-researcher.1 +1 -1
  58. package/dst/man/ase-agent.1 +1 -1
  59. package/dst/man/ase-artifact-list.1 +1 -1
  60. package/dst/man/ase-artifact-name.1 +1 -1
  61. package/dst/man/ase-artifact.1 +1 -1
  62. package/dst/man/ase-config-delete.1 +1 -1
  63. package/dst/man/ase-config-edit.1 +1 -1
  64. package/dst/man/ase-config-get.1 +1 -1
  65. package/dst/man/ase-config-init.1 +1 -1
  66. package/dst/man/ase-config-list.1 +1 -1
  67. package/dst/man/ase-config-set.1 +1 -1
  68. package/dst/man/ase-config.1 +1 -1
  69. package/dst/man/ase-hook-permission-request.1 +1 -1
  70. package/dst/man/ase-hook-pre-tool-use.1 +1 -1
  71. package/dst/man/ase-hook-session-end.1 +1 -1
  72. package/dst/man/ase-hook-session-persona.1 +1 -1
  73. package/dst/man/ase-hook-session-start.1 +1 -1
  74. package/dst/man/ase-hook-stop.1 +1 -1
  75. package/dst/man/ase-hook-user-prompt-submit.1 +1 -1
  76. package/dst/man/ase-hook.1 +1 -1
  77. package/dst/man/ase-man.1 +1 -1
  78. package/dst/man/ase-mcp.1 +1 -1
  79. package/dst/man/ase-service-log.1 +1 -1
  80. package/dst/man/ase-service-send.1 +1 -1
  81. package/dst/man/ase-service-start.1 +1 -1
  82. package/dst/man/ase-service-status.1 +1 -1
  83. package/dst/man/ase-service-stop.1 +1 -1
  84. package/dst/man/ase-service.1 +1 -1
  85. package/dst/man/ase-setup-disable.1 +1 -1
  86. package/dst/man/ase-setup-enable.1 +1 -1
  87. package/dst/man/ase-setup-install.1 +1 -1
  88. package/dst/man/ase-setup-mcp.1 +1 -1
  89. package/dst/man/ase-setup-status.1 +1 -1
  90. package/dst/man/ase-setup-statusline.1 +1 -1
  91. package/dst/man/ase-setup-telemetry.1 +1 -1
  92. package/dst/man/ase-setup-uninstall.1 +1 -1
  93. package/dst/man/ase-setup-update.1 +1 -1
  94. package/dst/man/ase-setup.1 +1 -1
  95. package/dst/man/ase-spec-export.1 +1 -1
  96. package/dst/man/ase-spec-lint.1 +1 -1
  97. package/dst/man/ase-spec-preview.1 +1 -1
  98. package/dst/man/ase-spec.1 +1 -1
  99. package/dst/man/ase-statusline.1 +1 -1
  100. package/dst/man/ase-task-assign.1 +1 -1
  101. package/dst/man/ase-task-board.1 +3 -3
  102. package/dst/man/ase-task-delete.1 +1 -1
  103. package/dst/man/ase-task-edit.1 +1 -1
  104. package/dst/man/ase-task-idscheme.1 +1 -1
  105. package/dst/man/ase-task-lifecycle.1 +1 -1
  106. package/dst/man/ase-task-list.1 +1 -1
  107. package/dst/man/ase-task-load.1 +1 -1
  108. package/dst/man/ase-task-newid.1 +1 -1
  109. package/dst/man/ase-task-purge.1 +1 -1
  110. package/dst/man/ase-task-rename.1 +1 -1
  111. package/dst/man/ase-task-request.1 +1 -1
  112. package/dst/man/ase-task-save.1 +1 -1
  113. package/dst/man/ase-task-status.1 +1 -1
  114. package/dst/man/ase-task-store.1 +1 -1
  115. package/dst/man/ase-task-view.1 +1 -1
  116. package/dst/man/ase-task.1 +1 -1
  117. package/dst/man/ase-util-compat.1 +1 -1
  118. package/dst/man/ase-util-diagram.1 +1 -1
  119. package/dst/man/ase-util-meta.1 +1 -1
  120. package/dst/man/ase-util-metric.1 +1 -1
  121. package/dst/man/ase-util-mint.1 +1 -1
  122. package/dst/man/ase-util-worktree.1 +1 -1
  123. package/dst/man/ase-util.1 +1 -1
  124. package/dst/man/ase.1 +1 -1
  125. package/package.json +1 -1
  126. package/plugin/.claude-plugin/plugin.json +1 -1
  127. package/plugin/.codex-plugin/plugin.json +1 -1
  128. package/plugin/.github/plugin/plugin.json +1 -1
  129. package/plugin/meta/ase-common-resolve.md +2 -2
  130. package/plugin/meta/ase-skill.md +7 -0
  131. package/plugin/mods/ase-mods.ts +16 -12
  132. package/plugin/skills/ase-arch-analyze/SKILL.md +3 -1
  133. package/plugin/skills/ase-code-analyze/SKILL.md +26 -10
  134. package/plugin/skills/ase-code-analyze/help.md +11 -6
  135. package/plugin/skills/ase-code-edit/SKILL.md +5 -4
  136. package/plugin/skills/ase-code-edit/help.md +1 -1
  137. package/plugin/skills/ase-code-lint/SKILL.md +52 -27
  138. package/plugin/skills/ase-code-lint/help.md +10 -5
  139. package/plugin/skills/ase-help-intent/data.md +60 -30
  140. package/plugin/skills/ase-help-skill/help.md +3 -4
  141. package/plugin/skills/ase-meta-quorum/SKILL.md +9 -0
  142. package/plugin/skills/ase-meta-quorum/help.md +5 -3
  143. package/plugin/skills/ase-meta-workflow/sample.md +5 -3
  144. package/plugin/skills/ase-repo-changelog/SKILL.md +8 -9
  145. package/plugin/skills/ase-repo-changelog/help.md +2 -2
  146. package/plugin/skills/ase-repo-review/SKILL.md +5 -3
  147. package/plugin/skills/ase-repo-review/help.md +3 -1
  148. package/plugin/skills/ase-spec-edit/SKILL.md +2 -1
  149. package/plugin/skills/ase-spec-edit/help.md +2 -1
  150. package/plugin/skills/ase-sync-reconcile/help.md +4 -4
  151. package/plugin/skills/ase-task-dissect/SKILL.md +10 -6
  152. package/plugin/skills/ase-task-grill/help.md +1 -1
  153. package/plugin/skills/ase-task-implement/SKILL.md +58 -1
  154. package/plugin/skills/ase-task-implement/help.md +16 -0
  155. package/plugin/skills/ase-task-list/help.md +2 -2
@@ -18,7 +18,7 @@ import { flowOf, layoutFlow, drawFlowSVG } from "./ase-task-board-flow.js";
18
18
  import { filterBoard, dropStandalone } from "./ase-task-board-filter.js";
19
19
  import { statsOf, statsRanges, effortScale } from "./ase-task-board-stats.js";
20
20
  import * as TaskFormat from "./ase-task-format.js";
21
- import { editProbe, editRefused, requestEdit, requestsOf, RequestError } from "./ase-task-request.js";
21
+ import { editProbe, editRefused, requestsOf, RequestError } from "./ase-task-request.js";
22
22
  import pkg from "../package.json" with { type: "json" };
23
23
  /* the build stamp of the loaded code (version and content hash of the board modules
24
24
  and page assets), reported by the ping route to let the CLI detect a stale running service
@@ -26,7 +26,7 @@ import pkg from "../package.json" with { type: "json" };
26
26
  export const BOARD_BUILD = (() => {
27
27
  const dir = path.dirname(fileURLToPath(import.meta.url));
28
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"])
29
+ for (const f of ["core.js", "filter.js", "flow.js", "graph.js", "stats.js", "web-server.js", "web-client.html", "web-client.css", "web-client.js"])
30
30
  hash.update(fs.readFileSync(path.join(dir, `ase-task-board-${f}`)));
31
31
  return `${pkg.version}:${hash.digest("hex")}`;
32
32
  })();
@@ -192,7 +192,7 @@ const attachmentDocument = async (log, id, n) => {
192
192
  try {
193
193
  text = new TextDecoder("utf-8", { fatal: true }).decode(a.content);
194
194
  }
195
- catch (_err) {
195
+ catch {
196
196
  text = "\0";
197
197
  }
198
198
  if (text.includes("\0"))
@@ -364,7 +364,7 @@ const registerPageRoutes = (server, log) => {
364
364
  };
365
365
  /* the filter query of a request (its optional "filter" query parameter) */
366
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 */
367
+ /* register the view routes of the web board: the board, the task and agent graphs, the statistics, the new task text, and the tasks with their attachments */
368
368
  const registerViewRoutes = (server, log) => {
369
369
  /* the board model, reduced onto the tasks matching the filter query */
370
370
  server.route({
@@ -637,29 +637,6 @@ const registerUpdateRoutes = (server, log) => {
637
637
  return h.response({ prompt: probe.prompt });
638
638
  })
639
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
640
  /* request a task by a prompt under a request name (processed by a request agent of
664
641
  a definition, resp. by "ase agent editor" for "edit"), answering 400 if the
665
642
  request name is invalid or the prompt of an "edit" request is empty (as the prompts
@@ -78,7 +78,7 @@ export default class TaskBoardCommand {
78
78
  "(restart it with \"ase service stop\" once no agent session uses it)");
79
79
  return port;
80
80
  }
81
- /* open an URL in the default browser of the platform, waiting only for
81
+ /* open a URL in the default browser of the platform, waiting only for
82
82
  the opener to spawn, as e.g. "xdg-open" can stay attached to the browser */
83
83
  async openBrowser(url) {
84
84
  const [cmd, ...args] = process.platform === "darwin" ? ["open", url] :
@@ -745,7 +745,7 @@ export const bindEffort = (plan) => {
745
745
  if (effort !== null && effort.hash === "")
746
746
  plan.header.Effort = `${effort.size} ${hash}`;
747
747
  else if (effort === null || effort.hash !== hash)
748
- Reflect.deleteProperty(plan.header, "Effort");
748
+ delete plan.header.Effort;
749
749
  return plan;
750
750
  };
751
751
  /* derive the title of a task plan from its body as defined by the
@@ -58,6 +58,7 @@ export class LocalTaskStoreClient {
58
58
  this.configure("project.task.idscheme", spec);
59
59
  this.idscheme = (await this.core.projectSet(this.prjId, { idscheme: spec })).project.idscheme;
60
60
  }
61
+ /* run an operation, yielding a fallback for a missing (404) task plan */
61
62
  async missing(op, fallback) {
62
63
  try {
63
64
  return await op();
@@ -68,6 +69,7 @@ export class LocalTaskStoreClient {
68
69
  throw err;
69
70
  }
70
71
  }
72
+ /* open the storage delegate shared by all clients of the store and register the project */
71
73
  async open() {
72
74
  let entry = LocalTaskStoreClient.shared.get(this.storage.key);
73
75
  if (entry === undefined) {
@@ -99,6 +101,7 @@ export class LocalTaskStoreClient {
99
101
  throw err;
100
102
  }
101
103
  }
104
+ /* release the shared storage delegate, closing it with its last client */
102
105
  async close() {
103
106
  const entry = this.entry;
104
107
  if (entry === undefined)
@@ -107,7 +110,9 @@ export class LocalTaskStoreClient {
107
110
  if (--entry.refs === 0) {
108
111
  if (LocalTaskStoreClient.shared.get(this.storage.key) === entry)
109
112
  LocalTaskStoreClient.shared.delete(this.storage.key);
110
- await entry.opened.then((opened) => opened.store.close(), () => { });
113
+ await entry.opened.then((opened) => opened.store.close(), () => {
114
+ /* a failed opening was already raised to its opener */
115
+ });
111
116
  }
112
117
  }
113
118
  list(fields) {
@@ -153,11 +158,13 @@ export class LocalTaskStoreClient {
153
158
  onChange();
154
159
  };
155
160
  let stopped = false;
161
+ let started = false;
156
162
  const start = async () => {
157
163
  await this.open();
158
164
  if (stopped)
159
165
  await this.close();
160
166
  else {
167
+ started = true;
161
168
  this.entry?.listeners.add(listener);
162
169
  onState?.(true);
163
170
  }
@@ -167,6 +174,8 @@ export class LocalTaskStoreClient {
167
174
  });
168
175
  return () => {
169
176
  stopped = true;
177
+ if (!started)
178
+ return;
170
179
  this.entry?.listeners.delete(listener);
171
180
  this.close().catch(() => { });
172
181
  };
@@ -174,9 +183,10 @@ export class LocalTaskStoreClient {
174
183
  }
175
184
  /* the interval of the keepalive pings of the event subscription (where a connection without
176
185
  any traffic for this interval is pinged and without any traffic for twice this interval
177
- is terminated) and the timeout of its WebSocket handshake */
186
+ is terminated), the timeout of its WebSocket handshake, and the timeout of the REST API requests */
178
187
  const KEEPALIVE_MS = 15 * 1000;
179
188
  const HANDSHAKE_MS = 10 * 1000;
189
+ const REQUEST_MS = 10 * 1000;
180
190
  /* the remote client: the REST API of a task store server, with the
181
191
  project registered under its configured lifecycle model and task id
182
192
  scheme on first use only (afterwards adopting the lifecycle model and
@@ -231,7 +241,7 @@ export class RemoteTaskStoreClient {
231
241
  },
232
242
  body: body !== undefined ? JSON.stringify(body) : undefined,
233
243
  dispatcher: this.dispatcher,
234
- signal: AbortSignal.timeout(10000),
244
+ signal: AbortSignal.timeout(REQUEST_MS),
235
245
  ignoreResponseError: true
236
246
  }).catch((err) => this.unreachable(err));
237
247
  if (r.status === 404 || tolerated.includes(r.status))
@@ -402,7 +412,7 @@ export class RemoteTaskStoreClient {
402
412
  headers: { Authorization: `Bearer ${this.token}` },
403
413
  responseType: "arrayBuffer",
404
414
  dispatcher: this.dispatcher,
405
- signal: AbortSignal.timeout(10000),
415
+ signal: AbortSignal.timeout(REQUEST_MS),
406
416
  ignoreResponseError: true
407
417
  }).catch((err) => this.unreachable(err));
408
418
  if (r.status === 404)
@@ -489,7 +499,9 @@ export class RemoteTaskStoreClient {
489
499
  if (timer !== null)
490
500
  clearTimeout(timer);
491
501
  ws?.close();
492
- this.close().catch(() => { });
502
+ this.close().catch(() => {
503
+ /* best-effort release of the dispatcher on unsubscribe */
504
+ });
493
505
  };
494
506
  }
495
507
  }
@@ -143,7 +143,7 @@ export class TaskStoreCore {
143
143
  if (effort !== undefined)
144
144
  header.Effort = effort;
145
145
  else
146
- Reflect.deleteProperty(header, "Effort");
146
+ delete header.Effort;
147
147
  return header;
148
148
  }
149
149
  /* load an existing task plan of a registered project */
@@ -408,7 +408,7 @@ export class TaskStoreCore {
408
408
  if (lifecycle.states.includes(status) && !states.includes(status))
409
409
  continue;
410
410
  const att = fields === "usage" ? (await this.store.taskLoad(prjId, entry.id))?.attachment
411
- .find((att) => TaskFormat.isUsage(att) && att.Data !== undefined) : undefined;
411
+ .find((a) => TaskFormat.isUsage(a) && a.Data !== undefined) : undefined;
412
412
  const header = fields === "header" ? await this.boundHeader(prjId, entry) : entry.header;
413
413
  result.push({
414
414
  id: entry.id,
@@ -605,12 +605,10 @@ export class TaskStoreCore {
605
605
  references: referrers.length
606
606
  };
607
607
  }
608
- else {
609
- await this.store.taskSave(prjId, taskId, plan);
610
- this.emitUpdated(prjId, taskId, plan, lifecycle, ["header"]);
611
- }
608
+ await this.store.taskSave(prjId, taskId, plan);
609
+ this.emitUpdated(prjId, taskId, plan, lifecycle, ["header"]);
612
610
  return {
613
- id: newId ?? taskId,
611
+ id: taskId,
614
612
  status: TaskFormat.taskStatus(plan.header, lifecycle),
615
613
  ...(status !== null ? { from } : {})
616
614
  };
@@ -235,3 +235,94 @@ export const attachmentSync = (current, wanted) => {
235
235
  }
236
236
  return ops;
237
237
  };
238
+ /* reuse the cached promise of a load, else start the load and cache its promise
239
+ (dropped again on failure), so repeated and concurrent loads share one request */
240
+ export const memoize = (cache, key, load) => {
241
+ let promise = cache.get(key);
242
+ if (promise === undefined) {
243
+ promise = load();
244
+ cache.set(key, promise);
245
+ promise.catch(() => {
246
+ cache.delete(key);
247
+ });
248
+ }
249
+ return promise;
250
+ };
251
+ /* the common base of the issue tracker storage plugins: the projects mapped onto
252
+ their locations, the storage lifecycle polling for changes while opened and
253
+ observed (without keeping the process alive), and the project listing */
254
+ export class IssueTaskStoragePlugin {
255
+ ctx;
256
+ unit;
257
+ now;
258
+ url = "";
259
+ poll = 0;
260
+ repos = new Map();
261
+ listener = null;
262
+ timer = null;
263
+ opened;
264
+ polling = false;
265
+ polls = new Map();
266
+ constructor(ctx, unit, now) {
267
+ this.ctx = ctx;
268
+ this.unit = unit;
269
+ this.now = now;
270
+ this.opened = now();
271
+ }
272
+ /* the storage lifecycle */
273
+ async open() {
274
+ this.opened = this.now();
275
+ if (this.poll > 0 && this.listener !== null) {
276
+ this.timer = setInterval(() => {
277
+ this.pollAll().catch(() => {
278
+ /* failures are already logged per project */
279
+ });
280
+ }, this.poll * 1000);
281
+ this.timer.unref();
282
+ }
283
+ this.ctx.log("debug", `opened ${this.repos.size} ${this.unit} on ${this.url} (polling every ${this.poll}s)`);
284
+ }
285
+ async close() {
286
+ if (this.timer !== null)
287
+ clearInterval(this.timer);
288
+ this.timer = null;
289
+ }
290
+ /* observe the changes of the issues made outside of this plugin instance */
291
+ watch(listener) {
292
+ this.listener = listener;
293
+ }
294
+ /* list the registered ones of the mapped projects */
295
+ async projectList() {
296
+ const out = [];
297
+ for (const prjId of this.repos.keys()) {
298
+ const entry = await this.projectGet(prjId);
299
+ if (entry !== null)
300
+ out.push(entry);
301
+ }
302
+ return out;
303
+ }
304
+ /* the change detection state of a project (starting at the opening time) */
305
+ pollState(prjId) {
306
+ let state = this.polls.get(prjId);
307
+ if (state === undefined) {
308
+ state = { since: this.opened, seen: new Map() };
309
+ this.polls.set(prjId, state);
310
+ }
311
+ return state;
312
+ }
313
+ /* poll all mapped projects, never overlapping itself */
314
+ async pollAll() {
315
+ if (this.polling)
316
+ return;
317
+ this.polling = true;
318
+ try {
319
+ for (const prjId of this.repos.keys())
320
+ await this.pollProject(prjId).catch((err) => {
321
+ this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
322
+ });
323
+ }
324
+ finally {
325
+ this.polling = false;
326
+ }
327
+ }
328
+ }
@@ -143,7 +143,7 @@ class FileTaskStoragePlugin {
143
143
  doc = parseYAML(text);
144
144
  }
145
145
  catch (err) {
146
- this.ctx.log("warning", `malformed project file "${file}" ignored: ${err.message}`);
146
+ this.ctx.log("warning", `malformed project file "${file}" ignored: ${err instanceof Error ? err.message : String(err)}`);
147
147
  return { lifecycle: this.lifecycle, idscheme: this.idscheme, seqmark: 0, broken: true };
148
148
  }
149
149
  return {
@@ -339,7 +339,9 @@ class FileTaskStoragePlugin {
339
339
  if (this.watcher !== w)
340
340
  return;
341
341
  this.watcher = null;
342
- w.close().catch(() => { }).finally(() => {
342
+ w.close().catch(() => {
343
+ /* re-arm even if the outdated watcher fails to close */
344
+ }).finally(() => {
343
345
  this.arm();
344
346
  this.schedule();
345
347
  });
@@ -418,7 +420,9 @@ class FileTaskStoragePlugin {
418
420
  clearTimeout(this.timer);
419
421
  this.timer = setTimeout(() => {
420
422
  this.timer = null;
421
- this.rescan().catch(() => { });
423
+ this.rescan().catch(() => {
424
+ /* failures are already logged per directory */
425
+ });
422
426
  }, 100);
423
427
  this.timer.unref();
424
428
  }
@@ -50,19 +50,10 @@ const inRepo = (loc, issue) => issue.repository === undefined || issue.repositor
50
50
  "After" onto the dependencies (the blocking issues), "Created"/"Modified" onto the
51
51
  issue timestamps, any other key (including "Group", as Gitea has no parent issues)
52
52
  onto "ase:<key>:<value>" labels, and the attachments onto the issue comments */
53
- class GiteaTaskStoragePlugin {
54
- ctx;
53
+ class GiteaTaskStoragePlugin extends TaskIssues.IssueTaskStoragePlugin {
55
54
  name = "gitea";
56
- url;
57
55
  source;
58
56
  gt;
59
- repos = new Map();
60
- poll;
61
- listener = null;
62
- timer = null;
63
- polling = false;
64
- opened = now();
65
- polls = new Map();
66
57
  registry = new LRUCache({ max: 64, ttl: 60 * 1000 });
67
58
  latest = new Map();
68
59
  labels = new Map();
@@ -71,7 +62,7 @@ class GiteaTaskStoragePlugin {
71
62
  depends = new LRUCache({ max: 1024, ttl: 60 * 1000 });
72
63
  loads = new LRUCache({ max: 256, ttl: 2 * 1000 });
73
64
  constructor(ctx) {
74
- this.ctx = ctx;
65
+ super(ctx, "repositories", now);
75
66
  const options = ctx.options;
76
67
  const token = options.token ?? process.env.GITEA_TOKEN ?? "";
77
68
  if (typeof token !== "string" || token === "")
@@ -147,27 +138,6 @@ class GiteaTaskStoragePlugin {
147
138
  return out;
148
139
  }
149
140
  }
150
- /* the storage lifecycle: polling for changes while opened and observed
151
- (without keeping the process alive) */
152
- async open() {
153
- this.opened = now();
154
- if (this.poll > 0 && this.listener !== null) {
155
- this.timer = setInterval(() => {
156
- this.pollAll().catch(() => { });
157
- }, this.poll * 1000);
158
- this.timer.unref();
159
- }
160
- this.ctx.log("debug", `opened ${this.repos.size} repositories on ${this.url} (polling every ${this.poll}s)`);
161
- }
162
- async close() {
163
- if (this.timer !== null)
164
- clearInterval(this.timer);
165
- this.timer = null;
166
- }
167
- /* observe the changes of the issues made outside of this plugin instance */
168
- watch(listener) {
169
- this.listener = listener;
170
- }
171
141
  /* ==== project registry ==== */
172
142
  /* the repository of a project */
173
143
  repo(prjId) {
@@ -240,15 +210,6 @@ class GiteaTaskStoragePlugin {
240
210
  async seqmark(loc, entry, fresh = false) {
241
211
  return Math.max(await this.latestNumber(loc, fresh), entry?.seqmark ?? 0);
242
212
  }
243
- async projectList() {
244
- const out = [];
245
- for (const prjId of this.repos.keys()) {
246
- const entry = await this.projectGet(prjId);
247
- if (entry !== null)
248
- out.push(entry);
249
- }
250
- return out;
251
- }
252
213
  /* get a project, with the sequence number high-water mark as the next
253
214
  allocated task id is the one of the next created issue */
254
215
  async projectGet(prjId) {
@@ -287,7 +248,7 @@ class GiteaTaskStoragePlugin {
287
248
  await found(this.call(loc, this.gt.repos.issueDeleteLabel(loc.owner, loc.repo, label.id)).then(() => true)) : null;
288
249
  this.labels.delete(`${loc.owner}/${loc.repo}`);
289
250
  this.registry.delete(prjId);
290
- return label !== undefined && result !== null;
251
+ return result !== null;
291
252
  }
292
253
  /* ==== issue mapping ==== */
293
254
  /* fetch the live issue of an issue number (null if none) */
@@ -303,15 +264,8 @@ class GiteaTaskStoragePlugin {
303
264
  const key = `${loc.owner}/${loc.repo}#${issue.number}@${issue.updated_at}`;
304
265
  if (fresh)
305
266
  this.depends.delete(key);
306
- let depends = this.depends.get(key);
307
- if (depends === undefined) {
308
- depends = found(this.all(loc, (query) => this.gt.repos.issueListIssueDependencies(loc.owner, loc.repo, String(issue.number), query)))
309
- .then((issues) => (issues ?? []));
310
- this.depends.set(key, depends);
311
- depends.catch(() => {
312
- this.depends.delete(key);
313
- });
314
- }
267
+ const depends = TaskIssues.memoize(this.depends, key, () => found(this.all(loc, (query) => this.gt.repos.issueListIssueDependencies(loc.owner, loc.repo, String(issue.number), query)))
268
+ .then((issues) => (issues ?? [])));
315
269
  return (await depends).filter((blocker) => inRepo(loc, blocker));
316
270
  }
317
271
  /* the comments of an issue */
@@ -362,10 +316,10 @@ class GiteaTaskStoragePlugin {
362
316
  if (label === undefined) {
363
317
  label = await this.call(loc, this.gt.repos.issueCreateLabel(loc.owner, loc.repo, { name, color: name.startsWith("ase:") ? "#c5def5" : "#ededed" })).catch(async (err) => {
364
318
  /* tolerate a label created concurrently */
365
- const label = statusOf(err) === 422 ? (await this.labelMap(loc, true)).get(name.toLowerCase()) : undefined;
366
- if (label === undefined)
319
+ const existing = statusOf(err) === 422 ? (await this.labelMap(loc, true)).get(name.toLowerCase()) : undefined;
320
+ if (existing === undefined)
367
321
  throw err;
368
- return label;
322
+ return existing;
369
323
  });
370
324
  const map = await this.labelMap(loc);
371
325
  map.set(name.toLowerCase(), label);
@@ -422,15 +376,8 @@ class GiteaTaskStoragePlugin {
422
376
  if (typeof assignee === "string" && assignee !== "") {
423
377
  const logins = issue?.assignees?.map((user) => user.login) ?? [];
424
378
  const key = `${loc.owner}/${loc.repo}`;
425
- let users = this.assignable.get(key);
426
- if (users === undefined) {
427
- users = this.call(loc, this.gt.repos.repoGetAssignees(loc.owner, loc.repo))
428
- .then((list) => new Set(list.map((user) => (user.login ?? "").toLowerCase())));
429
- this.assignable.set(key, users);
430
- users.catch(() => {
431
- this.assignable.delete(key);
432
- });
433
- }
379
+ const users = TaskIssues.memoize(this.assignable, key, () => this.call(loc, this.gt.repos.repoGetAssignees(loc.owner, loc.repo))
380
+ .then((list) => new Set(list.map((user) => (user.login ?? "").toLowerCase()))));
434
381
  if (logins.includes(assignee))
435
382
  assignees = logins;
436
383
  else if ((await users).has(assignee.toLowerCase()))
@@ -486,11 +433,7 @@ class GiteaTaskStoragePlugin {
486
433
  own write or detected change of the issue), as e.g. the boards load the plan
487
434
  and its attachments in separate operations */
488
435
  load(prjId, loc, n) {
489
- const key = `${prjId}#${n}`;
490
- const cached = this.loads.get(key);
491
- if (cached !== undefined)
492
- return cached;
493
- const load = (async () => {
436
+ return TaskIssues.memoize(this.loads, `${prjId}#${n}`, async () => {
494
437
  const [issue, comments] = await Promise.all([
495
438
  this.fetch(loc, n),
496
439
  n === 0 ? [] : found(this.comments(loc, n))
@@ -498,12 +441,7 @@ class GiteaTaskStoragePlugin {
498
441
  if (issue === null)
499
442
  return null;
500
443
  return { issue, after: await this.blockers(loc, issue), comments: comments ?? [] };
501
- })();
502
- this.loads.set(key, load);
503
- load.catch(() => {
504
- this.loads.delete(key);
505
444
  });
506
- return load;
507
445
  }
508
446
  /* ==== task plans ==== */
509
447
  async taskList(prjId) {
@@ -606,30 +544,6 @@ class GiteaTaskStoragePlugin {
606
544
  throw new Error(`task "${oldId}" cannot be renamed to "${newId}", as task ids are Gitea issue numbers`);
607
545
  }
608
546
  /* ==== change detection ==== */
609
- /* the change detection state of a project (starting at the opening time) */
610
- pollState(prjId) {
611
- let state = this.polls.get(prjId);
612
- if (state === undefined) {
613
- state = { since: this.opened, seen: new Map() };
614
- this.polls.set(prjId, state);
615
- }
616
- return state;
617
- }
618
- /* poll all registered projects, never overlapping itself */
619
- async pollAll() {
620
- if (this.polling)
621
- return;
622
- this.polling = true;
623
- try {
624
- for (const prjId of this.repos.keys())
625
- await this.pollProject(prjId).catch((err) => {
626
- this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
627
- });
628
- }
629
- finally {
630
- this.polling = false;
631
- }
632
- }
633
547
  /* poll the issues of a project updated since the last poll (Gitea supports no
634
548
  conditional requests), and report the issues not seen at their update time yet:
635
549
  as added if created after the opening, else as updated (issues deleted on Gitea
@@ -60,18 +60,9 @@ const inRepo = (loc, issue) => {
60
60
  the attachments onto the issue comments (where "Group" and "After" fall back
61
61
  onto "ase:Group:<id>" resp. "ase:After:<ids>" labels on instances without
62
62
  sub-issues resp. issue dependencies) */
63
- class GitHubTaskStoragePlugin {
64
- ctx;
63
+ class GitHubTaskStoragePlugin extends TaskIssues.IssueTaskStoragePlugin {
65
64
  name = "github";
66
- url;
67
65
  gh;
68
- repos = new Map();
69
- poll;
70
- listener = null;
71
- timer = null;
72
- polling = false;
73
- opened = now();
74
- polls = new Map();
75
66
  registry = new LRUCache({ max: 64, ttl: 60 * 1000 });
76
67
  latest = new Map();
77
68
  labels = new Map();
@@ -80,9 +71,9 @@ class GitHubTaskStoragePlugin {
80
71
  recent = new Map();
81
72
  loads = new LRUCache({ max: 256, ttl: 2 * 1000 });
82
73
  constructor(ctx) {
83
- this.ctx = ctx;
74
+ super(ctx, "repositories", now);
84
75
  const options = ctx.options;
85
- const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
76
+ const token = options.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) ?? "";
86
77
  if (typeof token !== "string" || token === "")
87
78
  throw new Error("task store: plugin \"github\" requires the \"token\" option (or $GITHUB_TOKEN resp. $GH_TOKEN)");
88
79
  const url = options.url ?? "https://github.com";
@@ -131,7 +122,7 @@ class GitHubTaskStoragePlugin {
131
122
  /* explain a denied access (but not an exceeded rate limit), as the
132
123
  GitHub message names neither the token source nor the missing permission */
133
124
  const source = options.token !== undefined ? "the \"token\" option (resp. \"project.task.store.token\")" :
134
- process.env.GITHUB_TOKEN !== undefined ? "$GITHUB_TOKEN" : "$GH_TOKEN";
125
+ process.env.GITHUB_TOKEN !== undefined && process.env.GITHUB_TOKEN !== "" ? "$GITHUB_TOKEN" : "$GH_TOKEN";
135
126
  this.gh.hook.error("request", (err, request) => {
136
127
  const status = statusOf(err);
137
128
  if ((status === 401 || status === 403) && !/rate limit/i.test(err.message)) {
@@ -144,27 +135,6 @@ class GitHubTaskStoragePlugin {
144
135
  throw err;
145
136
  });
146
137
  }
147
- /* the storage lifecycle: polling for changes while opened and observed
148
- (without keeping the process alive) */
149
- async open() {
150
- this.opened = now();
151
- if (this.poll > 0 && this.listener !== null) {
152
- this.timer = setInterval(() => {
153
- this.pollAll().catch(() => { });
154
- }, this.poll * 1000);
155
- this.timer.unref();
156
- }
157
- this.ctx.log("debug", `opened ${this.repos.size} repositories on ${this.url} (polling every ${this.poll}s)`);
158
- }
159
- async close() {
160
- if (this.timer !== null)
161
- clearInterval(this.timer);
162
- this.timer = null;
163
- }
164
- /* observe the changes of the issues made outside of this plugin instance */
165
- watch(listener) {
166
- this.listener = listener;
167
- }
168
138
  /* ==== project registry ==== */
169
139
  /* the repository of a project */
170
140
  repo(prjId) {
@@ -222,15 +192,6 @@ class GitHubTaskStoragePlugin {
222
192
  throw err;
223
193
  }
224
194
  }
225
- async projectList() {
226
- const out = [];
227
- for (const prjId of this.repos.keys()) {
228
- const entry = await this.projectGet(prjId);
229
- if (entry !== null)
230
- out.push(entry);
231
- }
232
- return out;
233
- }
234
195
  /* get a project, with the highest issue number as the sequence number high-water
235
196
  mark, so the next allocated task id is the one of the next created issue */
236
197
  async projectGet(prjId) {
@@ -501,11 +462,7 @@ class GitHubTaskStoragePlugin {
501
462
  own write or detected change of the issue), as e.g. the boards load the plan
502
463
  and its attachments in separate operations */
503
464
  load(prjId, loc, n) {
504
- const key = `${prjId}#${n}`;
505
- const cached = this.loads.get(key);
506
- if (cached !== undefined)
507
- return cached;
508
- const load = (async () => {
465
+ return TaskIssues.memoize(this.loads, `${prjId}#${n}`, async () => {
509
466
  const [issue, comments] = await Promise.all([
510
467
  this.fetch(loc, n),
511
468
  n === 0 ? [] : found(this.gh.paginate(this.gh.rest.issues.listComments, { ...loc, issue_number: n, per_page: 100 }))
@@ -513,12 +470,7 @@ class GitHubTaskStoragePlugin {
513
470
  if (issue === null)
514
471
  return null;
515
472
  return { issue, after: await this.blockers(loc, issue), comments: comments ?? [] };
516
- })();
517
- this.loads.set(key, load);
518
- load.catch(() => {
519
- this.loads.delete(key);
520
473
  });
521
- return load;
522
474
  }
523
475
  /* ==== task plans ==== */
524
476
  async taskList(prjId) {
@@ -618,30 +570,6 @@ class GitHubTaskStoragePlugin {
618
570
  throw new Error(`task "${oldId}" cannot be renamed to "${newId}", as task ids are GitHub issue numbers`);
619
571
  }
620
572
  /* ==== change detection ==== */
621
- /* the change detection state of a project (starting at the opening time) */
622
- pollState(prjId) {
623
- let state = this.polls.get(prjId);
624
- if (state === undefined) {
625
- state = { since: this.opened, seen: new Map() };
626
- this.polls.set(prjId, state);
627
- }
628
- return state;
629
- }
630
- /* poll all registered projects, never overlapping itself */
631
- async pollAll() {
632
- if (this.polling)
633
- return;
634
- this.polling = true;
635
- try {
636
- for (const prjId of this.repos.keys())
637
- await this.pollProject(prjId).catch((err) => {
638
- this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
639
- });
640
- }
641
- finally {
642
- this.polling = false;
643
- }
644
- }
645
573
  /* poll the issues of a project updated since the last poll, conditionally
646
574
  via the entity tag of the last poll (a "304" answer is free of rate limit),
647
575
  and report the issues not seen at their update time yet: as added if created