@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.
- 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 +228 -111
- 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,484 @@
|
|
|
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 { parse as parseYAML } from "yaml";
|
|
9
|
+
import writeFileAtomic from "write-file-atomic";
|
|
10
|
+
import lockfile from "proper-lockfile";
|
|
11
|
+
import { watch } from "chokidar";
|
|
12
|
+
import * as TaskFormat from "./ase-task-format.js";
|
|
13
|
+
/* the task file pattern and the per-project registry file */
|
|
14
|
+
const TASK_FILE_RE = /^TASK-([A-Za-z0-9#][A-Za-z0-9#_-]*)\.md$/;
|
|
15
|
+
const PROJECT_FILE = "PROJECT.yaml";
|
|
16
|
+
/* check for the existence of a filesystem path (without blocking the event loop) */
|
|
17
|
+
const exists = (p) => fs.promises.access(p).then(() => true, () => false);
|
|
18
|
+
/* the built-in storage plugin: task plans persisted as "TASK-<id>.md"
|
|
19
|
+
files in the textual task format (see "ase-format-task.md"), either
|
|
20
|
+
flat in the base directory ("solo" mode, accepting any project id)
|
|
21
|
+
or below "<basedir>/<prjId>/", with the "PROJECT.yaml" file of the
|
|
22
|
+
directory serving as the project registry entry and carrying the
|
|
23
|
+
lifecycle model name, the task id scheme, and the sequence number
|
|
24
|
+
high-water mark */
|
|
25
|
+
class FileTaskStoragePlugin {
|
|
26
|
+
ctx;
|
|
27
|
+
name = "ase";
|
|
28
|
+
verbatim = true;
|
|
29
|
+
basedir;
|
|
30
|
+
solo;
|
|
31
|
+
lifecycle;
|
|
32
|
+
idscheme;
|
|
33
|
+
/* the change detection state: the listener, the directory watcher, the
|
|
34
|
+
observed projects (onto their directories), the stamps of the task plan
|
|
35
|
+
files per directory, and the debounced, non-overlapping rescanning */
|
|
36
|
+
listener = null;
|
|
37
|
+
watcher = null;
|
|
38
|
+
tracked = new Map();
|
|
39
|
+
stamps = new Map();
|
|
40
|
+
timer = null;
|
|
41
|
+
watching = false;
|
|
42
|
+
scanning = false;
|
|
43
|
+
pending = false;
|
|
44
|
+
constructor(ctx) {
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
const options = ctx.options;
|
|
47
|
+
if (typeof options.basedir !== "string" || options.basedir === "")
|
|
48
|
+
throw new Error("task store: plugin \"ase\" requires the \"basedir\" option");
|
|
49
|
+
this.basedir = path.resolve(options.basedir);
|
|
50
|
+
this.solo = options.solo === true;
|
|
51
|
+
this.lifecycle = typeof options.lifecycle === "string" && options.lifecycle !== "" ? options.lifecycle : "solo";
|
|
52
|
+
this.idscheme = typeof options.idscheme === "string" && options.idscheme !== "" ? options.idscheme : "slug";
|
|
53
|
+
if (!Object.hasOwn(TaskFormat.taskLifecycles, this.lifecycle))
|
|
54
|
+
throw new Error(`task store: plugin "ase" received unknown lifecycle model "${this.lifecycle}"`);
|
|
55
|
+
if (TaskFormat.checkIdScheme(this.idscheme) !== "")
|
|
56
|
+
throw new Error(`task store: plugin "ase" received invalid task id scheme "${this.idscheme}"`);
|
|
57
|
+
}
|
|
58
|
+
/* the storage lifecycle (the base directory is not created upfront,
|
|
59
|
+
but on demand once the first file is stored into it), watching
|
|
60
|
+
the base directory for changes while opened and observed */
|
|
61
|
+
async open() {
|
|
62
|
+
if (this.listener !== null) {
|
|
63
|
+
this.watching = true;
|
|
64
|
+
this.arm();
|
|
65
|
+
}
|
|
66
|
+
this.ctx.log("debug", `opened base directory "${this.basedir}" ` +
|
|
67
|
+
`(storage: ${this.solo ? "single" : "multi"}, lifecycle: ${this.lifecycle})`);
|
|
68
|
+
}
|
|
69
|
+
async close() {
|
|
70
|
+
this.watching = false;
|
|
71
|
+
if (this.timer !== null)
|
|
72
|
+
clearTimeout(this.timer);
|
|
73
|
+
this.timer = null;
|
|
74
|
+
const watcher = this.watcher;
|
|
75
|
+
this.watcher = null;
|
|
76
|
+
await watcher?.close();
|
|
77
|
+
this.tracked.clear();
|
|
78
|
+
this.stamps.clear();
|
|
79
|
+
}
|
|
80
|
+
/* the directory of a project */
|
|
81
|
+
dir(prjId) {
|
|
82
|
+
return this.solo ? this.basedir : path.join(this.basedir, prjId);
|
|
83
|
+
}
|
|
84
|
+
/* the cross-process lock of a project, taken beside its directory
|
|
85
|
+
(as "<dir>.lock"), as the directory itself is created on demand only;
|
|
86
|
+
parent directories created for the lock are removed again afterwards,
|
|
87
|
+
unless the operation stored something into them */
|
|
88
|
+
async lock(prjId, op) {
|
|
89
|
+
const dir = this.dir(prjId);
|
|
90
|
+
const parent = path.resolve(path.dirname(dir));
|
|
91
|
+
let topmost;
|
|
92
|
+
const release = await lockfile.lock(dir, {
|
|
93
|
+
realpath: false,
|
|
94
|
+
retries: { retries: 50, minTimeout: 20, maxTimeout: 200 },
|
|
95
|
+
onCompromised: (err) => this.ctx.log("error", `lock of project "${prjId}" compromised: ${err.message}`),
|
|
96
|
+
fs: {
|
|
97
|
+
...fs,
|
|
98
|
+
mkdir: (p, cb) => {
|
|
99
|
+
const attempt = (tries) => {
|
|
100
|
+
fs.mkdir(path.dirname(p), { recursive: true }, (err, made) => {
|
|
101
|
+
if (err !== null)
|
|
102
|
+
return cb(err);
|
|
103
|
+
if (made !== undefined && (topmost === undefined || made.length < topmost.length))
|
|
104
|
+
topmost = made;
|
|
105
|
+
fs.mkdir(p, (error) => {
|
|
106
|
+
/* retry if a concurrent cleanup removed the parent directory meanwhile */
|
|
107
|
+
if (error?.code === "ENOENT" && tries > 0)
|
|
108
|
+
return attempt(tries - 1);
|
|
109
|
+
cb(error);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
attempt(3);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
return await op();
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await release().catch((err) => this.ctx.log("warning", `unlock of project "${prjId}" failed: ${err.message}`));
|
|
122
|
+
if (topmost !== undefined)
|
|
123
|
+
for (let d = parent; d.length >= topmost.length; d = path.dirname(d))
|
|
124
|
+
try {
|
|
125
|
+
await fs.promises.rmdir(d);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/* read the registry entry of a project directory: its lifecycle model
|
|
133
|
+
name, its task id scheme, the high-water mark of its sequence numbers,
|
|
134
|
+
and (in "solo" mode only) the id it was first registered under (a
|
|
135
|
+
malformed file falls back to the defaults and is flagged as broken) */
|
|
136
|
+
async projectOf(dir) {
|
|
137
|
+
const file = path.join(dir, PROJECT_FILE);
|
|
138
|
+
if (!await exists(file))
|
|
139
|
+
return { lifecycle: this.lifecycle, idscheme: this.idscheme, seqmark: 0 };
|
|
140
|
+
const text = await fs.promises.readFile(file, "utf8");
|
|
141
|
+
let doc;
|
|
142
|
+
try {
|
|
143
|
+
doc = parseYAML(text);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
this.ctx.log("warning", `malformed project file "${file}" ignored: ${err.message}`);
|
|
147
|
+
return { lifecycle: this.lifecycle, idscheme: this.idscheme, seqmark: 0, broken: true };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...(typeof doc?.id === "string" && TaskFormat.ID_RE.test(doc.id) ? { id: doc.id } : {}),
|
|
151
|
+
lifecycle: typeof doc?.lifecycle === "string" && doc.lifecycle !== "" ? doc.lifecycle : this.lifecycle,
|
|
152
|
+
idscheme: typeof doc?.idscheme === "string" && doc.idscheme !== "" ? doc.idscheme : this.idscheme,
|
|
153
|
+
seqmark: typeof doc?.seqmark === "number" && Number.isSafeInteger(doc.seqmark) && doc.seqmark > 0 ? doc.seqmark : 0
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/* write the registry entry of a project directory, where in "solo" mode the id
|
|
157
|
+
of the first registration is kept, as any project id is accepted and a
|
|
158
|
+
differing one must not cause a rewrite of the file (all strings are
|
|
159
|
+
quoted, as e.g. the task id scheme can contain "#" and ":") */
|
|
160
|
+
async projectWrite(dir, id, lifecycle, idscheme, seqmark) {
|
|
161
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
162
|
+
await writeFileAtomic(path.join(dir, PROJECT_FILE), (this.solo ? `id: ${JSON.stringify(id)}\n` : "") + `lifecycle: ${JSON.stringify(lifecycle)}\n` +
|
|
163
|
+
`idscheme: ${JSON.stringify(idscheme)}\n` +
|
|
164
|
+
(seqmark > 0 ? `seqmark: ${seqmark}\n` : ""), { encoding: "utf8" });
|
|
165
|
+
}
|
|
166
|
+
/* the project registry */
|
|
167
|
+
async projectList() {
|
|
168
|
+
const out = [];
|
|
169
|
+
if (this.solo) {
|
|
170
|
+
if (await exists(path.join(this.basedir, PROJECT_FILE))) {
|
|
171
|
+
const { id, lifecycle, idscheme, seqmark } = await this.projectOf(this.basedir);
|
|
172
|
+
out.push({ id: id ?? path.basename(this.basedir), lifecycle, idscheme, seqmark });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (await exists(this.basedir))
|
|
176
|
+
for (const entry of await fs.promises.readdir(this.basedir, { withFileTypes: true }))
|
|
177
|
+
if (entry.isDirectory() && TaskFormat.ID_RE.test(entry.name) && await exists(path.join(this.basedir, entry.name, PROJECT_FILE))) {
|
|
178
|
+
const { lifecycle, idscheme, seqmark } = await this.projectOf(path.join(this.basedir, entry.name));
|
|
179
|
+
out.push({ id: entry.name, lifecycle, idscheme, seqmark });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
async projectGet(prjId) {
|
|
184
|
+
const dir = this.dir(prjId);
|
|
185
|
+
if (!await exists(path.join(dir, PROJECT_FILE)))
|
|
186
|
+
return null;
|
|
187
|
+
const { lifecycle, idscheme, seqmark } = await this.projectOf(dir);
|
|
188
|
+
await this.track(prjId);
|
|
189
|
+
return { id: prjId, lifecycle, idscheme, seqmark };
|
|
190
|
+
}
|
|
191
|
+
async projectSet(prjId, lifecycle, idscheme) {
|
|
192
|
+
const dir = this.dir(prjId);
|
|
193
|
+
const result = await exists(path.join(dir, PROJECT_FILE)) ? "updated" : "created";
|
|
194
|
+
const prev = await this.projectOf(dir);
|
|
195
|
+
if (result === "created" || prev.broken || prev.lifecycle !== lifecycle || prev.idscheme !== idscheme)
|
|
196
|
+
await this.projectWrite(dir, prev.id ?? prjId, lifecycle, idscheme, prev.seqmark);
|
|
197
|
+
await this.track(prjId);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
async projectMark(prjId, seqmark) {
|
|
201
|
+
const dir = this.dir(prjId);
|
|
202
|
+
const prev = await this.projectOf(dir);
|
|
203
|
+
if (seqmark !== prev.seqmark)
|
|
204
|
+
await this.projectWrite(dir, prev.id ?? prjId, prev.lifecycle, prev.idscheme, seqmark);
|
|
205
|
+
}
|
|
206
|
+
async projectDelete(prjId) {
|
|
207
|
+
const dir = this.dir(prjId);
|
|
208
|
+
const file = path.join(dir, PROJECT_FILE);
|
|
209
|
+
if (!await exists(file))
|
|
210
|
+
return false;
|
|
211
|
+
this.untrack(prjId);
|
|
212
|
+
/* unregister the project, but keep its directory as long as it still carries any files */
|
|
213
|
+
await fs.promises.rm(file, { force: true });
|
|
214
|
+
if ((await fs.promises.readdir(dir)).length === 0)
|
|
215
|
+
await fs.promises.rmdir(dir);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
/* the task plan file of a project and the lifecycle model its
|
|
219
|
+
legacy content is normalized against */
|
|
220
|
+
file(prjId, taskId) {
|
|
221
|
+
return path.join(this.dir(prjId), `TASK-${taskId}.md`);
|
|
222
|
+
}
|
|
223
|
+
async model(prjId) {
|
|
224
|
+
const name = (await this.projectOf(this.dir(prjId))).lifecycle;
|
|
225
|
+
return Object.hasOwn(TaskFormat.taskLifecycles, name) ? TaskFormat.taskLifecycles[name] : TaskFormat.taskLifecycles[this.lifecycle];
|
|
226
|
+
}
|
|
227
|
+
/* the listing entry of a task plan file of a directory (null if missing or empty) */
|
|
228
|
+
async entry(dir, taskId, lifecycle) {
|
|
229
|
+
const file = path.join(dir, `TASK-${taskId}.md`);
|
|
230
|
+
const st = await fs.promises.stat(file).catch(() => null);
|
|
231
|
+
if (st === null || !st.isFile())
|
|
232
|
+
return null;
|
|
233
|
+
const text = await fs.promises.readFile(file, "utf8");
|
|
234
|
+
if (text === "")
|
|
235
|
+
return null;
|
|
236
|
+
const plan = TaskFormat.parseTaskText(taskId, text, lifecycle);
|
|
237
|
+
return { id: taskId, title: TaskFormat.taskTitle(plan.body), header: plan.header, mtime: st.mtime };
|
|
238
|
+
}
|
|
239
|
+
/* the task plans */
|
|
240
|
+
async taskList(prjId) {
|
|
241
|
+
const dir = this.dir(prjId);
|
|
242
|
+
if (!await exists(dir))
|
|
243
|
+
return [];
|
|
244
|
+
const lifecycle = await this.model(prjId);
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const entry of await fs.promises.readdir(dir)) {
|
|
247
|
+
const m = TASK_FILE_RE.exec(entry);
|
|
248
|
+
if (m === null)
|
|
249
|
+
continue;
|
|
250
|
+
const task = await this.entry(dir, m[1], lifecycle);
|
|
251
|
+
if (task !== null)
|
|
252
|
+
out.push(task);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
async taskLoad(prjId, taskId) {
|
|
257
|
+
const file = this.file(prjId, taskId);
|
|
258
|
+
if (!await exists(file))
|
|
259
|
+
return null;
|
|
260
|
+
/* an empty task file reads as no task (as before the task store) */
|
|
261
|
+
const text = await fs.promises.readFile(file, "utf8");
|
|
262
|
+
if (text === "")
|
|
263
|
+
return null;
|
|
264
|
+
return TaskFormat.parseTaskText(taskId, text, await this.model(prjId));
|
|
265
|
+
}
|
|
266
|
+
async taskSave(prjId, taskId, plan) {
|
|
267
|
+
const file = this.file(prjId, taskId);
|
|
268
|
+
const result = await exists(file) ? "updated" : "created";
|
|
269
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
270
|
+
await writeFileAtomic(file, TaskFormat.formatTaskText(plan), { encoding: "utf8" });
|
|
271
|
+
await this.written(prjId, taskId);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
async taskDelete(prjId, taskId) {
|
|
275
|
+
const file = this.file(prjId, taskId);
|
|
276
|
+
if (!await exists(file))
|
|
277
|
+
return false;
|
|
278
|
+
await fs.promises.rm(file, { force: true });
|
|
279
|
+
await this.written(prjId, taskId);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
async taskRename(prjId, oldId, newId) {
|
|
283
|
+
const oldFile = this.file(prjId, oldId);
|
|
284
|
+
const newFile = this.file(prjId, newId);
|
|
285
|
+
if (!await exists(oldFile))
|
|
286
|
+
return false;
|
|
287
|
+
/* move atomically first and rewrite the header afterwards, as a crash
|
|
288
|
+
in between is harmless: parsing takes the "Id" from the filename */
|
|
289
|
+
await fs.promises.rename(oldFile, newFile);
|
|
290
|
+
const plan = TaskFormat.parseTaskText(newId, await fs.promises.readFile(newFile, "utf8"), await this.model(prjId));
|
|
291
|
+
await writeFileAtomic(newFile, TaskFormat.formatTaskText(plan), { encoding: "utf8" });
|
|
292
|
+
await this.written(prjId, oldId, newId);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
/* the referenced attachment files, confined to the project directory */
|
|
296
|
+
async fileRead(prjId, file) {
|
|
297
|
+
const dir = await fs.promises.realpath(this.dir(prjId)).catch(() => null);
|
|
298
|
+
const full = dir !== null ? await fs.promises.realpath(path.resolve(dir, file)).catch(() => null) : null;
|
|
299
|
+
if (dir === null || full === null || !full.startsWith(dir + path.sep))
|
|
300
|
+
return null;
|
|
301
|
+
const st = await fs.promises.stat(full).catch(() => null);
|
|
302
|
+
if (st === null || !st.isFile())
|
|
303
|
+
return null;
|
|
304
|
+
return fs.promises.readFile(full);
|
|
305
|
+
}
|
|
306
|
+
/* ==== change detection ==== */
|
|
307
|
+
/* observe the changes of the task plan files made outside of this plugin instance */
|
|
308
|
+
watch(listener) {
|
|
309
|
+
this.listener = listener;
|
|
310
|
+
}
|
|
311
|
+
/* watch the base directory, or, as long as it does not exist yet, its
|
|
312
|
+
nearest existing ancestor for the creation of the next path step
|
|
313
|
+
towards it, re-arming once it appears (chokidar alone misses this) */
|
|
314
|
+
arm() {
|
|
315
|
+
if (!this.watching)
|
|
316
|
+
return;
|
|
317
|
+
const dir = this.basedir;
|
|
318
|
+
let w;
|
|
319
|
+
if (fs.existsSync(dir)) {
|
|
320
|
+
w = watch(dir, {
|
|
321
|
+
ignoreInitial: true,
|
|
322
|
+
depth: this.solo ? 0 : 1,
|
|
323
|
+
ignored: (file, stats) => (file !== dir && file.endsWith(".lock"))
|
|
324
|
+
|| (stats?.isFile() === true && !TASK_FILE_RE.test(path.basename(file)))
|
|
325
|
+
});
|
|
326
|
+
w.on("all", () => this.schedule());
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
let base = path.dirname(dir);
|
|
330
|
+
while (!fs.existsSync(base) && path.dirname(base) !== base)
|
|
331
|
+
base = path.dirname(base);
|
|
332
|
+
const next = path.join(base, path.relative(base, dir).split(path.sep)[0]);
|
|
333
|
+
w = watch(base, {
|
|
334
|
+
ignoreInitial: true,
|
|
335
|
+
depth: 0,
|
|
336
|
+
ignored: (file) => file !== base && file !== next
|
|
337
|
+
});
|
|
338
|
+
const rearm = () => {
|
|
339
|
+
if (this.watcher !== w)
|
|
340
|
+
return;
|
|
341
|
+
this.watcher = null;
|
|
342
|
+
w.close().catch(() => { }).finally(() => {
|
|
343
|
+
this.arm();
|
|
344
|
+
this.schedule();
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
w.on("addDir", (file) => {
|
|
348
|
+
if (file === next)
|
|
349
|
+
rearm();
|
|
350
|
+
});
|
|
351
|
+
w.on("ready", () => {
|
|
352
|
+
if (fs.existsSync(next))
|
|
353
|
+
rearm();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
w.on("error", (err) => {
|
|
357
|
+
this.ctx.log("warning", `watcher: ${err instanceof Error ? err.message : String(err)}`);
|
|
358
|
+
});
|
|
359
|
+
this.watcher = w;
|
|
360
|
+
}
|
|
361
|
+
/* the stamp (modification time and size) of a non-empty task plan file (null if missing or empty) */
|
|
362
|
+
async stamp(file) {
|
|
363
|
+
const st = await fs.promises.stat(file).catch(() => null);
|
|
364
|
+
return st !== null && st.isFile() && st.size > 0 ? `${st.mtimeMs}:${st.size}` : null;
|
|
365
|
+
}
|
|
366
|
+
/* the stamps of the task plan files of a directory */
|
|
367
|
+
async scan(dir) {
|
|
368
|
+
const stamps = new Map();
|
|
369
|
+
const entries = await fs.promises.readdir(dir).catch((err) => {
|
|
370
|
+
if (err.code === "ENOENT")
|
|
371
|
+
return [];
|
|
372
|
+
throw err;
|
|
373
|
+
});
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
const m = TASK_FILE_RE.exec(entry);
|
|
376
|
+
if (m === null)
|
|
377
|
+
continue;
|
|
378
|
+
const stamp = await this.stamp(path.join(dir, entry));
|
|
379
|
+
if (stamp !== null)
|
|
380
|
+
stamps.set(m[1], stamp);
|
|
381
|
+
}
|
|
382
|
+
return stamps;
|
|
383
|
+
}
|
|
384
|
+
/* start resp. stop observing a project, taking the initial stamps of its directory */
|
|
385
|
+
async track(prjId) {
|
|
386
|
+
if (!this.watching || this.tracked.has(prjId))
|
|
387
|
+
return;
|
|
388
|
+
const dir = this.dir(prjId);
|
|
389
|
+
this.tracked.set(prjId, dir);
|
|
390
|
+
if (!this.stamps.has(dir))
|
|
391
|
+
this.stamps.set(dir, await this.scan(dir));
|
|
392
|
+
}
|
|
393
|
+
untrack(prjId) {
|
|
394
|
+
const dir = this.tracked.get(prjId);
|
|
395
|
+
this.tracked.delete(prjId);
|
|
396
|
+
if (dir !== undefined && ![...this.tracked.values()].includes(dir))
|
|
397
|
+
this.stamps.delete(dir);
|
|
398
|
+
}
|
|
399
|
+
/* record the stamps of the task plan files written by this plugin instance,
|
|
400
|
+
so the change detection does not report them as external changes */
|
|
401
|
+
async written(prjId, ...taskIds) {
|
|
402
|
+
const stamps = this.stamps.get(this.dir(prjId));
|
|
403
|
+
if (stamps === undefined)
|
|
404
|
+
return;
|
|
405
|
+
for (const taskId of taskIds) {
|
|
406
|
+
const stamp = await this.stamp(this.file(prjId, taskId));
|
|
407
|
+
if (stamp === null)
|
|
408
|
+
stamps.delete(taskId);
|
|
409
|
+
else
|
|
410
|
+
stamps.set(taskId, stamp);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/* schedule a rescan of the observed projects, debounced */
|
|
414
|
+
schedule() {
|
|
415
|
+
if (!this.watching)
|
|
416
|
+
return;
|
|
417
|
+
if (this.timer !== null)
|
|
418
|
+
clearTimeout(this.timer);
|
|
419
|
+
this.timer = setTimeout(() => {
|
|
420
|
+
this.timer = null;
|
|
421
|
+
this.rescan().catch(() => { });
|
|
422
|
+
}, 100);
|
|
423
|
+
this.timer.unref();
|
|
424
|
+
}
|
|
425
|
+
/* rescan the directories of the observed projects, never overlapping itself,
|
|
426
|
+
and report the task plan files whose stamps changed: as added if unknown
|
|
427
|
+
before, as deleted if gone, else as updated (to all projects of a directory) */
|
|
428
|
+
async rescan() {
|
|
429
|
+
if (this.scanning) {
|
|
430
|
+
this.pending = true;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.scanning = true;
|
|
434
|
+
try {
|
|
435
|
+
const dirs = new Map();
|
|
436
|
+
for (const [prjId, dir] of this.tracked)
|
|
437
|
+
dirs.set(dir, [...(dirs.get(dir) ?? []), prjId]);
|
|
438
|
+
for (const [dir, prjIds] of dirs) {
|
|
439
|
+
try {
|
|
440
|
+
const before = this.stamps.get(dir);
|
|
441
|
+
if (before === undefined)
|
|
442
|
+
continue;
|
|
443
|
+
const after = await this.scan(dir);
|
|
444
|
+
const lifecycle = await this.model(prjIds[0]);
|
|
445
|
+
const change = { added: [], updated: [], deleted: [] };
|
|
446
|
+
for (const [taskId, stamp] of after) {
|
|
447
|
+
if (before.get(taskId) === stamp)
|
|
448
|
+
continue;
|
|
449
|
+
const entry = await this.entry(dir, taskId, lifecycle);
|
|
450
|
+
if (entry !== null)
|
|
451
|
+
(before.has(taskId) ? change.updated : change.added).push(entry);
|
|
452
|
+
}
|
|
453
|
+
for (const taskId of before.keys())
|
|
454
|
+
if (!after.has(taskId))
|
|
455
|
+
change.deleted.push(taskId);
|
|
456
|
+
/* update the stamps in place (and only of the reported task plans),
|
|
457
|
+
as the own writes meanwhile already recorded theirs */
|
|
458
|
+
if (!this.watching || this.stamps.get(dir) !== before)
|
|
459
|
+
continue;
|
|
460
|
+
for (const entry of [...change.added, ...change.updated])
|
|
461
|
+
before.set(entry.id, after.get(entry.id));
|
|
462
|
+
for (const taskId of change.deleted)
|
|
463
|
+
before.delete(taskId);
|
|
464
|
+
if (this.listener !== null && (change.added.length > 0 || change.updated.length > 0 || change.deleted.length > 0))
|
|
465
|
+
for (const prjId of prjIds)
|
|
466
|
+
this.listener(prjId, change);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
this.ctx.log("warning", `scanning directory "${dir}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
this.scanning = false;
|
|
475
|
+
if (this.pending) {
|
|
476
|
+
this.pending = false;
|
|
477
|
+
this.schedule();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/* the plugin factory */
|
|
483
|
+
const factory = (ctx) => new FileTaskStoragePlugin(ctx);
|
|
484
|
+
export default factory;
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { pathToFileURL } from "node:url";
|
|
8
|
+
import PQueue from "p-queue";
|
|
9
|
+
/* the methods every storage plugin has to provide */
|
|
10
|
+
const pluginMethods = [
|
|
11
|
+
"open", "close",
|
|
12
|
+
"projectList", "projectGet", "projectSet", "projectDelete",
|
|
13
|
+
"taskList", "taskLoad", "taskSave", "taskDelete", "taskRename"
|
|
14
|
+
];
|
|
15
|
+
/* the name of the default built-in storage plugin, and the modules of all built-in ones */
|
|
16
|
+
export const BUILTIN_PLUGIN = "ase";
|
|
17
|
+
const builtinPlugins = {
|
|
18
|
+
ase: "./ase-task-store-plugin-ase.js",
|
|
19
|
+
github: "./ase-task-store-plugin-github.js",
|
|
20
|
+
gitlab: "./ase-task-store-plugin-gitlab.js",
|
|
21
|
+
gitea: "./ase-task-store-plugin-gitea.js"
|
|
22
|
+
};
|
|
23
|
+
/* resolve a storage plugin name onto its module specifier: the name
|
|
24
|
+
"ase" (or no name) selects the default built-in plugin, "github", "gitlab", resp. "gitea"
|
|
25
|
+
the built-in GitHub, GitLab, resp. Gitea Issues plugin, any other plain name selects the NPM
|
|
26
|
+
package "ase-task-store-<name>", a relative or absolute path is taken
|
|
27
|
+
as a local module, and anything else is passed through as a module specifier */
|
|
28
|
+
export const resolveTaskStoragePlugin = (name) => {
|
|
29
|
+
if (name === null || name === "")
|
|
30
|
+
name = BUILTIN_PLUGIN;
|
|
31
|
+
if (Object.hasOwn(builtinPlugins, name))
|
|
32
|
+
return new URL(builtinPlugins[name], import.meta.url).href;
|
|
33
|
+
if (/^[A-Za-z0-9_-]+$/.test(name))
|
|
34
|
+
return `ase-task-store-${name}`;
|
|
35
|
+
if (name.startsWith(".") || path.isAbsolute(name))
|
|
36
|
+
return pathToFileURL(path.resolve(name)).href;
|
|
37
|
+
return name;
|
|
38
|
+
};
|
|
39
|
+
/* load a storage plugin by name, instantiate it through its factory
|
|
40
|
+
with the given context, and verify it provides the plugin interface */
|
|
41
|
+
export const loadTaskStoragePlugin = async (name, ctx) => {
|
|
42
|
+
const specifier = resolveTaskStoragePlugin(name);
|
|
43
|
+
name ??= BUILTIN_PLUGIN;
|
|
44
|
+
let mod;
|
|
45
|
+
try {
|
|
46
|
+
mod = await import(specifier);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
throw new Error(`task store: failed to load storage plugin "${name}" (module "${specifier}"): ${message}`, { cause: err });
|
|
51
|
+
}
|
|
52
|
+
const factory = mod?.default;
|
|
53
|
+
if (typeof factory !== "function")
|
|
54
|
+
throw new Error(`task store: storage plugin "${name}" (module "${specifier}") ` +
|
|
55
|
+
"does not default-export a plugin factory function");
|
|
56
|
+
let plugin;
|
|
57
|
+
try {
|
|
58
|
+
plugin = factory(ctx);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new Error(`task store: storage plugin "${name}" factory failed: ${message}`, { cause: err });
|
|
63
|
+
}
|
|
64
|
+
if (typeof plugin !== "object" || plugin === null)
|
|
65
|
+
throw new Error(`task store: storage plugin "${name}" factory did not return a plugin object`);
|
|
66
|
+
if (typeof plugin.name !== "string" || plugin.name === "")
|
|
67
|
+
throw new Error(`task store: storage plugin "${name}" lacks the "name" property`);
|
|
68
|
+
for (const method of pluginMethods)
|
|
69
|
+
if (typeof plugin[method] !== "function")
|
|
70
|
+
throw new Error(`task store: storage plugin "${name}" lacks the "${method}" method`);
|
|
71
|
+
for (const method of ["lock", "fileRead", "watch", "projectMark"])
|
|
72
|
+
if (plugin[method] !== undefined && typeof plugin[method] !== "function")
|
|
73
|
+
throw new Error(`task store: storage plugin "${name}" provides a non-function "${method}" property`);
|
|
74
|
+
return plugin;
|
|
75
|
+
};
|
|
76
|
+
/* the storage delegate the REST API server operates on: it forwards
|
|
77
|
+
every persistence operation into the loaded storage plugin and
|
|
78
|
+
additionally serializes the operations per project, as the part
|
|
79
|
+
endpoints are read-modify-write cycles on entire plans */
|
|
80
|
+
export class TaskStore {
|
|
81
|
+
plugin;
|
|
82
|
+
queues = new Map();
|
|
83
|
+
constructor(plugin) {
|
|
84
|
+
this.plugin = plugin;
|
|
85
|
+
}
|
|
86
|
+
/* the name of the underlying storage plugin */
|
|
87
|
+
get name() {
|
|
88
|
+
return this.plugin.name;
|
|
89
|
+
}
|
|
90
|
+
/* whether the underlying storage plugin stores task plans verbatim */
|
|
91
|
+
get verbatim() {
|
|
92
|
+
return this.plugin.verbatim === true;
|
|
93
|
+
}
|
|
94
|
+
/* run an operation of a project strictly serialized: in-process
|
|
95
|
+
queued in arrival order (hence not re-entrant) and additionally
|
|
96
|
+
under the cross-process lock of the storage plugin (if any) */
|
|
97
|
+
async serialize(prjId, op) {
|
|
98
|
+
let queue = this.queues.get(prjId);
|
|
99
|
+
if (queue === undefined) {
|
|
100
|
+
queue = new PQueue({ concurrency: 1 });
|
|
101
|
+
queue.addEventListener("idle", () => this.queues.delete(prjId));
|
|
102
|
+
this.queues.set(prjId, queue);
|
|
103
|
+
}
|
|
104
|
+
return queue.add(() => this.plugin.lock !== undefined ? this.plugin.lock(prjId, op) : op());
|
|
105
|
+
}
|
|
106
|
+
/* delegate the storage lifecycle, closing only after all queued operations settled */
|
|
107
|
+
open() { return this.plugin.open(); }
|
|
108
|
+
async close() {
|
|
109
|
+
while (this.queues.size > 0)
|
|
110
|
+
await Promise.all(Array.from(this.queues.values(), (queue) => queue.onIdle()));
|
|
111
|
+
return this.plugin.close();
|
|
112
|
+
}
|
|
113
|
+
/* delegate the project registry operations */
|
|
114
|
+
projectList() { return this.plugin.projectList(); }
|
|
115
|
+
projectGet(prjId) { return this.plugin.projectGet(prjId); }
|
|
116
|
+
projectSet(prjId, lifecycle, idscheme) { return this.plugin.projectSet(prjId, lifecycle, idscheme); }
|
|
117
|
+
projectDelete(prjId) { return this.plugin.projectDelete(prjId); }
|
|
118
|
+
/* delegate the optional high-water mark persistence (a no-op if unsupported) */
|
|
119
|
+
projectMark(prjId, seqmark) {
|
|
120
|
+
return this.plugin.projectMark !== undefined ? this.plugin.projectMark(prjId, seqmark) : Promise.resolve();
|
|
121
|
+
}
|
|
122
|
+
/* delegate the task plan operations */
|
|
123
|
+
taskList(prjId) { return this.plugin.taskList(prjId); }
|
|
124
|
+
taskLoad(prjId, taskId) { return this.plugin.taskLoad(prjId, taskId); }
|
|
125
|
+
taskSave(prjId, taskId, plan) { return this.plugin.taskSave(prjId, taskId, plan); }
|
|
126
|
+
taskDelete(prjId, taskId) { return this.plugin.taskDelete(prjId, taskId); }
|
|
127
|
+
taskRename(prjId, oldId, newId) { return this.plugin.taskRename(prjId, oldId, newId); }
|
|
128
|
+
/* delegate the optional observation of changes made outside of the plugin (a no-op if unsupported) */
|
|
129
|
+
watch(listener) {
|
|
130
|
+
this.plugin.watch?.(listener);
|
|
131
|
+
}
|
|
132
|
+
/* delegate the optional referenced file reading (null if unsupported) */
|
|
133
|
+
get canReadFiles() { return this.plugin.fileRead !== undefined; }
|
|
134
|
+
fileRead(prjId, file) {
|
|
135
|
+
return this.plugin.fileRead !== undefined ? this.plugin.fileRead(prjId, file) : Promise.resolve(null);
|
|
136
|
+
}
|
|
137
|
+
}
|