dsh-tower 2.0.4 → 2.1.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/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +427 -0
- package/domain/contracts/outputs.js +305 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +102 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -11,21 +11,54 @@
|
|
|
11
11
|
{ "id": "git", "status": "pass", "detail": "git version 2.55.0" },
|
|
12
12
|
{ "id": "state", "status": "pass", "detail": ".../state.json revision 7" }
|
|
13
13
|
],
|
|
14
|
+
"archive": {
|
|
15
|
+
"missions": 3,
|
|
16
|
+
"archivedMissions": 12,
|
|
17
|
+
"stateBytes": 48211,
|
|
18
|
+
"archiveBytes": 190244,
|
|
19
|
+
"years": [2026],
|
|
20
|
+
"files": 2,
|
|
21
|
+
"unreadable": [],
|
|
22
|
+
"policy": { "enabled": true, "afterDays": 30, "maxMissions": 100 }
|
|
23
|
+
},
|
|
14
24
|
"environmentContract": {
|
|
15
25
|
"platform": "win32",
|
|
16
26
|
"gitVersion": "git version 2.55.0",
|
|
17
27
|
"repoRoot": "D:/repo",
|
|
18
28
|
"commonDir": "D:/repo/.git",
|
|
19
|
-
"stateDir": "D:/repo/.git/dsh-tower"
|
|
29
|
+
"stateDir": "D:/repo/.git/dsh-tower",
|
|
30
|
+
"caseInsensitive": true,
|
|
31
|
+
"comparisonMode": "case-insensitive"
|
|
20
32
|
}
|
|
21
33
|
}
|
|
22
34
|
```
|
|
23
35
|
|
|
36
|
+
`archive` 报告终态任务归档:活跃 `missions`、`archivedMissions` 与 `stateBytes`,归档目录的
|
|
37
|
+
`archiveBytes`/`years`/`files`,每条 `unreadable` 诊断,以及配置的 `policy`(`enabled`、`afterDays`、
|
|
38
|
+
`maxMissions`)。归档目录不存在是健康的空归档(`archive` 检查为 `pass`);截断的 index/year 文件是
|
|
39
|
+
`warn` 并附带诊断,且归档会暂停而不是覆盖它——归档不可读期间没有数据会丢失,符合条件的 mission 仍留在
|
|
40
|
+
`state.json`。
|
|
41
|
+
|
|
24
42
|
检查覆盖:git 可用性、仓库身份、state schema/加载、仓库配置是否存在、worker/reviewer preset 名称、
|
|
25
43
|
subagents 与 HMR 服务、workspace 锁持有者/是否过期、实时 roster、worktree 是否存在/归属/是否脏、
|
|
26
|
-
test-discovery
|
|
44
|
+
test-discovery 策略、完成报告大小、归档健康度、旧版快照、batch 与租约。被 `tower_teardown` 正常清理的 mission worktree 会被
|
|
27
45
|
跳过(检查中会注明已移除数量),不会被误报为 missing。`warn` 是提示;`fail` 表示仓库在 spawn/merge 前需要
|
|
46
|
+
|
|
47
|
+
`caseInsensitive` 是 `tower_init` 在检出目录所在文件系统上一次性探测的结果;`null` 表示“尚未探测”,
|
|
48
|
+
此时 `case-mode` 检查为 `warn`。`comparisonMode` 是 scope 匹配实际使用的比较方式:
|
|
49
|
+
`case-insensitive` 会折叠双方大小写,`case-sensitive` 则保持逐字节精确。doctor 从不执行探测(探测会
|
|
50
|
+
创建临时文件,只读调用不能写入);在没有可用写权限的检出上运行 `tower_init` 会记录该模式,在此之前
|
|
51
|
+
使用安全的 `case-sensitive` 默认值。
|
|
52
|
+
|
|
53
|
+
检查覆盖:git 可用性、仓库身份、state schema/加载、已探测的大小写模式、仓库配置是否存在、
|
|
54
|
+
worker/reviewer preset 名称、subagents 与 HMR 服务、workspace 锁持有者/是否过期、实时 roster、
|
|
55
|
+
worktree 是否存在/归属/是否脏、test-discovery 策略、旧版快照、batch 与租约。被 `tower_teardown`
|
|
56
|
+
正常清理的 mission worktree 会被跳过(检查中会注明已移除数量),不会被误报为 missing。`warn` 是提示;`fail` 表示仓库在 spawn/merge 前需要
|
|
28
57
|
处理。state 加载失败会作为失败的检查报告而不是抛出,因此 doctor 在损坏的仓库上仍然有用。
|
|
29
58
|
|
|
59
|
+
`reports` 检查会指出仓库中最大的完成报告(`<bytes> B on <mission>`,对照 `gates.maxReportBytes` 上限),
|
|
60
|
+
同样的数据也以 `reports: { count, largest: { missionId, bytes } | null, capBytes }` 返回。上限生效前写入的
|
|
61
|
+
报告只会产生 `warn`,doctor 绝不会重写它们。
|
|
62
|
+
|
|
30
63
|
环境契约仅供参考;它不是身份或授权证明。`tower_init` 仍要求控制塔角色,`tower_spawn` 仍会检查
|
|
31
64
|
实时子代理目录。
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[English](../../reference/errors.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
|
|
6
|
-
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义
|
|
6
|
+
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 35 个工具错误码。
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
[tower:mission-not-found] unknown mission "M9"
|
|
@@ -20,15 +20,19 @@
|
|
|
20
20
|
| `mission-not-found` | 不存在该 id 的 mission。 | 过期或拼错的 `M9` |
|
|
21
21
|
| `finding-not-found` | 不存在该 id 的 finding。 | `tower_findings({id:"F99"})` |
|
|
22
22
|
| `invalid-transition` | 请求的状态转换不被允许。 | 尝试 blocked→merged;finding 状态图之外的流转;reviewer 修改 finding;在还有未完成任务、blocker、无提交或未读消息时完成 mission。 |
|
|
23
|
+
| `stale-base` | 操作准备期间仓库状态已变化,需要重试。 | two-phase mutation 重校验 |
|
|
23
24
|
| `worktree-dirty` | mission worktree 有未提交改动,不能完成。 | `tower_complete` 前遗留 staged/modified/untracked 文件 |
|
|
24
25
|
| `state-locked` | 另一个 Tower 写入者持有 state 锁。 | 锁超时后的并发写 |
|
|
25
|
-
| `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json` |
|
|
26
|
+
| `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json`;非法 JSON;没有可用的 `schemaVersion` |
|
|
27
|
+
| `state-version-unsupported` | state 文件的 schema 版本高于本构建支持的版本,或无法迁移。 | 打开由更新插件写入的仓库;未知的旧版本;本构建拒绝降级 |
|
|
28
|
+
| `state-migration-failed` | state 迁移抛出异常;原文件未被修改。 | 迁移步骤中的 bug;查看迁移前的 `state.json.v<N>.bak` |
|
|
26
29
|
| `config-invalid` | Tower 配置无效。 | 格式错误的仓库 `config.json`、违反 schema 的值 |
|
|
27
30
|
| `config-unknown-key` | Tower 配置存在未知顶层键。 | 例如把键拼成 `allowAgentToggle` |
|
|
28
31
|
| `git-failed` | git 命令因未归入其他分类的原因失败。 | 被拒绝的合并命令、非法 ref |
|
|
29
32
|
| `git-timeout` | git 命令超时或输出超出缓冲区。 | 慢 pre-merge 钩子、超大 diff |
|
|
30
33
|
| `spawn-unavailable` | subagents 服务不可用。 | 没有 `subagents` 的 Host |
|
|
31
34
|
| `provider-missing` | 没有注册的 subagent provider。 | provider 列表为空 |
|
|
35
|
+
| `provider-cooldown` | 连续可重试的 provider 失败后,spawn 进入短暂冷却。 | 等待 `blockedUntil` 后重试 |
|
|
32
36
|
| `preset-missing` | 配置的 agent preset 未注册。 | 缺少 `tower-worker`/`tower-reviewer` preset 行、DSH 未重启 |
|
|
33
37
|
| `reviewer-prerequisite` | reviewer spawn 的前置条件未满足。 | mission 尚未 `completed` 或还没有 worktree 就 spawn reviewer |
|
|
34
38
|
| `not-authorized` | 调用方 agent 角色无权执行该操作。 | worker 调用仅限控制塔的工具,或修改 mission 状态/scope;reviewer 修改 finding |
|
|
@@ -32,16 +32,16 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
32
32
|
| 工具 | 参数 | 用途 / 说明 |
|
|
33
33
|
|---|---|---|
|
|
34
34
|
| `tower_init` | `base?`、`autoGitInit?`、`snapshotDirty?`、`reanchor?` | 创建 `<git-common-dir>/dsh-tower/state.json`。`autoGitInit` 执行 `git init` 并创建空的初始提交;`snapshotDirty` 记录一个游离快照提交作为 mission 基线;`reanchor` 仅在没有非终态 mission 时修改基线。返回 `dirty`、`snapshotCommit`、`reanchored`。 |
|
|
35
|
-
| `tower_plan` | `missions[]`(`title
|
|
35
|
+
| `tower_plan` | `missions[]`(`title`——可打印 Unicode,NFC 规范化,≤120 字符、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。mission id 保持 ASCII(`M1`、`M2`);不可见字符与超长标题会以 `invalid-argument` 拒绝。`reviewPolicy:"none"` 需要非空理由;审查门禁开启时,preflight 还要求 `gates.allowReviewExemption`。 |
|
|
36
36
|
| `tower_mission` | `id`(别名 `missionId`)、`status?`、`task_done?`、`task_drop?`+`task_drop_reason?`、`blocker?`、`clear_blockers?`、`note?`、`scope?`、`pause_cause?`、`authorization?` | 更新单个 mission。worker 可安全使用的字段是 task/blocker/note;不能在这里设置 `merged`。暂停的 mission 记录 `pause_cause`(user/failure/resource/dependency);恢复用户暂停的 mission 会遵循 `mode.requireResumeAuthorization`。 |
|
|
37
|
-
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` |
|
|
37
|
+
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。分页 mission 只带 `reportBytes`;`missionId` 返回完整详情(任务/备注/审查/finding/完成报告正文)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。 |
|
|
38
38
|
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer`。`takeover:true` 替换已不再运行的 worker 条目(ownerGeneration 递增)。每个 mission 只能有一个活跃 worker;`spawn.maxLiveWorkers` 限制整个仓库。 |
|
|
39
39
|
| `tower_roster` | — | roster 及来自子代理目录的实时活动(`running`/`inactive`/`missing`/`unknown`)与 `stale` 标记。只读。 |
|
|
40
40
|
| `tower_adopt` | `retireMissing?` | 把 roster 条目与持久子代理目录对账。可证明已消失的 agent 会被标记为 retired(默认),或标记为 `missing`(`retireMissing:false`)以便检查/接管。 |
|
|
41
41
|
| `tower_diff` | `missionId`(别名 `id`)、`maxBytes?` | 只读 merge-base diff:文件、提交、有上限的 patch。供 reviewer 安全使用。 |
|
|
42
42
|
| `tower_send` | `to`、`subject`、`body`、`scope?`、`interrupt?`、`consentRef?` | 向 roster 名称、`tower` 或 `all` 发送持久消息;对运行中的 agent 会即时投递。`interrupt:true` 先中断运行中的回合(Kimi 风格)再投递;`consentRef` 记录用于审计。受 `send.maxPerMinute` 限速。 |
|
|
43
43
|
| `tower_inbox` | `limit?`、`include_read?` | 读取并确认发给调用者的消息;未读消息会阻止 `tower_complete`。 |
|
|
44
|
-
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
44
|
+
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`report` 受 `gates.maxReportBytes`(默认 32768 UTF-8 字节)硬上限约束:超限会以 `invalid-argument` 拒绝并给出 `nextAction`(把全文写进 mission worktree 的文件),绝不截断;存储时会记录 `reportBytes`。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
45
45
|
| `tower_interrupt` | `to?`(roster 名称)、`agentId?`、`reason?` | 通过 `subagents.interrupt` 中断一个运行中的 roster agent,并记录 `state:"interrupted"`。幂等(未运行 → `interrupted:false`)。 |
|
|
46
46
|
| `tower_finding` | `type`、`title`、`summary`、`details`、`suggested_fix`、`severity?`、`missionId?`、`location?` | 记录 scope 之外的问题;reviewer 用它代替直接改文件。新 finding 初始为 `open`。 |
|
|
47
47
|
| `tower_findings` | `id?`、`status?`、`note?`、过滤(`type?`、`severity?`、`missionId?`、`open?`、`limit?`) | 生命周期工具:列出/读取/流转。状态为 `open → triaged → fixed`/`wontfix`,允许重新打开。只有控制塔或 worker 能修改;reviewer 只能列出/读取。 |
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Pure batch lifecycle decisions and status projection.
|
|
2
|
+
//
|
|
3
|
+
// `domain/batch.js` owns the admission/close evidence validation; this module
|
|
4
|
+
// owns the id assignment, close/abandon record transitions, and status payload
|
|
5
|
+
// that app/batch.js used to keep inline. Receipt file reads stay in the tool
|
|
6
|
+
// layer.
|
|
7
|
+
// @ts-check
|
|
8
|
+
import { TowerToolError } from '../errors.js'
|
|
9
|
+
import { summarize } from '../batch.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [code]
|
|
16
|
+
* @returns {never}
|
|
17
|
+
*/
|
|
18
|
+
function fail(message, code = 'batch-invalid') {
|
|
19
|
+
throw new TowerToolError(code, message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Next free `B<n>` id.
|
|
24
|
+
* @param {any[]} batches
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function nextBatchId(batches) {
|
|
28
|
+
const max = batches.reduce((value, batch) => {
|
|
29
|
+
const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
|
|
30
|
+
return match ? Math.max(value, Number(match[1])) : value
|
|
31
|
+
}, 0)
|
|
32
|
+
return `B${max + 1}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Project the persisted batch record from normalized create input.
|
|
37
|
+
* @param {string} id
|
|
38
|
+
* @param {any} normalized
|
|
39
|
+
* @param {string} actor
|
|
40
|
+
* @param {number} now
|
|
41
|
+
* @returns {any}
|
|
42
|
+
*/
|
|
43
|
+
export function createBatchRecord(id, normalized, actor, now) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
status: 'open',
|
|
47
|
+
...normalized,
|
|
48
|
+
createdBy: actor,
|
|
49
|
+
createdAt: now,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mark a validated open batch closed with its evidence snapshot.
|
|
55
|
+
* @param {any} batch
|
|
56
|
+
* @param {{ checks?: unknown, treeTip?: unknown, receiptHashes?: unknown }} options
|
|
57
|
+
* @param {string} actor
|
|
58
|
+
* @param {number} now
|
|
59
|
+
* @returns {any}
|
|
60
|
+
*/
|
|
61
|
+
export function closeBatchRecord(batch, options, actor, now) {
|
|
62
|
+
batch.status = 'closed'
|
|
63
|
+
batch.closedAt = now
|
|
64
|
+
batch.closedBy = actor
|
|
65
|
+
batch.close = {
|
|
66
|
+
checks: options.checks ?? [],
|
|
67
|
+
treeTip: options.treeTip ?? null,
|
|
68
|
+
receiptHashes: options.receiptHashes ?? {},
|
|
69
|
+
at: now,
|
|
70
|
+
}
|
|
71
|
+
return batch
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A closed batch cannot be abandoned; an already-abandoned one is a no-op.
|
|
76
|
+
* @param {any} batch
|
|
77
|
+
* @param {string} batchId
|
|
78
|
+
* @returns {{ alreadyAbandoned: boolean }}
|
|
79
|
+
*/
|
|
80
|
+
export function abandonBatchCheck(batch, batchId) {
|
|
81
|
+
if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
|
|
82
|
+
return { alreadyAbandoned: batch.status === 'abandoned' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply an abandonment to the draft batch.
|
|
87
|
+
* @param {{ batch: any, actor: string, reason?: unknown, now: number }} params
|
|
88
|
+
* @returns {any}
|
|
89
|
+
*/
|
|
90
|
+
export function applyBatchAbandon({ batch, actor, reason, now }) {
|
|
91
|
+
batch.status = 'abandoned'
|
|
92
|
+
batch.abandonedAt = now
|
|
93
|
+
batch.abandonedBy = actor
|
|
94
|
+
batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
|
|
95
|
+
return batch
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read-only `tower_batch status` payload.
|
|
100
|
+
* @param {TowerState} state
|
|
101
|
+
* @param {string | undefined} batchId
|
|
102
|
+
* @returns {any}
|
|
103
|
+
*/
|
|
104
|
+
export function batchStatusPayload(state, batchId) {
|
|
105
|
+
const batches = state.batches ?? []
|
|
106
|
+
const batch = batchId ? batches.find((entry) => entry.id === batchId) : null
|
|
107
|
+
if (batchId && !batch) fail(`unknown batch "${batchId}"`)
|
|
108
|
+
return {
|
|
109
|
+
batches: batches.map((entry) => summarize(state, entry)),
|
|
110
|
+
...(batch ? { batch: summarize(state, batch) } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Pure message routing, read tracking, and inbox projections.
|
|
2
|
+
//
|
|
3
|
+
// app/messages.js owns the state lock, load, persistence, and activity log;
|
|
4
|
+
// app/roster.js's unreadFor delegates to `unreadMessages` here.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
9
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message
|
|
13
|
+
* @returns {never}
|
|
14
|
+
*/
|
|
15
|
+
function fail(message) {
|
|
16
|
+
throw new TowerToolError('invalid-argument', message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate the recipient against the roster plus the synthetic `tower` name.
|
|
21
|
+
* @param {{ roster?: RosterEntry[], to: string, from: string }} params
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
24
|
+
export function assertMessageRecipient({ roster = [], to, from }) {
|
|
25
|
+
const names = new Set(roster.map((entry) => entry.name))
|
|
26
|
+
names.add('tower')
|
|
27
|
+
if (to !== 'all' && !names.has(to)) {
|
|
28
|
+
fail(`unknown message recipient "${to}" (known: ${[...names].join(', ')}, all)`)
|
|
29
|
+
}
|
|
30
|
+
if (to === from) fail('refusing to send a message to yourself')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Project the persisted message record.
|
|
35
|
+
* @param {any} input
|
|
36
|
+
* @param {number} seq
|
|
37
|
+
* @param {number} now
|
|
38
|
+
* @returns {any}
|
|
39
|
+
*/
|
|
40
|
+
export function createMessage(input, seq, now) {
|
|
41
|
+
return {
|
|
42
|
+
id: `msg-${seq}`,
|
|
43
|
+
seq,
|
|
44
|
+
from: input.from,
|
|
45
|
+
to: input.to,
|
|
46
|
+
subject: input.subject,
|
|
47
|
+
body: input.body,
|
|
48
|
+
scope: input.scope ?? null,
|
|
49
|
+
consentRef: input.consentRef ?? null,
|
|
50
|
+
at: now,
|
|
51
|
+
readBy: [],
|
|
52
|
+
delivery: [],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append delivery results to a message, preserving earlier entries.
|
|
58
|
+
* @param {{ delivery?: unknown[] }} message
|
|
59
|
+
* @param {unknown[]} results
|
|
60
|
+
* @returns {unknown[]}
|
|
61
|
+
*/
|
|
62
|
+
export function addDelivery(message, results) {
|
|
63
|
+
message.delivery = [...(message.delivery ?? []), ...results]
|
|
64
|
+
return message.delivery
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Inbox page plus read-marking mutation. Returns `changed` so the use case can
|
|
69
|
+
* decide whether the state file needs a write.
|
|
70
|
+
* @param {any[]} messages
|
|
71
|
+
* @param {{ reader: string, limit?: number, includeRead?: boolean }} input
|
|
72
|
+
* @param {number} now
|
|
73
|
+
* @returns {{ payload: any, changed: boolean }}
|
|
74
|
+
*/
|
|
75
|
+
export function inboxView(messages, input, now) {
|
|
76
|
+
const reader = input.reader
|
|
77
|
+
const limit = Math.max(1, Math.min(100, input.limit ?? 20))
|
|
78
|
+
const includeRead = input.includeRead === true
|
|
79
|
+
const visible = [...messages]
|
|
80
|
+
.reverse()
|
|
81
|
+
.filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
|
|
82
|
+
const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
|
|
83
|
+
const page = (includeRead ? visible : unread).slice(0, limit)
|
|
84
|
+
|
|
85
|
+
let changed = false
|
|
86
|
+
for (const message of page) {
|
|
87
|
+
if (!(message.readBy ?? []).includes(reader)) {
|
|
88
|
+
message.readBy = [...(message.readBy ?? []), reader]
|
|
89
|
+
message.readAt = { ...(message.readAt ?? {}), [reader]: now }
|
|
90
|
+
changed = true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
|
|
94
|
+
return {
|
|
95
|
+
payload: {
|
|
96
|
+
reader,
|
|
97
|
+
messages: page.map((message) => ({
|
|
98
|
+
id: message.id,
|
|
99
|
+
from: message.from,
|
|
100
|
+
to: message.to,
|
|
101
|
+
subject: message.subject,
|
|
102
|
+
body: message.body,
|
|
103
|
+
scope: message.scope ?? null,
|
|
104
|
+
at: message.at,
|
|
105
|
+
delivery: message.delivery ?? [],
|
|
106
|
+
ackAt: message.readAt?.[reader] ?? null,
|
|
107
|
+
})),
|
|
108
|
+
pageSize: page.length,
|
|
109
|
+
unreadRemaining,
|
|
110
|
+
},
|
|
111
|
+
changed,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Unread inbox rows for one recipient (messages from self never count).
|
|
117
|
+
* @param {TowerState} state
|
|
118
|
+
* @param {string} name
|
|
119
|
+
* @returns {any[]}
|
|
120
|
+
*/
|
|
121
|
+
export function unreadMessages(state, name) {
|
|
122
|
+
return (state.messages ?? []).filter((message) => {
|
|
123
|
+
if (message.from === name) return false
|
|
124
|
+
if (message.to !== name && message.to !== 'all') return false
|
|
125
|
+
return !(/** @type {any[]} */ (message.readBy ?? [])).includes(name)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Pure roster observation and stale/inactive policy.
|
|
2
|
+
//
|
|
3
|
+
// app/roster.js owns the state lock, load, persistence, and activity log; the
|
|
4
|
+
// transitions below mutate only the in-memory roster rows they are given.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { ROSTER_INACTIVE } from '../roster.js'
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {RosterEntry} entry
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
export function clearStale(entry) {
|
|
15
|
+
delete entry.stale
|
|
16
|
+
delete entry.staleAt
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Count repeated absence observations and list rows ready to adopt.
|
|
21
|
+
* @param {RosterEntry[]} roster
|
|
22
|
+
* @param {{ present?: unknown[], absent?: unknown[], confirmations?: unknown }} observations
|
|
23
|
+
* @param {number} now
|
|
24
|
+
* @returns {{ candidates: string[], changed: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function recordRosterObservations(roster, { present = [], absent = [], confirmations = 2 }, now) {
|
|
27
|
+
const presentIds = new Set(present)
|
|
28
|
+
const absentIds = new Set(absent)
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const candidates = []
|
|
31
|
+
let changed = false
|
|
32
|
+
for (const entry of roster) {
|
|
33
|
+
if (presentIds.has(entry.agentId)) {
|
|
34
|
+
if (entry.absentObservations !== undefined) {
|
|
35
|
+
delete entry.absentObservations
|
|
36
|
+
delete entry.absentSince
|
|
37
|
+
changed = true
|
|
38
|
+
}
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!absentIds.has(entry.agentId)) continue
|
|
42
|
+
entry.absentObservations = (/** @type {number} */ (entry.absentObservations ?? 0)) + 1
|
|
43
|
+
entry.absentSince ??= now
|
|
44
|
+
changed = true
|
|
45
|
+
const observations = /** @type {number} */ (entry.absentObservations)
|
|
46
|
+
if (observations >= Math.max(2, Number(confirmations) || 2)) {
|
|
47
|
+
candidates.push(/** @type {string} */ (entry.agentId))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { candidates, changed }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Release live slots whose catalog activity is explicitly inactive.
|
|
55
|
+
* @param {RosterEntry[]} roster
|
|
56
|
+
* @param {Record<string, string>} activity
|
|
57
|
+
* @param {number} now
|
|
58
|
+
* @returns {{ released: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
export function reconcileObservedInactive(roster, activity = {}, now) {
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const released = []
|
|
63
|
+
for (const entry of roster) {
|
|
64
|
+
if (entry.state === 'reserved' || ROSTER_INACTIVE.has(entry.state)) continue
|
|
65
|
+
const agentId = /** @type {string} */ (entry.agentId)
|
|
66
|
+
if (activity[agentId] !== 'inactive') continue
|
|
67
|
+
entry.state = 'inactive'
|
|
68
|
+
entry.outcome = 'catalog reported inactive'
|
|
69
|
+
entry.outcomeAt = now
|
|
70
|
+
clearStale(entry)
|
|
71
|
+
released.push(entry.name)
|
|
72
|
+
}
|
|
73
|
+
return { released }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Retire entries by agent id, skipping rows already retired.
|
|
78
|
+
* @param {RosterEntry[]} roster
|
|
79
|
+
* @param {unknown[]} agentIds
|
|
80
|
+
* @param {number} now
|
|
81
|
+
* @returns {{ retired: string[] }}
|
|
82
|
+
*/
|
|
83
|
+
export function retireEntries(roster, agentIds, now) {
|
|
84
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
85
|
+
/** @type {string[]} */
|
|
86
|
+
const retired = []
|
|
87
|
+
for (const entry of roster) {
|
|
88
|
+
if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
|
|
89
|
+
entry.state = 'retired'
|
|
90
|
+
entry.retiredAt = now
|
|
91
|
+
clearStale(entry)
|
|
92
|
+
retired.push(/** @type {string} */ (entry.agentId))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { retired }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark entries missing unless they are already inactive.
|
|
100
|
+
* @param {RosterEntry[]} roster
|
|
101
|
+
* @param {unknown[]} agentIds
|
|
102
|
+
* @param {number} now
|
|
103
|
+
* @returns {{ missing: string[] }}
|
|
104
|
+
*/
|
|
105
|
+
export function markMissingEntries(roster, agentIds, now) {
|
|
106
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
107
|
+
/** @type {string[]} */
|
|
108
|
+
const missing = []
|
|
109
|
+
for (const entry of roster) {
|
|
110
|
+
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
111
|
+
entry.state = 'missing'
|
|
112
|
+
entry.missingSince = now
|
|
113
|
+
clearStale(entry)
|
|
114
|
+
missing.push(/** @type {string} */ (entry.agentId))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { missing }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Watchdog sweep: catalog-inactive rows become inactive; rows absent from an
|
|
122
|
+
* authoritative catalog and older than `staleMs` become stale. Unknown catalog
|
|
123
|
+
* state is never treated as stopped.
|
|
124
|
+
* @param {RosterEntry[]} roster
|
|
125
|
+
* @param {{ activity?: Record<string, string>, staleMs?: unknown, authoritative?: unknown }} input
|
|
126
|
+
* @param {number} now
|
|
127
|
+
* @returns {{ marked: string[], idled: string[] }}
|
|
128
|
+
*/
|
|
129
|
+
export function sweepRosterState(roster, input = {}, now) {
|
|
130
|
+
const staleMs = Number(input.staleMs) || 0
|
|
131
|
+
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
132
|
+
const activity = input.activity ?? {}
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const marked = []
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const idled = []
|
|
137
|
+
for (const entry of roster) {
|
|
138
|
+
if (ROSTER_INACTIVE.has(entry.state)) continue
|
|
139
|
+
const observed = activity[/** @type {string} */ (entry.agentId)]
|
|
140
|
+
if (observed === 'inactive') {
|
|
141
|
+
entry.state = 'inactive'
|
|
142
|
+
entry.outcome = entry.outcome ?? 'idle (catalog)'
|
|
143
|
+
entry.outcomeAt = now
|
|
144
|
+
idled.push(entry.name)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (observed === 'running') continue
|
|
148
|
+
if (observed !== undefined) continue
|
|
149
|
+
const age = now - Number(entry.spawnedAt ?? now)
|
|
150
|
+
if (age > staleMs) {
|
|
151
|
+
entry.state = 'stale'
|
|
152
|
+
entry.stale = true
|
|
153
|
+
entry.staleAt = now
|
|
154
|
+
marked.push(entry.name)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { marked, idled }
|
|
158
|
+
}
|