@veewo/dsh-better-tasks 0.4.4 → 0.4.5
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/README.md +6 -6
- package/docs/install-rc3.md +9 -9
- package/docs/release.md +4 -2
- package/docs/rollback.md +3 -1
- package/docs/task-order-persistence.md +25 -0
- package/lib/client.js +10 -18
- package/package.json +2 -1
- package/src/client.js +4 -1
- package/src/index.js +4 -6
- package/src/native-pin-host.mjs +97 -145
- package/src/pin-client.mjs +5 -16
package/README.md
CHANGED
|
@@ -4,15 +4,15 @@ DSH 侧栏任务泳道:共享原生 Pin、独立任务输入、Final 预览与
|
|
|
4
4
|
|
|
5
5
|
## 按 DSH 版本安装
|
|
6
6
|
|
|
7
|
-
**当前 rc.3 分发组合:DSH Desktop `0.2.2-rc.3` + Better Tasks `0.4.
|
|
7
|
+
**当前 rc.3 分发组合:DSH Desktop `0.2.2-rc.3` + Better Tasks `0.4.5`(Pre-release)。**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
已合并初始化优化:不扫描会话历史、按持久化的完整顺序恢复任务,并区分顺序恢复与旧队列导入提示;同时保留子/孙 agent 活跃状态修复和选中标题主题蓝色。安装必须取齐[rc.3 完整七包](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>),按[rc.3 安装说明](<docs/install-rc3.md>)处理;npm 主包不是完整运行时组合。以下 rc.2 入口保留供仍在使用 rc.2 的用户。
|
|
10
10
|
|
|
11
11
|
0.4.1 包含新任务排序与斜杠提交修复,必须同步更新主包和本 Release 的 conversation fork,见[安装说明](<docs/install.md>)。旧 [0.4.0 组合](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.2-tasks-v0.4.0>)保留供回滚。
|
|
12
12
|
|
|
13
13
|
| DSH Desktop 运行时 | Better Tasks | 完整安装入口 |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| `0.2.2-rc.3` | `0.4.
|
|
15
|
+
| `0.2.2-rc.3` | `0.4.5` | [dsh-v0.2.2-rc.3-tasks-v0.4.5](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>)(会话活跃态与选中标题修复) |
|
|
16
16
|
| `0.2.2-rc.2` | `0.4.1` | [dsh-v0.2.2-rc.2-tasks-v0.4.1](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.2-tasks-v0.4.1>)(含输入框回退修复) |
|
|
17
17
|
| `0.2.1-rc.2` | `0.3.2` | [历史插件](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/v0.3.2>) + [对应 fork 库](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/better-tasks-forks-0.2.1-rc.2-r1>) |
|
|
18
18
|
| `0.2.0-rc.2` | `0.3.1` | [历史发行版](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/v0.3.1>) |
|
|
@@ -21,8 +21,8 @@ DSH 侧栏任务泳道:共享原生 Pin、独立任务输入、Final 预览与
|
|
|
21
21
|
|
|
22
22
|
**完整安装 = 插件本体 + 同一 Release 的六个 UI fork,共 7 个 `.tgz`。** 源码 ZIP 和单独 npm 包都不是完整安装包。六个 fork 保留原生包名,属于本项目兼容性修改,并非 DeepSeek 官方发行包。
|
|
23
23
|
|
|
24
|
-
1.
|
|
25
|
-
2.
|
|
24
|
+
1. 按表格中的完整 DSH 版本选择 Release,取齐七包、`release-manifest.json`、`SHA256SUMS.txt` 和 `INSTALL.md`;rc.3 使用 0.4.5,rc.2 保留 0.4.1。
|
|
25
|
+
2. 按对应的 [rc.3 安装说明](<docs/install-rc3.md>)或 [rc.2 安装说明](<docs/install.md>)校验并备份自己的 Profile,配置 `nodeLinker: isolated` 与六个精确的 `publicHoistPattern`。
|
|
26
26
|
3. 完全退出 Desktop(含托盘),安装七包,再手动启动验收。升级与卸载见[回滚说明](<docs/rollback.md>)。
|
|
27
27
|
|
|
28
28
|
本次完整 Release 包含最新 `ui-user-questions` 修复。较早的 `better-tasks-forks-0.2.2-rc.2` 是历史 fork 库,不含这次最终修复,不要与本次七包混用。
|
|
@@ -47,7 +47,7 @@ Source patches live under [src-patches](<src-patches/>), project-owned modules u
|
|
|
47
47
|
|
|
48
48
|
**仅在 `main` 上开发和提交**;不再维护按功能、发布或 DSH 版本划分的长期分支。已有分支仅保留历史引用。支持版本只由 `main` 上不可变的 `dsh-v<DSH版本>-tasks-v<Tasks版本>` annotated tag 与完整 Release 标记,`main` 的最新提交不等于已经发布或适配完成,详见[发布规范](<docs/release.md>)。
|
|
49
49
|
|
|
50
|
-
rc.3 的 HOC 与六个 fork 来源见[0.4.2 适配记录](<docs/dsh-0.2.2-rc.3-support.md>);0.4.4
|
|
50
|
+
rc.3 的 HOC 与六个 fork 来源见[0.4.2 适配记录](<docs/dsh-0.2.2-rc.3-support.md>);0.4.5 合并[初始化与完整顺序持久化](<docs/task-order-persistence.md>),并保留 0.4.4 的[会话活跃状态与选中标题修复](<docs/task-session-activity.md>)。精确七包哈希及启动结果随 Release 的 `validation.json` 交付。rc.2 入口保持不变,不混装旧 rc.3 产物或仅按 npm latest 安装。
|
|
51
51
|
|
|
52
52
|
侧栏新增按钮、调整排版或适配新 DSH 前,先遵循[侧栏排版与适配规范](<docs/sidebar-layout.md>);数字、原生语义样式与版本合同分层维护。
|
|
53
53
|
|
package/docs/install-rc3.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# 安装:DSH 0.2.2-rc.3 / Tasks 0.4.
|
|
1
|
+
# 安装:DSH 0.2.2-rc.3 / Tasks 0.4.5
|
|
2
2
|
|
|
3
|
-
组合标识:`dsh-v0.2.2-rc.3-tasks-v0.4.
|
|
3
|
+
组合标识:`dsh-v0.2.2-rc.3-tasks-v0.4.5`。完整七包见[固定版本 Release](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>)。这是面向 RC 运行时的 Pre-release;npm 主包单独安装不能替代七包组合。以随包 `validation.json` 的目标版本、启动结果与清理结果为准;没有 PASS 记录的组合不得安装。不自动升级或重启真实 Desktop。
|
|
4
4
|
|
|
5
5
|
## 1. 先确认目标运行时
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ rc.2 用户保留 [Tasks 0.4.1 七包](<https://github.com/chanyuenpang/dsh-bett
|
|
|
12
12
|
|
|
13
13
|
从固定版本 Release 下载全部独立资产(不是 GitHub 的 Source code ZIP),必须包含以下七包,及 `release-manifest.json`、`SHA256SUMS.txt`、`INSTALL.md`、`ROLLBACK.md`、`validation.json`:
|
|
14
14
|
|
|
15
|
-
- `veewo-dsh-better-tasks-0.4.
|
|
15
|
+
- `veewo-dsh-better-tasks-0.4.5.tgz`
|
|
16
16
|
- `deepseek-ai-dsh-client-ui-layout-0.2.2-rc.3.tgz`
|
|
17
17
|
- `deepseek-ai-dsh-client-ui-workspace-0.2.2-rc.3.tgz`
|
|
18
18
|
- `deepseek-ai-dsh-client-ui-user-questions-0.2.2-rc.3.tgz`
|
|
@@ -25,16 +25,16 @@ rc.2 用户保留 [Tasks 0.4.1 七包](<https://github.com/chanyuenpang/dsh-bett
|
|
|
25
25
|
```powershell
|
|
26
26
|
$ErrorActionPreference = 'Stop'
|
|
27
27
|
# 替换为刚解压且已经核对的组合目录;此步不修改 DSH。
|
|
28
|
-
$downloadDir = (Resolve-Path './dsh-v0.2.2-rc.3-tasks-v0.4.
|
|
28
|
+
$downloadDir = (Resolve-Path './dsh-v0.2.2-rc.3-tasks-v0.4.5').Path
|
|
29
29
|
$manifest = Get-Content (Join-Path $downloadDir 'release-manifest.json') -Raw | ConvertFrom-Json
|
|
30
30
|
$validation = Get-Content (Join-Path $downloadDir 'validation.json') -Raw | ConvertFrom-Json
|
|
31
|
-
if ($manifest.tag -ne 'dsh-v0.2.2-rc.3-tasks-v0.4.
|
|
32
|
-
$manifest.dshVersion -ne '0.2.2-rc.3' -or $manifest.tasksVersion -ne '0.4.
|
|
31
|
+
if ($manifest.tag -ne 'dsh-v0.2.2-rc.3-tasks-v0.4.5' -or
|
|
32
|
+
$manifest.dshVersion -ne '0.2.2-rc.3' -or $manifest.tasksVersion -ne '0.4.5' -or
|
|
33
33
|
$validation.result -ne 'PASS' -or $validation.dshVersion -ne '0.2.2-rc.3' -or
|
|
34
34
|
!$validation.cleanupComplete -or @($manifest.assets).Count -ne 7) {
|
|
35
35
|
throw '候选版本、数量或启动验证不符;不要安装'
|
|
36
36
|
}
|
|
37
|
-
$expected = @('veewo-dsh-better-tasks-0.4.
|
|
37
|
+
$expected = @('veewo-dsh-better-tasks-0.4.5.tgz')
|
|
38
38
|
$expected += 'layout','workspace','user-questions','input-trigger','commands','conversation' |
|
|
39
39
|
ForEach-Object { "deepseek-ai-dsh-client-ui-$_-0.2.2-rc.3.tgz" }
|
|
40
40
|
if (@(Compare-Object ($expected | Sort-Object) (@($manifest.assets.filename) | Sort-Object)).Count) {
|
|
@@ -87,8 +87,8 @@ if ($LASTEXITCODE -ne 0) { throw '安装失败;保持 Desktop 停止并恢复
|
|
|
87
87
|
|
|
88
88
|
手动启动同一套 rc.3 Desktop。启动证据仅确认核心 shell/侧栏/Session/Workspace/会话可激活,没有启动错误;**不代替用户功能和外观验收**。
|
|
89
89
|
|
|
90
|
-
0.4.
|
|
90
|
+
0.4.5 恢复 `sidebar.session.action` 原生 HOC 接点。没有可选 action 时新会话按钮不变;DeepSeek 会话导入仍是官方实验性可选 bundle,默认不由 Better Tasks 启用。需要时在插件页启用官方导入插件,其入口通过标准 HOC 出现在新会话行;工作区选择继续用官方 factory。创建会话权限不足时整行不渲染,Better Tasks 不拥有导入解析或 Session 持久化逻辑。
|
|
91
91
|
|
|
92
|
-
0.4.
|
|
92
|
+
0.4.5 还修复任务卡仅按主 agent 判断 idle 的问题:原生目录中已知子/孙 agent 仍在运行时保持 running;最后一个停止后恢复 idle。选中的会话标题改为官方主题蓝色,适配两种标题强调方式,卡片本身仍保留原来的状态色。本版已完整合入初始化和顺序持久化优化:恢复已保存的完整任务顺序,不再扫描 Session 目录/历史或计算首工作时间;新增、拖动、移除均持久化顺序,初始化显示“正在恢复任务顺序”,只有真实旧队列导入时才显示合并提示。旧字段保持可读、旧队列保持只读,既有旧数据不删除。详见[完整顺序合同](<task-order-persistence.md>)。六个 fork 仍沿用 0.4.2 已验证字节。
|
|
93
93
|
|
|
94
94
|
请自行验收上述状态和选中标题、原生新会话、任务 Pin/排序、独立输入、问答和斜杠提交;遇到异常保留那次 Console 堆栈。回滚见随包 `ROLLBACK.md` 或[回滚说明](<rollback.md>):先退出、恢复匹配 rc.3 的完整七包与配置;如退回 rc.2,必须把 DSH 运行时和原 rc.2 七包作为整体恢复,不能只降主包。会话、原生 Pin、排序、外观和设置数据不删除。
|
package/docs/release.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
## 当前公开组合与 tag 规则
|
|
4
4
|
|
|
5
5
|
- DSH Desktop 运行时:**0.2.2-rc.3**。
|
|
6
|
-
- Better Tasks npm/package 版本:**0.4.
|
|
7
|
-
- Git tag / GitHub Release:**`dsh-v0.2.2-rc.3-tasks-v0.4.
|
|
6
|
+
- Better Tasks npm/package 版本:**0.4.5**。
|
|
7
|
+
- Git tag / GitHub Release:**`dsh-v0.2.2-rc.3-tasks-v0.4.5`**(Pre-release)。
|
|
8
8
|
- 安装入口:[rc.3 安装说明](<install-rc3.md>);旧 rc.2 / Tasks 0.4.1 的[安装说明](<install.md>)和资产保持不变,兼容表见 [README](<../README.md>)。
|
|
9
9
|
|
|
10
10
|
今后统一使用 `dsh-v<DSH版本>-tasks-v<Tasks版本>`。DSH 版本写完整,保留 rc 后缀;Tasks 版本仍由插件自己的 manifest 管理。同一 Tasks 源码可适配多条 DSH 线,但每条线都要有独立验证的组合 Release。已发布的 tag 与资产 URL 不移动、不覆盖;内容变化应升 Tasks 版本并创建新组合 tag。面向 RC 运行时的本次组合标记为 GitHub Pre-release,不依赖全局 Latest 作为安装入口。
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
## 本次组合与历史内容
|
|
21
21
|
|
|
22
|
+
0.4.5 / rc.3:在 0.4.4 的 subagent 活跃态和选中标题修复上,完整合入初始化优化、唯一完整顺序持久化及恢复/旧队列导入提示分离。排序不再调用 listSessions/listEvents 或读取历史首工作时间;旧 native-order schema 保持可读,旧队列只读,导入仍有副作用前栅栏与中断不重试规则。完整七包独立通过本轮启动门禁后交付;六个 fork 不改字节。0.4.4 tag 与资产保留,不覆盖。
|
|
23
|
+
|
|
22
24
|
0.4.4 / rc.3:在已验证的 0.4.2 上修复子/孙 agent 活跃时任务卡误显示 idle,并用官方主题蓝色标识当前选中会话标题。主包更新,六个 fork 保留 0.4.2 的已验证字节;完整七包有独立最终组合启动证据。0.4.3 是已安装的本地先行候选,不覆盖它的本地包或既有 tag。并行开发的排序重构明确不在这次发布中。GitHub 分发完整七包;npm 只发布 `@veewo/dsh-better-tasks` 主包,不向上游 `@deepseek-ai` scope 发布 fork,且不表示 npm 单包可独立安装。
|
|
23
25
|
|
|
24
26
|
0.4.2 / rc.3:主包声明 root/hoc 的 `sidebar.session.action`,把原生新会话内容作为第三参 children 交给渲染器;整行受创建权限保护,可选动作不增加服务硬依赖。六个 fork 按精确 rc.3 基线从最新 overlay/patch 重建,保留 workspace picker factory、问答回退、原生 Pin、导航与真实仲裁 Promise。候选使用新的完整七包、清单与独立 rc.3 Desktop 启动证据,不沿用旧 rc.3 库的接受结论。最终结果以随包 `validation.json` 为准。
|
package/docs/rollback.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# 卸载 / 回滚 Better Tasks
|
|
2
2
|
|
|
3
|
-
## rc.3 / Tasks 0.4.
|
|
3
|
+
## rc.3 / Tasks 0.4.5 回滚
|
|
4
|
+
|
|
5
|
+
0.4.5 合并初始化与完整顺序持久化优化;回到 0.4.4 应恢复该版本的完整七包与配置,保留现有 Session、原生 Pin 和存储。旧版不会自动恢复从未落盘的历史显示顺序;不要删除排序域或旧队列来“重置”升级。
|
|
4
6
|
|
|
5
7
|
[rc.3 候选](<install-rc3.md>)必须整体保留主包与六个 rc.3 fork。安装失败时保持 Desktop 退出,恢复安装前 Profile 配置与同一运行时的完整七包;仅把主包降到 rc.2 的 0.4.1 不是合法回滚。若要退回 rc.2,须先恢复相应官方运行时,再恢复原 rc.2 / Tasks 0.4.1 七包,不能跨线混用。以下移除插件与六个 fork 的通用步骤同样适用;不删除任何 Session/Pin/排序/设置数据。
|
|
6
8
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# 完整任务顺序持久化(Tasks 0.4.5)
|
|
2
|
+
|
|
3
|
+
任务成员仍由官方 Workspace Pin 唯一拥有。Better Tasks 的 native-order v1 记录只维护完整顺序 sessionIds、已提交 revision 和一次性旧队列导入 journal,不拥有第二份成员集合。
|
|
4
|
+
|
|
5
|
+
- 新 Pin 的成员插入完整顺序顶部并直接触发保存,不必先打开任务视图。同一快照批量新增保留快照顺序,连续新增事件后者在前。
|
|
6
|
+
- 手动拖动保存完整顺序;取消 Pin/归档保存删除后的顺序。隐藏空白、子代理、冷目录项仍占持久位置,客户端过滤不能删除它们。
|
|
7
|
+
- 启动按已保存 sessionIds 原序恢复,补入当前官方新增成员,剔除已移除成员。原生 Pin 数组的纯重排、消息、标题、工作时间及目录加载不会重排已有成员;已 Pin 空白转为非空也不二次提顶。
|
|
8
|
+
- 排序不读取 listSessions/listEvents 或会话历史,不再计算首工作时间。旧 manualSessionIds/manualOrderAt/时间映射保持 schema 可读,但不再参与排序,新提交不再写这些字段。无法恢复旧版从未持久化的临时显示顺序。
|
|
9
|
+
- 启动存储打开期间与写入期间保留有序成员变化,尤其取消 Pin 再重新 Pin 同一 ID 必须作为重新插入,不只采用最后一个成员集合。
|
|
10
|
+
|
|
11
|
+
## 提交、失败与恢复
|
|
12
|
+
|
|
13
|
+
所有顺序写入由同一 Host 队列串行执行。成功持久化后才推进 revision;拖动通过 expectedRevision CAS 拒绝过时请求。存储失败不改变官方 Pin,不推进已提交 revision,不伪报拖动成功。成员仍即时可见或移除,未保存的完整顺序保留在内存并显式告警,拖动暂停;后续 GET、焦点刷新或成员事件可重试,成功后清除瞬时错误。客户端刷新有界,不无限轮询。
|
|
14
|
+
|
|
15
|
+
官方成员域和排序域没有公共原子事务:两者提交之间若进程崩溃,重启会从官方成员补齐缺项,但不能重建崩溃窗口内从未落盘的全部插入先后。不通过私有事务、回滚 Pin 或历史扫描猜测补救。
|
|
16
|
+
|
|
17
|
+
## 旧队列与提示
|
|
18
|
+
|
|
19
|
+
旧 better_tasks_pin_queue 保持只读。仅缺少 native-order 时尝试一次导入;保留 seed 顺序、先持久化 attempted 栅栏再调用官方 Pin、用户 touch/archive 优先、失败 partial、中断 uncertain 且不自动重试的规则。没有待导入成员时直接 done,不伪造 running。
|
|
20
|
+
|
|
21
|
+
初始化显示“正在恢复任务顺序”;只有真正旧队列 running 才显示“正在合并已有置顶任务”。这不是所有外部存储或 Pin I/O 都有固定耗时上限的承诺。
|
|
22
|
+
|
|
23
|
+
## 验证边界
|
|
24
|
+
|
|
25
|
+
单元/合同覆盖旧记录原序恢复、查询零调用、未打开任务页即保存、插入/拖动/删除后重启、并发和重入、失败告警与恢复、旧队列安全导入及提示分离。真实 profile 修改前仍须精确候选隔离冷启动,关闭 Cindy、隔离全部存储、确认核心 shell 激活并清理;启动门禁不代替用户功能验收,不自动重启真实 Desktop。
|
package/lib/client.js
CHANGED
|
@@ -70,7 +70,9 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
|
|
71
71
|
const armMigrationRefresh = () => {
|
|
72
72
|
stopTimer()
|
|
73
|
-
|
|
73
|
+
const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
|
|
74
|
+
const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
|
|
75
|
+
if (!started || disposed || !(restoring || saving)) { polls = 0; return }
|
|
74
76
|
if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
|
|
75
77
|
polls++
|
|
76
78
|
timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
|
|
@@ -118,17 +120,6 @@ window.__ModuleLoader__.load({
|
|
|
118
120
|
}
|
|
119
121
|
binding = next; generation++
|
|
120
122
|
let signature, closed = false
|
|
121
|
-
let visible = new Set()
|
|
122
|
-
const observeStartedTasks = () => {
|
|
123
|
-
if (closed || disposed || !nativeReady()) return
|
|
124
|
-
const sessions = next.sessions?.getSnapshot?.()
|
|
125
|
-
if (sessions?.phase !== 'ready') return
|
|
126
|
-
const nextVisible = new Set(native.pinnedSessionIds.filter(id => sessions.byId?.[id]?.blank === false))
|
|
127
|
-
const appeared = [...nextVisible].some(id => !visible.has(id))
|
|
128
|
-
visible = nextVisible
|
|
129
|
-
// A pre-existing Pin can become a new task without a workspace change.
|
|
130
|
-
if (appeared && started) void refresh().catch(() => {})
|
|
131
|
-
}
|
|
132
123
|
const observe = () => {
|
|
133
124
|
if (closed || disposed) return
|
|
134
125
|
const snapshot = next.source.getSnapshot()
|
|
@@ -136,14 +127,12 @@ window.__ModuleLoader__.load({
|
|
|
136
127
|
const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
|
|
137
128
|
const changed = signature !== value
|
|
138
129
|
signature = value; publish()
|
|
139
|
-
observeStartedTasks()
|
|
140
130
|
if (changed && nativeReady() && started) void refresh().catch(() => {})
|
|
141
131
|
}
|
|
142
132
|
const off = next.source.subscribe(observe)
|
|
143
|
-
const offSessions = typeof next.sessions?.subscribe === 'function' ? next.sessions.subscribe(observeStartedTasks) : () => {}
|
|
144
133
|
disconnect = () => {
|
|
145
134
|
if (closed) return
|
|
146
|
-
closed = true; off();
|
|
135
|
+
closed = true; off(); stopTimer(); generation++
|
|
147
136
|
if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
|
|
148
137
|
}
|
|
149
138
|
observe()
|
|
@@ -174,7 +163,7 @@ window.__ModuleLoader__.load({
|
|
|
174
163
|
actionError = null; countPending(sessionId, 1)
|
|
175
164
|
return enqueue(async () => {
|
|
176
165
|
if (!loaded || !writable) await readOrder()
|
|
177
|
-
if (!writable) throw new Error('Task order is not ready
|
|
166
|
+
if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
|
|
178
167
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
179
168
|
const queue = source.getSnapshot().queue
|
|
180
169
|
if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
|
|
@@ -856,7 +845,7 @@ window.__ModuleLoader__.load({
|
|
|
856
845
|
* browser's localStorage is an append log, so records from earlier builds stay
|
|
857
846
|
* forever and only payloads whose revision matches the delivered build count.
|
|
858
847
|
*/
|
|
859
|
-
const BUILD_REVISION = '
|
|
848
|
+
const BUILD_REVISION = 'da11927b17db72c2a819ac8fc74933039cf592c8ba30b2b9efc45149c0b9c0ca'
|
|
860
849
|
/** Render probe: on by default, so a delivered build can prove the column rendered. */
|
|
861
850
|
const RENDER_PROBE = true
|
|
862
851
|
/** Per-render payload; rewritten on every invocation. */
|
|
@@ -964,6 +953,7 @@ window.__ModuleLoader__.load({
|
|
|
964
953
|
'tasks.todoInProgress': '进行中',
|
|
965
954
|
'tasks.todoPending': '待处理',
|
|
966
955
|
'tasks.empty': '还没有置顶会话',
|
|
956
|
+
'tasks.orderRestoring': '正在恢复任务顺序…',
|
|
967
957
|
'tasks.pinMigration': '正在合并已有置顶任务…',
|
|
968
958
|
'tasks.expandGoal': '展开 Goal',
|
|
969
959
|
'tasks.collapseGoal': '收起 Goal',
|
|
@@ -1068,6 +1058,7 @@ window.__ModuleLoader__.load({
|
|
|
1068
1058
|
'tasks.todoInProgress': 'In progress',
|
|
1069
1059
|
'tasks.todoPending': 'Pending',
|
|
1070
1060
|
'tasks.empty': 'No pinned sessions yet',
|
|
1061
|
+
'tasks.orderRestoring': 'Restoring task order…',
|
|
1071
1062
|
'tasks.pinMigration': 'Importing existing pinned tasks…',
|
|
1072
1063
|
'tasks.expandGoal': 'Expand Goal',
|
|
1073
1064
|
'tasks.collapseGoal': 'Collapse Goal',
|
|
@@ -2209,7 +2200,8 @@ window.__ModuleLoader__.load({
|
|
|
2209
2200
|
style: { '--dbt-projection-font-size': `${preferences.detailFontSize}px` },
|
|
2210
2201
|
},
|
|
2211
2202
|
appearanceState.status === 'unavailable' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.appearanceUnavailable')) : null,
|
|
2212
|
-
|
|
2203
|
+
taskPins.migration === 'initializing' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.orderRestoring')) : null,
|
|
2204
|
+
taskPins.migration === 'running' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
|
|
2213
2205
|
taskPins.error !== null || moveError !== '' ? element('div', { className: 'dbt-task-warning', role: 'status' }, moveError || taskPins.error) : null,
|
|
2214
2206
|
actionWarning === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, actionWarning),
|
|
2215
2207
|
tasks.length === 0 ? element('div', { className: 'dbt-task-empty' }, t('tasks.empty')) :
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veewo/dsh-better-tasks",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH Desktop sidebar replacement with native pinned-task cards and independent task input surfaces.",
|
|
6
6
|
"publishConfig": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"docs/sidebar-layout.md",
|
|
31
31
|
"docs/rc2-slash-submit-catch.md",
|
|
32
32
|
"docs/task-session-activity.md",
|
|
33
|
+
"docs/task-order-persistence.md",
|
|
33
34
|
"docs/release.md",
|
|
34
35
|
"docs/rollback.md"
|
|
35
36
|
],
|
package/src/client.js
CHANGED
|
@@ -139,6 +139,7 @@ const dictionaries = {
|
|
|
139
139
|
'tasks.todoInProgress': '进行中',
|
|
140
140
|
'tasks.todoPending': '待处理',
|
|
141
141
|
'tasks.empty': '还没有置顶会话',
|
|
142
|
+
'tasks.orderRestoring': '正在恢复任务顺序…',
|
|
142
143
|
'tasks.pinMigration': '正在合并已有置顶任务…',
|
|
143
144
|
'tasks.expandGoal': '展开 Goal',
|
|
144
145
|
'tasks.collapseGoal': '收起 Goal',
|
|
@@ -243,6 +244,7 @@ const dictionaries = {
|
|
|
243
244
|
'tasks.todoInProgress': 'In progress',
|
|
244
245
|
'tasks.todoPending': 'Pending',
|
|
245
246
|
'tasks.empty': 'No pinned sessions yet',
|
|
247
|
+
'tasks.orderRestoring': 'Restoring task order…',
|
|
246
248
|
'tasks.pinMigration': 'Importing existing pinned tasks…',
|
|
247
249
|
'tasks.expandGoal': 'Expand Goal',
|
|
248
250
|
'tasks.collapseGoal': 'Collapse Goal',
|
|
@@ -1384,7 +1386,8 @@ function TaskSwimlane({ useSessions, useWorkspaces, useSessionStatus, useTaskCon
|
|
|
1384
1386
|
style: { '--dbt-projection-font-size': `${preferences.detailFontSize}px` },
|
|
1385
1387
|
},
|
|
1386
1388
|
appearanceState.status === 'unavailable' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.appearanceUnavailable')) : null,
|
|
1387
|
-
|
|
1389
|
+
taskPins.migration === 'initializing' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.orderRestoring')) : null,
|
|
1390
|
+
taskPins.migration === 'running' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
|
|
1388
1391
|
taskPins.error !== null || moveError !== '' ? element('div', { className: 'dbt-task-warning', role: 'status' }, moveError || taskPins.error) : null,
|
|
1389
1392
|
actionWarning === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, actionWarning),
|
|
1390
1393
|
tasks.length === 0 ? element('div', { className: 'dbt-task-empty' }, t('tasks.empty')) :
|
package/src/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from './pin-model.mjs'
|
|
10
10
|
import { installProjectAppearance } from './project-appearance.mjs'
|
|
11
11
|
import { createFinalMessageHttpHandler } from './final-message.mjs'
|
|
12
|
-
import { createNativePinHost
|
|
12
|
+
import { createNativePinHost } from './native-pin-host.mjs'
|
|
13
13
|
import { createTaskExecutionProjection } from './task-execution.mjs'
|
|
14
14
|
|
|
15
15
|
export const name = 'dsh-better-tasks'
|
|
@@ -75,10 +75,11 @@ export const pinQueueDomain = defineDomain({
|
|
|
75
75
|
export const nativePinOrderDomain = defineDomain({
|
|
76
76
|
name: 'better_tasks_native_order', version: 1,
|
|
77
77
|
tables: { state: domainTable(pinQueueRecord.extend({
|
|
78
|
-
//
|
|
78
|
+
// Read compatibility only. sessionIds is the sole order; new writes omit
|
|
79
|
+
// the former manual subset and time-derived sorting metadata.
|
|
79
80
|
manualSessionIds: z.array(z.string().min(1)).refine((ids) => new Set(ids).size === ids.length).optional(),
|
|
80
81
|
manualOrderAt: z.number().int().nonnegative().optional(),
|
|
81
|
-
//
|
|
82
|
+
// Old admission/work caches are accepted, never read to determine order.
|
|
82
83
|
taskCreatedAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
|
|
83
84
|
taskFirstWorkAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
|
|
84
85
|
migration: z.object({
|
|
@@ -194,9 +195,6 @@ export async function apply(ctx) {
|
|
|
194
195
|
}
|
|
195
196
|
const controller = createNativePinHost({
|
|
196
197
|
registry: ctx.workspaceRegistry,
|
|
197
|
-
listSessions: (signal) => ctx.sessionQuery.listSessions(signal),
|
|
198
|
-
readTaskStartedAt: async (header, signal) => typeof ctx.sessionQuery.listEvents === 'function'
|
|
199
|
-
? taskFirstWorkAt(await ctx.sessionQuery.listEvents(header.id, signal)) : undefined,
|
|
200
198
|
openOrder: () => open(nativePinOrderDomain, 'state'),
|
|
201
199
|
openLegacy: () => open(pinQueueDomain, 'queues'),
|
|
202
200
|
subscribe: listener => ctx.on('domain/changed', listener),
|
package/src/native-pin-host.mjs
CHANGED
|
@@ -1,131 +1,75 @@
|
|
|
1
|
-
// Official Workspace pins own membership. This controller owns
|
|
2
|
-
// at-most-once legacy import journal
|
|
1
|
+
// Official Workspace pins own membership. This controller owns one complete order
|
|
2
|
+
// and a sealed, at-most-once legacy import journal; it never compensates native Pin.
|
|
3
3
|
const NATIVE_ORDER_KEY = 'global'
|
|
4
4
|
const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index])
|
|
5
5
|
const ownedIds = value => {
|
|
6
6
|
if (!Array.isArray(value) || value.some(id => typeof id !== 'string' || id === '') || new Set(value).size !== value.length) throw new Error('Invalid native pin/order identities')
|
|
7
7
|
return [...value]
|
|
8
8
|
}
|
|
9
|
-
const projectIds = (rank, pins) => {
|
|
10
|
-
const pinned = new Set(pins), ranked = new Set(rank)
|
|
11
|
-
return [...rank.filter(id => pinned.has(id)), ...pins.filter(id => !ranked.has(id))]
|
|
12
|
-
}
|
|
13
9
|
const messageOf = error => String(error?.message ?? error).slice(0, 1000)
|
|
14
10
|
const changedIds = (a, b) => {
|
|
15
11
|
const before = new Set(a), after = new Set(b)
|
|
16
12
|
return [...a.filter(id => !after.has(id)), ...b.filter(id => !before.has(id))]
|
|
17
13
|
}
|
|
14
|
+
// New members join the complete sequence immediately. Pure native array reorders
|
|
15
|
+
// cannot move existing members; neither clocks nor Session content are involved.
|
|
16
|
+
const mergeOrder = (sequence, pins, reserved = new Set()) => {
|
|
17
|
+
const members = new Set(pins), ranked = new Set(sequence)
|
|
18
|
+
return [...pins.filter(id => !ranked.has(id)), ...sequence.filter(id => members.has(id) || reserved.has(id))]
|
|
19
|
+
}
|
|
18
20
|
export class NativePinError extends Error {
|
|
19
21
|
constructor(code, message, status = 400) { super(message); this.code = code; this.status = status }
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
/** First admitted work, not empty-file preparation time or later activity. */
|
|
23
|
-
export function taskFirstWorkAt(events) {
|
|
24
|
-
for (const event of events) {
|
|
25
|
-
if (['agent/inbox/spliced', 'user/message', 'turn/start'].includes(event.type)
|
|
26
|
-
&& Number.isSafeInteger(event.time) && event.time >= 0) return event.time
|
|
27
|
-
}
|
|
28
|
-
return undefined
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
/** open* returns {table,close}; subscribe returns an event unsubscriber. */
|
|
32
|
-
export function createNativePinHost({ registry, openOrder, openLegacy, subscribe,
|
|
33
|
-
let pins = [], archived = [], rank = [], revision = 0, updatedAt = now(), epoch = 0
|
|
34
|
-
let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true
|
|
35
|
-
let tail = Promise.resolve(), initialization, migration, reconcileQueued = false
|
|
36
|
-
const handles = new Set(), touched = new Set(), warnings = [], warningSet = new Set()
|
|
25
|
+
export function createNativePinHost({ registry, openOrder, openLegacy, subscribe, now = () => new Date().toISOString() }) {
|
|
26
|
+
let pins = [], archived = [], rank = [], sequence = [], revision = 0, updatedAt = now(), epoch = 0
|
|
27
|
+
let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true, hydrated = false
|
|
28
|
+
let tail = Promise.resolve(), initialization, migration, reconcileQueued = false, persistenceError = null
|
|
29
|
+
const snapshots = [], handles = new Set(), touched = new Set(), reservedIds = new Set(), warnings = [], warningSet = new Set()
|
|
37
30
|
const warn = error => { const text = messageOf(error); if (!warningSet.has(text)) { warningSet.add(text); warnings.push(text); if (warnings.length > 30) warnings.shift() } }
|
|
38
31
|
const enqueue = work => {
|
|
39
32
|
const next = tail.then(() => { if (disposed) throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503); return work() })
|
|
40
33
|
tail = next.catch(() => {})
|
|
41
34
|
return next
|
|
42
35
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const members = new Set(ids), manual = new Set(record.manualSessionIds)
|
|
54
|
-
// New tasks lead; an existing manual sequence never follows message activity.
|
|
55
|
-
return [...newestFirst(ids.filter(id => !manual.has(id))), ...record.manualSessionIds.filter(id => members.has(id))]
|
|
56
|
-
}
|
|
57
|
-
async function refreshCreatedAt() {
|
|
58
|
-
const before = orderedIds()
|
|
59
|
-
try {
|
|
60
|
-
if (typeof listSessions === 'function' && pins.some(id => !headers.has(id))) {
|
|
61
|
-
const rows = await listSessions(lifetime.signal)
|
|
62
|
-
if (disposed) return
|
|
63
|
-
for (const { header } of rows) if (pins.includes(header.id)) {
|
|
64
|
-
headers.set(header.id, header)
|
|
65
|
-
if (!createdAt.has(header.id) && Number.isSafeInteger(header.createdAt) && header.createdAt >= 0) createdAt.set(header.id, header.createdAt)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
for (const id of [...pins]) {
|
|
69
|
-
const header = headers.get(id)
|
|
70
|
-
// Forks inherit parent work; their own creation header remains the key.
|
|
71
|
-
if (!header || header.isSeeded === true) continue
|
|
72
|
-
let timestamp = firstWorkAt.get(id)
|
|
73
|
-
if (timestamp === undefined && typeof readTaskStartedAt === 'function') {
|
|
74
|
-
try { timestamp = await readTaskStartedAt(header, lifetime.signal) }
|
|
75
|
-
catch (error) { if (!disposed) warn(error); continue }
|
|
76
|
-
if (disposed) return
|
|
77
|
-
if (!pins.includes(id) || !Number.isSafeInteger(timestamp) || timestamp < 0) continue
|
|
78
|
-
firstWorkAt.set(id, timestamp); metadataDirty = true
|
|
79
|
-
// Only work born after the manual decision leads. A delayed birth
|
|
80
|
-
// read must not override an explicit drag made after that work began.
|
|
81
|
-
const manualAt = record?.manualOrderAt ?? Date.parse(record?.updatedAt)
|
|
82
|
-
const newerThanManual = Number.isFinite(manualAt) ? timestamp > manualAt : phase === 'done' || phase === 'partial'
|
|
83
|
-
if (newerThanManual && record?.manualSessionIds?.includes(id)) {
|
|
84
|
-
record = { ...record, manualSessionIds: record.manualSessionIds.filter(value => value !== id) }
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (timestamp !== undefined) createdAt.set(id, Math.max(createdAt.get(id) ?? 0, timestamp))
|
|
88
|
-
}
|
|
89
|
-
for (const map of [createdAt, firstWorkAt, headers]) for (const id of map.keys()) if (!pins.includes(id)) map.delete(id)
|
|
90
|
-
if (!sameIds(before, orderedIds())) { revision++; updatedAt = now() }
|
|
91
|
-
} catch (error) { if (!disposed) warn(error) }
|
|
36
|
+
const orderedIds = () => sequence.filter(id => pins.includes(id))
|
|
37
|
+
const orderReady = () => !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial')
|
|
38
|
+
const response = () => {
|
|
39
|
+
const errors = [...warnings, ...(persistenceError ? [persistenceError] : [])]
|
|
40
|
+
return {
|
|
41
|
+
queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
|
|
42
|
+
...(errors.length ? { warnings: errors } : {}),
|
|
43
|
+
migration: { status: phase },
|
|
44
|
+
writable: orderReady() && !persistenceError && sameIds(rank, orderedIds()),
|
|
45
|
+
}
|
|
92
46
|
}
|
|
93
|
-
const
|
|
94
|
-
queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
|
|
95
|
-
...(warnings.length === 0 ? {} : { warnings: [...warnings] }),
|
|
96
|
-
migration: { status: phase }, writable: !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial'),
|
|
97
|
-
})
|
|
47
|
+
const reservations = () => new Set([...reservedIds].filter(id => !touched.has(id) && !archived.includes(id)))
|
|
98
48
|
const nativeSnapshot = (nextPins, nextArchived, observe = true) => {
|
|
99
|
-
const
|
|
49
|
+
const a = ownedIds(nextArchived), excluded = new Set(a), p = ownedIds(nextPins).filter(id => !excluded.has(id))
|
|
100
50
|
nativeValid = true
|
|
101
51
|
if (sameIds(p, pins) && sameIds(a, archived)) return
|
|
52
|
+
const membershipChanged = changedIds(pins, p).length > 0 || changedIds(archived, a).length > 0
|
|
102
53
|
if (observe) for (const id of [...changedIds(pins, p), ...changedIds(archived, a)]) touched.add(id)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// A same-millisecond click must still precede the prior admission. This
|
|
112
|
-
// logical ordering key is never shown as a replacement Session date.
|
|
113
|
-
for (const timestamp of createdAt.values()) if (timestamp >= admittedAt && timestamp < Number.MAX_SAFE_INTEGER) admittedAt = timestamp + 1
|
|
114
|
-
for (const id of p) if (!previousMembers.has(id)) { createdAt.set(id, admittedAt); admittedHere.add(id) }
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
pins = p; archived = a; epoch++
|
|
118
|
-
if (!sameIds(previous, projectIds(rank, pins))) { revision++; updatedAt = now() }
|
|
54
|
+
pins = p; archived = a
|
|
55
|
+
if (!observe) { sequence = [...p]; return }
|
|
56
|
+
if (membershipChanged) epoch++
|
|
57
|
+
// Retain all transitions while opening storage, including Unpin -> Pin of
|
|
58
|
+
// the same id. One latest snapshot would lose its re-insertion position.
|
|
59
|
+
if (!hydrated) snapshots.push([...p])
|
|
60
|
+
sequence = mergeOrder(sequence, pins, reservations())
|
|
61
|
+
if (membershipChanged) start()
|
|
119
62
|
scheduleReconcile()
|
|
120
63
|
}
|
|
121
64
|
function scheduleReconcile() {
|
|
122
|
-
if (!
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
65
|
+
if (!orderReady() || reconcileQueued) return
|
|
66
|
+
reconcileQueued = true
|
|
67
|
+
void enqueue(async () => {
|
|
68
|
+
try { await reconcile() } finally { reconcileQueued = false }
|
|
69
|
+
}).catch(() => {})
|
|
126
70
|
}
|
|
127
|
-
// Storage emits before WorkspaceRegistry's private cache is assigned.
|
|
128
|
-
//
|
|
71
|
+
// Storage emits before WorkspaceRegistry's private cache is assigned. Always
|
|
72
|
+
// consume the owned event payload, never registry getters inside this callback.
|
|
129
73
|
const off = subscribe(change => {
|
|
130
74
|
if (disposed || change?.domain !== 'workspace' || change.table !== '' || change.key !== '' || change.operation !== 'put') return
|
|
131
75
|
try { nativeSnapshot(change.value?.pinnedSessionIds, change.value?.archivedSessionIds) }
|
|
@@ -133,27 +77,31 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
133
77
|
})
|
|
134
78
|
try { nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds, false) }
|
|
135
79
|
catch (error) { nativeValid = false; warn(error); phase = 'error' }
|
|
136
|
-
const initiallyPinned = new Set(pins)
|
|
80
|
+
const initialPins = [...pins], initiallyPinned = new Set(pins)
|
|
137
81
|
|
|
138
82
|
async function save(next) {
|
|
139
83
|
const nextRank = ownedIds(next.sessionIds)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
84
|
+
// Legacy fields remain readable in the v1 schema, but are no longer writers
|
|
85
|
+
// or authorities. Every new commit contains just one complete order.
|
|
86
|
+
const { manualSessionIds, manualOrderAt, taskCreatedAtBySession, taskFirstWorkAtBySession, ...base } = next
|
|
87
|
+
const value = { ...base, schemaVersion: 1, revision: revision + 1, sessionIds: nextRank, updatedAt: now() }
|
|
88
|
+
try {
|
|
89
|
+
if (record === undefined) await table.put(NATIVE_ORDER_KEY, value)
|
|
90
|
+
else await table.update(NATIVE_ORDER_KEY, () => value)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
persistenceError = 'Task order was not saved; official Pin membership is unchanged: ' + messageOf(error)
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
// Never advance a committed revision/rank before storage acknowledges. The
|
|
96
|
+
// live sequence may already include newer events received during this write.
|
|
97
|
+
record = value; rank = [...value.sessionIds]; revision = value.revision; updatedAt = value.updatedAt
|
|
98
|
+
persistenceError = null
|
|
150
99
|
return value
|
|
151
100
|
}
|
|
152
101
|
async function reconcile() {
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await save({ ...record, sessionIds: ids })
|
|
102
|
+
if (!orderReady() || record === undefined) return
|
|
103
|
+
while (!sameIds(rank, orderedIds())) await save({ ...record, sessionIds: orderedIds() })
|
|
104
|
+
persistenceError = null
|
|
157
105
|
}
|
|
158
106
|
async function open(factory) {
|
|
159
107
|
const handle = await factory()
|
|
@@ -162,7 +110,7 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
162
110
|
return handle
|
|
163
111
|
}
|
|
164
112
|
async function close(handle) { if (!handles.delete(handle)) return; await handle.close() }
|
|
165
|
-
const replaceEntry = (id, patch) => ({ ...record, migration: { ...record.migration,
|
|
113
|
+
const replaceEntry = (id, patch) => ({ ...record, sessionIds: [...sequence], migration: { ...record.migration,
|
|
166
114
|
entries: record.migration.entries.map(entry => entry.sessionId === id ? { ...entry, ...patch } : entry) } })
|
|
167
115
|
|
|
168
116
|
async function runMigration() {
|
|
@@ -190,7 +138,7 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
190
138
|
} catch (error) {
|
|
191
139
|
const known = error?.name === 'WorkspaceUnknownSessionError' || error?.name === 'WorkspaceArchivedSessionPinError'
|
|
192
140
|
outcome = { status: known ? 'skipped' : 'failed', detail: messageOf(error) }
|
|
193
|
-
warn(
|
|
141
|
+
warn('Legacy task ' + entry.sessionId + ' was not imported: ' + outcome.detail)
|
|
194
142
|
}
|
|
195
143
|
await save(replaceEntry(entry.sessionId, outcome))
|
|
196
144
|
})
|
|
@@ -199,38 +147,38 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
199
147
|
await enqueue(async () => {
|
|
200
148
|
const partial = record.migration.entries.some(entry => entry.status === 'failed' || entry.status === 'attempted')
|
|
201
149
|
const status = partial ? 'partial' : 'done'
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await save({ ...record, sessionIds:
|
|
150
|
+
reservedIds.clear()
|
|
151
|
+
sequence = orderedIds() // Remove only the unimported reservations.
|
|
152
|
+
await save({ ...record, sessionIds: [...sequence], migration: { ...record.migration, status } })
|
|
205
153
|
phase = status
|
|
154
|
+
await reconcile()
|
|
206
155
|
})
|
|
207
156
|
}
|
|
157
|
+
function hydrate(base, reserved = new Set()) {
|
|
158
|
+
for (const id of reserved) reservedIds.add(id)
|
|
159
|
+
sequence = mergeOrder(base, initialPins, reserved)
|
|
160
|
+
for (const snapshot of snapshots) sequence = mergeOrder(sequence, snapshot, reserved)
|
|
161
|
+
snapshots.length = 0; hydrated = true
|
|
162
|
+
}
|
|
208
163
|
async function initialize() {
|
|
209
164
|
if (!nativeValid) throw new Error('Native pin state is unavailable')
|
|
210
165
|
const order = await open(openOrder); table = order.table
|
|
211
166
|
const stored = table.get(NATIVE_ORDER_KEY)
|
|
212
167
|
if (stored !== undefined) {
|
|
213
|
-
rank = ownedIds(stored.sessionIds); record = stored; revision =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
for (const [id, timestamp] of Object.entries(stored.taskFirstWorkAtBySession ?? {})) {
|
|
218
|
-
if (pins.includes(id) && Number.isSafeInteger(timestamp) && timestamp >= 0) firstWorkAt.set(id, timestamp)
|
|
219
|
-
}
|
|
220
|
-
await refreshCreatedAt()
|
|
221
|
-
for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn(`Legacy task ${entry.sessionId}: ${entry.detail}`)
|
|
168
|
+
rank = ownedIds(stored.sessionIds); record = stored; revision = stored.revision; updatedAt = stored.updatedAt
|
|
169
|
+
hydrate(rank)
|
|
170
|
+
for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn('Legacy task ' + entry.sessionId + ': ' + entry.detail)
|
|
222
171
|
if (stored.migration.status === 'running') {
|
|
223
172
|
warn('Legacy pin migration was interrupted; unconfirmed entries will not be retried automatically. The original queue is preserved.')
|
|
224
173
|
await enqueue(async () => {
|
|
225
|
-
await save({ ...record, sessionIds:
|
|
174
|
+
await save({ ...record, sessionIds: orderedIds(), migration: { ...record.migration, status: 'partial', entries: record.migration.entries.map(entry => entry.status === 'pending' || entry.status === 'attempted' ? { ...entry, status: 'uncertain', detail: 'Interrupted import; use the official Pin control to recover explicitly' } : entry) } })
|
|
226
175
|
})
|
|
227
176
|
phase = 'partial'
|
|
228
177
|
} else phase = stored.migration.status
|
|
229
178
|
await enqueue(reconcile)
|
|
230
179
|
return
|
|
231
180
|
}
|
|
232
|
-
|
|
233
|
-
// The legacy unit is opened with its unchanged v1 schema, read, then closed.
|
|
181
|
+
// The legacy unit is opened unchanged, read and closed; never erase its data.
|
|
234
182
|
const legacy = await open(openLegacy)
|
|
235
183
|
let seed
|
|
236
184
|
try { seed = legacy.table.get(NATIVE_ORDER_KEY); if (seed !== undefined) ownedIds(seed.sessionIds) }
|
|
@@ -240,17 +188,25 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
240
188
|
status: alreadyNative.has(sessionId) || touched.has(sessionId) || archived.includes(sessionId) ? 'skipped' : 'pending',
|
|
241
189
|
...(alreadyNative.has(sessionId) ? { detail: 'Already officially pinned; never an import target' } : {}),
|
|
242
190
|
}))
|
|
191
|
+
const pending = new Set(entries.filter(entry => entry.status === 'pending').map(entry => entry.sessionId))
|
|
192
|
+
hydrate([...seedIds, ...initialPins.filter(id => !seedIds.includes(id))], pending)
|
|
193
|
+
// No old import work means no fake migration phase or extra journal writes.
|
|
194
|
+
const status = pending.size ? 'running' : 'done'
|
|
243
195
|
await enqueue(async () => {
|
|
244
|
-
await save({
|
|
245
|
-
|
|
246
|
-
|
|
196
|
+
await save({ sessionIds: [...sequence], migration: { status, sourceRevision: seed?.revision ?? null, entries } })
|
|
197
|
+
phase = status
|
|
198
|
+
if (status === 'done') await reconcile()
|
|
247
199
|
})
|
|
248
|
-
migration = runMigration().catch(error => { if (!disposed) { warn(
|
|
200
|
+
if (status === 'running') migration = runMigration().catch(error => { if (!disposed) { warn('Legacy migration stopped: ' + messageOf(error) + '. The original queue is preserved.'); phase = 'error' } })
|
|
249
201
|
}
|
|
250
|
-
|
|
202
|
+
function start() {
|
|
251
203
|
if (started || disposed) return
|
|
252
204
|
started = true; phase = 'initializing'
|
|
253
|
-
initialization = initialize().catch(error => {
|
|
205
|
+
initialization = initialize().catch(error => {
|
|
206
|
+
// A loaded terminal journal with a dirty sequence can retry a failed save.
|
|
207
|
+
// Opening/fencing/import failures remain terminal and must not replay Pin.
|
|
208
|
+
if (!disposed && !orderReady()) { phase = 'error'; warn('Task order initialization failed: ' + messageOf(error)) }
|
|
209
|
+
})
|
|
254
210
|
}
|
|
255
211
|
function requireCommand(command) {
|
|
256
212
|
if (command === null || typeof command !== 'object' || typeof command.sessionId !== 'string' || command.sessionId === '') throw new NativePinError('better-tasks/invalid-request', 'A Session identity is required')
|
|
@@ -261,25 +217,20 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
261
217
|
current: response,
|
|
262
218
|
async mutate(command) {
|
|
263
219
|
requireCommand(command)
|
|
264
|
-
// Explicit compatibility intent also counts when the native call will be
|
|
265
|
-
// an idempotent no-op and therefore emit no workspace change event.
|
|
266
220
|
if (command.action !== 'move') touched.add(command.sessionId)
|
|
267
221
|
start()
|
|
268
|
-
|
|
269
|
-
// to return the already-known initializing/readonly state afterwards.
|
|
270
|
-
if (command.action === 'move' && !response().writable) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is initializing, migrating or unavailable; official Pin remains usable', 503)
|
|
222
|
+
if (command.action === 'move' && !orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task order is initializing, importing or unavailable; official Pin remains usable', 503)
|
|
271
223
|
return enqueue(async () => {
|
|
272
224
|
if (command.action !== 'move') {
|
|
273
|
-
// Compatibility for old clients; all new UI uses official public controls.
|
|
274
225
|
const observedEpoch = epoch
|
|
275
226
|
if (command.action === 'unpin') await registry.unpinSession(command.sessionId)
|
|
276
227
|
else await registry.pinSession(command.sessionId)
|
|
277
228
|
if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
|
|
278
|
-
try { await reconcile() } catch
|
|
229
|
+
try { await reconcile() } catch { /* save exposes persistenceError; never undo the native result. */ }
|
|
279
230
|
return response()
|
|
280
231
|
}
|
|
281
232
|
if (!Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0) throw new NativePinError('better-tasks/invalid-request', 'expectedRevision must be a non-negative safe integer')
|
|
282
|
-
if (!
|
|
233
|
+
if (!orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is unavailable; official Pin remains usable', 503)
|
|
283
234
|
await reconcile()
|
|
284
235
|
if (command.expectedRevision !== revision) throw new NativePinError('better-tasks/revision-conflict', 'Task order changed', 409)
|
|
285
236
|
const ids = orderedIds(), before = command.beforeSessionId ?? null
|
|
@@ -290,18 +241,19 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
|
|
|
290
241
|
next.splice(before === null ? next.length : next.indexOf(before), 0, command.sessionId)
|
|
291
242
|
if (sameIds(next, ids)) return response()
|
|
292
243
|
const observedEpoch = epoch
|
|
293
|
-
await save({ ...record, sessionIds: next
|
|
244
|
+
await save({ ...record, sessionIds: next })
|
|
294
245
|
if (observedEpoch !== epoch) {
|
|
295
246
|
await reconcile()
|
|
296
247
|
throw new NativePinError('better-tasks/revision-conflict', 'Official membership changed while saving task order', 409)
|
|
297
248
|
}
|
|
249
|
+
sequence = next
|
|
298
250
|
return response()
|
|
299
251
|
})
|
|
300
252
|
},
|
|
301
253
|
async settled() { await initialization; await migration; await tail },
|
|
302
254
|
async dispose() {
|
|
303
255
|
if (disposed) return
|
|
304
|
-
disposed = true;
|
|
256
|
+
disposed = true; off?.()
|
|
305
257
|
await initialization; await migration; await tail
|
|
306
258
|
await Promise.all([...handles].map(close))
|
|
307
259
|
},
|
package/src/pin-client.mjs
CHANGED
|
@@ -67,7 +67,9 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
|
|
|
67
67
|
const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
|
|
68
68
|
const armMigrationRefresh = () => {
|
|
69
69
|
stopTimer()
|
|
70
|
-
|
|
70
|
+
const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
|
|
71
|
+
const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
|
|
72
|
+
if (!started || disposed || !(restoring || saving)) { polls = 0; return }
|
|
71
73
|
if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
|
|
72
74
|
polls++
|
|
73
75
|
timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
|
|
@@ -115,17 +117,6 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
|
|
|
115
117
|
}
|
|
116
118
|
binding = next; generation++
|
|
117
119
|
let signature, closed = false
|
|
118
|
-
let visible = new Set()
|
|
119
|
-
const observeStartedTasks = () => {
|
|
120
|
-
if (closed || disposed || !nativeReady()) return
|
|
121
|
-
const sessions = next.sessions?.getSnapshot?.()
|
|
122
|
-
if (sessions?.phase !== 'ready') return
|
|
123
|
-
const nextVisible = new Set(native.pinnedSessionIds.filter(id => sessions.byId?.[id]?.blank === false))
|
|
124
|
-
const appeared = [...nextVisible].some(id => !visible.has(id))
|
|
125
|
-
visible = nextVisible
|
|
126
|
-
// A pre-existing Pin can become a new task without a workspace change.
|
|
127
|
-
if (appeared && started) void refresh().catch(() => {})
|
|
128
|
-
}
|
|
129
120
|
const observe = () => {
|
|
130
121
|
if (closed || disposed) return
|
|
131
122
|
const snapshot = next.source.getSnapshot()
|
|
@@ -133,14 +124,12 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
|
|
|
133
124
|
const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
|
|
134
125
|
const changed = signature !== value
|
|
135
126
|
signature = value; publish()
|
|
136
|
-
observeStartedTasks()
|
|
137
127
|
if (changed && nativeReady() && started) void refresh().catch(() => {})
|
|
138
128
|
}
|
|
139
129
|
const off = next.source.subscribe(observe)
|
|
140
|
-
const offSessions = typeof next.sessions?.subscribe === 'function' ? next.sessions.subscribe(observeStartedTasks) : () => {}
|
|
141
130
|
disconnect = () => {
|
|
142
131
|
if (closed) return
|
|
143
|
-
closed = true; off();
|
|
132
|
+
closed = true; off(); stopTimer(); generation++
|
|
144
133
|
if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
|
|
145
134
|
}
|
|
146
135
|
observe()
|
|
@@ -171,7 +160,7 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
|
|
|
171
160
|
actionError = null; countPending(sessionId, 1)
|
|
172
161
|
return enqueue(async () => {
|
|
173
162
|
if (!loaded || !writable) await readOrder()
|
|
174
|
-
if (!writable) throw new Error('Task order is not ready
|
|
163
|
+
if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
|
|
175
164
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
176
165
|
const queue = source.getSnapshot().queue
|
|
177
166
|
if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
|