dsh-workflow-map 0.3.4 → 0.3.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/CHANGELOG.md +43 -4
- package/README.en.md +56 -19
- package/README.md +51 -17
- package/client.js +139 -7
- package/docs/screenshots/01-main-dark.png +0 -0
- package/docs/screenshots/02-main-light.png +0 -0
- package/docs/screenshots/03-running-step.png +0 -0
- package/docs/screenshots/04-step-detail.png +0 -0
- package/docs/screenshots/05-hot-inject.png +0 -0
- package/docs/screenshots/06-settings.png +0 -0
- package/package.json +2 -1
- package/src/client/StepColumn.js +4 -1
- package/src/client/panel.js +97 -4
- package/src/client/pulse.js +11 -1
- package/src/client/store.js +23 -1
- package/src/client/theme.js +4 -0
- package/src/fold.mjs +69 -8
- package/src/orchestrator.mjs +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
3
3
|
`dsh-workflow-map` 的所有重要变更记录在此。
|
|
4
4
|
格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [SemVer](https://semver.org/)。
|
|
5
5
|
|
|
6
|
+
## [0.3.5] - 2026-10-31
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **README 截图区改为按市场评分规则书写的六图画廊**:主界面(深色/浅色)、运行中的单元、分步详情、
|
|
11
|
+
热注入、设置面板。图片落在 `docs/screenshots/`,相对路径引用,图注含「预览/截图/主界面」等关键词,
|
|
12
|
+
使插件市场的卡片与安装对话框能自动取到(该市场只认 README 里位于 GitHub 四域名的图片,
|
|
13
|
+
并按语义与尺寸打分取前 6 张;带 badge/logo 的一律排除)。
|
|
14
|
+
- **标题栏增加手动刷新按钮(`↻`)**。点一次立即重新读取当前会话的视图,不等自动轮询的下一个周期;
|
|
15
|
+
读取成功后按钮短暂变为「已刷新」,随后恢复图标。它走的是同一条 `GET /dsh-workflow/state` 与同一个
|
|
16
|
+
`If-None-Match` 校验器,不引入第二条数据通道。自动轮询正常工作时无需使用,仅在自动刷新受阻时兜底。
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **返回即结束:节点的「已完成」不再由正文是否为空推断**。`nodeView` 发布布尔 `result`(折叠层的 `settled` 标记),
|
|
21
|
+
工具返回空正文(例如无输出的 `pwsh`)也算已返回。此前浏览器半只按 `resultHead !== ""` 判断,
|
|
22
|
+
空输出的调用因此永远停在「正在调用」状态。同时 `STATE_VERSION` 升到 5,旧检查点按契约丢弃重建。
|
|
23
|
+
- **结果不是活动**。`foldToolResult` 曾把刚返回的节点重新设为 `active`,面板因此对已经返回的调用声称「正在调用」;
|
|
24
|
+
现在只有当更新的调用打开了另一个节点时才保留高亮,否则交还给步骤。
|
|
25
|
+
- **未收到 `turn/end` 的回合不再让整张图无限流动**。`view.running` 是回合标志,中断/崩溃/压缩后的会话会把它永久留在 `true`;
|
|
26
|
+
浏览器半现在以时钟为第二证人:最后一个已折叠事件早于空闲窗口(15 s)即判定为空闲,无时钟(离线预览)时仍信任标志。
|
|
27
|
+
- **重开的行不再继承上一轮的 100 %**。`foldTodos` 把 `completed` 行改回 `in_progress` 时保留了 `percent` 与 `maxPercent`,
|
|
28
|
+
新行一出现就钉在 100 %,进度条不再移动。现在重开(以及降级为 `pending`)会清空百分比与高水位,`startedAt` 重新计时。
|
|
29
|
+
- **运行中的步骤不再显示 100 %**,且**自报的最新值就是显示值**。`percentFor` 的模型分支此前取「自报」与「高水位」的较大值并允许 100,
|
|
30
|
+
既让未完成的步骤声称 100 %,也让后续自报被历史高水位压住。高水位仍记录在 `step.maxPercent`,只是不再参与显示。
|
|
31
|
+
- **自动轮询不再因一次慢请求而永久停止**。每个周期开始时会中止上一个未完成的请求,被中止的请求返回
|
|
32
|
+
`status: -1`;旧代码在这里直接 `return`,不重新排程。只要有一次请求慢于轮询间隔(网络抖动、宿主繁忙),
|
|
33
|
+
循环就此结束,地图停在最后一帧——表现为「地图不会自己更新」。现在中止只让位给已在飞行中的替代请求,
|
|
34
|
+
两者都结束后照常排程;该规则抽成纯函数 `wfPollContinues()` 并纳入自测。
|
|
35
|
+
- **已结束的 PAE 计划不再改写地图**。`<cwd>/.pae/<sessionId>/orchestrator.json` 一旦写入就长期保留,
|
|
36
|
+
`phase` 为 `completed` / `aborted` 时仍会被当作当前计划读取,用它自己的 `statuses`、`anchorSeqs` 与步骤标题
|
|
37
|
+
覆盖本轮待办表刚建出的行,于是面板显示的是上一轮的计划。现在计划段新增布尔 `terminal`,为真时仍发布
|
|
38
|
+
(详情页要用它的 `stepReports`),但不再修改任何行的标题与状态;浏览器半的状态判定同样据此让位。
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- 自测口径:离线 10 套合计 1570 条(`selftest-fold` 217、`selftest-host` 54、`selftest-orchestrator` 155、其余同前),
|
|
43
|
+
浏览器量测 10 套合计 323 条。各套件的输出以退出码与逐条结果为准,本文件不复述结论。
|
|
44
|
+
|
|
6
45
|
## [0.3.4] - 2026-10-30
|
|
7
46
|
|
|
8
47
|
### Changed
|
|
@@ -49,7 +88,7 @@
|
|
|
49
88
|
|
|
50
89
|
### Tests
|
|
51
90
|
|
|
52
|
-
- **离线自测 10 套 / 1533
|
|
91
|
+
- **离线自测 10 套 / 1533 条**(0.3.3 为 1515 条)。本轮新增 13 条:`selftest-fold`
|
|
53
92
|
的 `(r)` 用例 8 条(待办表改回 `pending` 必须降级、改写表后的 `pending` 行不得顶替运行步、
|
|
54
93
|
`in_progress` 行对应的记录即使原来是 `pending` 也要转为运行、未命名空行被丢弃、回合结束仍保留表中
|
|
55
94
|
`in_progress` 的步、非运行行不出数字……),`selftest-panel` 的浅色主题针脚 5 条(抬升变量、深色覆盖、
|
|
@@ -57,7 +96,7 @@
|
|
|
57
96
|
- **真实会话日志**:`check-real-injects` 折叠 147 个会话、识别出 19 条热注入记录、failures 0;
|
|
58
97
|
`audit-real` 折叠 148 个会话、629 个步骤,约束 ①(分步必须存在于待办表)与 ②(同一时刻只有一个
|
|
59
98
|
running 步)各 0 违规;`done` 步显示 <100% 的数量为 **0**,非运行步带数字的数量为 **0**(修前 74 条)。
|
|
60
|
-
- **浏览器量测 10 套 / 323
|
|
99
|
+
- **浏览器量测 10 套 / 323 条**(0.3.3 为 308 条):
|
|
61
100
|
`panel 21 · timing 20 · inject 43 · dock 14 · flow 20 · effects 28 · branches 15 · detail 50 · settings 56 · main 56`。
|
|
62
101
|
`main` 从 41 增至 56 的 15 条是本轮新增的主题对比检查:浅色帧的页面底色确实是宿主真值 `#fff`、
|
|
63
102
|
两种主题下 `--wf-lift` 各自生效、单元卡边线 alpha 与抬升渐变俱在、14 px 颗星徽标靠自身颜色边线可见
|
|
@@ -86,7 +125,7 @@
|
|
|
86
125
|
|
|
87
126
|
- `tools/selftest-fold.mjs` 新增 8 条断言:`(o1)`/`(o2)` 编号清单换表后序号连续、标题不重名、
|
|
88
127
|
行不继承邻居标题;`(p)` 计划确认点只落在该落的步骤、`kind` 为 `plan`、重复应用不重复、已完成步骤不带标记。
|
|
89
|
-
- 离线自测 **10 套 / 1515
|
|
128
|
+
- 离线自测 **10 套 / 1515 条**(0.3.2 为 1507 条)。
|
|
90
129
|
|
|
91
130
|
|
|
92
131
|
## [0.3.1] - 2026-10-28
|
|
@@ -111,7 +150,7 @@
|
|
|
111
150
|
|
|
112
151
|
### Tests
|
|
113
152
|
|
|
114
|
-
- 离线自测 **10 套 / 1507
|
|
153
|
+
- 离线自测 **10 套 / 1507 条**、浏览器量测 **10 套 / 308 条**。
|
|
115
154
|
|
|
116
155
|
### Notes
|
|
117
156
|
|
package/README.en.md
CHANGED
|
@@ -25,16 +25,28 @@ Three entry points share one data source and one set of components: the sidebar
|
|
|
25
25
|
The panel folds session events into a step sequence, showing each step's status, progress, tool calls
|
|
26
26
|
and file operations, and can inject prompts into the next step of a running session.
|
|
27
27
|
|
|
28
|
-

|
|
29
29
|
|
|
30
|
-
*
|
|
31
|
-
on both sides for tool/file types
|
|
32
|
-
|
|
30
|
+
*Main screen (dark): the selected sidebar entry on the left, the serial main flow in the centre (one unit
|
|
31
|
+
card per step, round interfaces on both sides for tool/file types, the running unit highlighted with a
|
|
32
|
+
flowing frame), the step detail page on the right, and the persistent injection bar at the bottom.*
|
|
33
|
+
|
|
34
|
+
### Screenshots (full preview)
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
|---|---|
|
|
38
|
+
|  |  |
|
|
39
|
+
| **Main screen · light theme** — same layout, colours follow the host theme | **Running unit** — the flowing frame marks "calling right now" |
|
|
40
|
+
|  |  |
|
|
41
|
+
| **Step detail** — type list, call arguments and result summary for that step | **Hot inject** — queue a prompt for the next step, revocable before it is claimed |
|
|
42
|
+
|  | |
|
|
43
|
+
|
|
44
|
+
All six are offscreen renders of the interface and contain no real session content.
|
|
33
45
|
|
|
34
46
|

|
|
35
47
|

|
|
36
48
|

|
|
37
|
-

|
|
38
50
|
|
|
39
51
|
---
|
|
40
52
|
|
|
@@ -112,18 +124,21 @@ Each step's percentage is resolved by the following **priority** (`percentFor` i
|
|
|
112
124
|
| Priority | Source | Rule | Cap |
|
|
113
125
|
|---|---|---|---|
|
|
114
126
|
| 1 | Todo table `todos` | `round(completed / total * 100)`; when a self-report exists, this measured value is still what is returned | 99 |
|
|
115
|
-
| 2 | Model self-report `model` | Only when the todo table has no countable rows: the
|
|
127
|
+
| 2 | Model self-report `model` | Only when the todo table has no countable rows: the most recent `stepProgress/report` percentage | 99 |
|
|
116
128
|
| 3 | Time baseline `baseline` | `elapsed / median(finished-step durations in this session) * 100` | 90 |
|
|
117
129
|
| 4 | Plan ordinal `baseline:steps` | Cold start with `current step / total steps * 100` when no durations exist | 90 |
|
|
118
130
|
| - | `none` | None of the four is available | No number shown |
|
|
119
131
|
|
|
120
132
|
Constraint: an unfinished step never shows 100 %; a finished (done or failed) step is pinned to 100 %.
|
|
121
133
|
|
|
122
|
-
The choice follows verifiability: the todo table is
|
|
123
|
-
whenever the todo table has completed rows, the percentage is that
|
|
124
|
-
`percentFor`
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
The choice follows verifiability: the todo table is a count of rows, a model self-report has no per-row
|
|
135
|
+
basis. So whenever the todo table has completed rows, the percentage is that ratio (both branches of
|
|
136
|
+
`percentFor` return `measured`); the self-report is a fallback only when no row can be counted, and the
|
|
137
|
+
**most recent** report is used so that a later report can move the value. The high-water record
|
|
138
|
+
`step.maxPercent` is kept and never rolled back, and does not take part in the displayed value.
|
|
139
|
+
When `foldTodos` takes a finished row back to `in_progress`, or demotes it to `pending`, it clears
|
|
140
|
+
`percent` and `maxPercent` and resets `startedAt`: a finished record's number belongs to that closure,
|
|
141
|
+
not to the row that reopens it.
|
|
127
142
|
|
|
128
143
|
A second line under the rail reads `estimated N minutes ago`, i.e. `view.asOf - step.lastEstimateAt`.
|
|
129
144
|
It describes how **stale** the number is, distinguishing "being updated" from "unchanged for a long time".
|
|
@@ -279,8 +294,14 @@ so time-based eviction would empty historical steps.
|
|
|
279
294
|
| Click a call row | Expand the full arguments, result and error in place |
|
|
280
295
|
| `Esc` | Close the right page, not the panel |
|
|
281
296
|
| Gear | Open settings: palette, motion, detail depth, node limit |
|
|
297
|
+
| `↻` | Read the current session's view again now; the button briefly shows "Refreshed" |
|
|
282
298
|
| Copy control | Copy path / command / snapshot, with a receipt that restores after 1.5 s |
|
|
283
299
|
|
|
300
|
+
The view is polled: every 0.8 s while the page is visible, every 3 s while it is hidden, as a conditional
|
|
301
|
+
request (`If-None-Match`) that the host answers with `304` and no body when nothing changed. `↻` runs one
|
|
302
|
+
cycle of that same request, for the case where automatic polling is blocked; it neither changes the cadence
|
|
303
|
+
nor adds a second data path.
|
|
304
|
+
|
|
284
305
|
Settings:
|
|
285
306
|
|
|
286
307
|
| Item | Values | Effect |
|
|
@@ -320,7 +341,7 @@ steps, their status, the percentage source, the node list and the report artifac
|
|
|
320
341
|
# after editing src/client/*.js, rebuild the browser half
|
|
321
342
|
node tools/build-client.mjs
|
|
322
343
|
|
|
323
|
-
# offline self-tests (10 suites
|
|
344
|
+
# offline self-tests (10 suites)
|
|
324
345
|
node tools/selftest-fold.mjs # fold semantics
|
|
325
346
|
node tools/selftest-host.mjs # registration, schema, projection wiring
|
|
326
347
|
node tools/selftest-orchestrator.mjs # plan file reading and merging
|
|
@@ -337,7 +358,7 @@ node tools/check-real-injects.mjs # fold historical logs in bulk
|
|
|
337
358
|
node tools/audit-real.mjs # four-constraint audit
|
|
338
359
|
```
|
|
339
360
|
|
|
340
|
-
Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge;
|
|
361
|
+
Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge; light/dark theme contrast included):
|
|
341
362
|
|
|
342
363
|
```bash
|
|
343
364
|
pwsh -File tools/measure-panel.ps1
|
|
@@ -359,7 +380,7 @@ pwsh -File tools/measure-main.ps1
|
|
|
359
380
|
|
|
360
381
|
## 10. Key constants
|
|
361
382
|
|
|
362
|
-
Every threshold is defined once and
|
|
383
|
+
Every threshold is defined once and read by the self-tests, so this table and the code share one source:
|
|
363
384
|
|
|
364
385
|
| Constant | Value | Location | Meaning |
|
|
365
386
|
|---|---|---|---|
|
|
@@ -376,7 +397,23 @@ Every threshold is defined once and asserted by the tests, so this table cannot
|
|
|
376
397
|
|
|
377
398
|
---
|
|
378
399
|
|
|
379
|
-
## 11.
|
|
400
|
+
## 11. Plan file and todo table precedence
|
|
401
|
+
|
|
402
|
+
The map's steps come from two sources, in a fixed order:
|
|
403
|
+
|
|
404
|
+
| Priority | Source | Effect |
|
|
405
|
+
|---|---|---|
|
|
406
|
+
| 1 | Todo table `todo/write` | This turn's step titles and statuses, the session's newest statement |
|
|
407
|
+
| 2 | Plan file `.pae/<sessionId>/orchestrator.json` | While `phase` is `planning` / `scheduled` / `executing` / `paused`, its step titles, `statuses` and `anchorSeqs` apply |
|
|
408
|
+
| - | The same file at `completed` / `aborted` | The plan is over: its steps and reports are still published for the detail page, but it no longer changes any row's title or status |
|
|
409
|
+
|
|
410
|
+
The plan file is written once and never deleted. Read without that distinction, a previous turn's plan
|
|
411
|
+
overrides the rows the current todo table has just created, and the panel disagrees with the conversation.
|
|
412
|
+
The view's plan section therefore carries a boolean `terminal`, and both halves (host and browser) obey it.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## 12. Known issues
|
|
380
417
|
|
|
381
418
|
Both defects recorded in 0.3.0 are closed:
|
|
382
419
|
|
|
@@ -390,7 +427,7 @@ that is how activation works, not a defect.
|
|
|
390
427
|
|
|
391
428
|
---
|
|
392
429
|
|
|
393
|
-
##
|
|
430
|
+
## 13. Layout
|
|
394
431
|
|
|
395
432
|
```
|
|
396
433
|
dsh-workflow-map/
|
|
@@ -410,7 +447,7 @@ dsh-workflow-map/
|
|
|
410
447
|
|
|
411
448
|
---
|
|
412
449
|
|
|
413
|
-
##
|
|
450
|
+
## 14. FAQ
|
|
414
451
|
|
|
415
452
|
**Can it show several sessions at once?**
|
|
416
453
|
The panel shows the current session only. Switching sessions switches the content; nothing is merged or tiled.
|
|
@@ -441,13 +478,13 @@ the panel to `none`.
|
|
|
441
478
|
|
|
442
479
|
---
|
|
443
480
|
|
|
444
|
-
##
|
|
481
|
+
## 15. License
|
|
445
482
|
|
|
446
483
|
MIT. Commercial use, modification and redistribution are permitted; keep the copyright notice.
|
|
447
484
|
|
|
448
485
|
---
|
|
449
486
|
|
|
450
|
-
##
|
|
487
|
+
## 16. Reporting an issue
|
|
451
488
|
|
|
452
489
|
The following helps locate a problem:
|
|
453
490
|
|
package/README.md
CHANGED
|
@@ -24,16 +24,27 @@ DeepSeek Harness 插件:把当前会话渲染为一张可交互的分步工作
|
|
|
24
24
|
会话标签栏(`conversation.view`)。面板将会话事件折叠为分步序列,展示每步的状态、进度、
|
|
25
25
|
工具调用与文件操作,并支持在会话运行中向其下一步注入提示词。
|
|
26
26
|
|
|
27
|
-

|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
*主界面(深色):左为侧边栏入口选中态,中为串行主流程(每步一张单元卡,两侧圆形接口为工具/文件
|
|
30
|
+
类型,运行中的单元高亮并带流动外框),右为分步详情页,底部为常驻注入条。*
|
|
31
|
+
|
|
32
|
+
### 界面截图(完整预览)
|
|
33
|
+
|
|
34
|
+
| | |
|
|
35
|
+
|---|---|
|
|
36
|
+
|  |  |
|
|
37
|
+
| **主界面 · 浅色主题** —— 与深色同一布局,配色随宿主主题切换 | **运行中的单元** —— 流动渐变外框标出「此刻在调用」 |
|
|
38
|
+
|  |  |
|
|
39
|
+
| **分步详情** —— 右页列出该步的类型清单、调用参数与结果摘要 | **热注入** —— 提示词排入下一步执行队列,被取用前可撤回 |
|
|
40
|
+
|  | |
|
|
41
|
+
|
|
42
|
+
以上六张为离屏渲染的界面预览,不含任何真实会话内容。
|
|
32
43
|
|
|
33
44
|

|
|
34
45
|

|
|
35
46
|

|
|
36
|
-

|
|
37
48
|
|
|
38
49
|
---
|
|
39
50
|
|
|
@@ -106,15 +117,18 @@ body 超 64 KiB,`503` 宿主未暴露相应能力。
|
|
|
106
117
|
| 优先级 | 来源 | 计算规则 | 上限 |
|
|
107
118
|
|---|---|---|---|
|
|
108
119
|
| 1 | 待办表实测 `todos` | `round(已完成项 / 总项 × 100)`;自报存在时也只取该实测值 | 99 |
|
|
109
|
-
| 2 | 模型自报 `model` |
|
|
120
|
+
| 2 | 模型自报 `model` | 待办表无可计行时,取最近一次 `stepProgress/report` 的百分比 | 99 |
|
|
110
121
|
| 3 | 时间基线 `baseline` | `elapsed / median(本会话已完步耗时) × 100` | 90 |
|
|
111
122
|
| 4 | 计划步序 `baseline:steps` | 无历史耗时时,用 `当前步序 / 总步数 × 100` 冷启动 | 90 |
|
|
112
123
|
| — | `none` | 四个来源均不可用 | 不显示数字 |
|
|
113
124
|
|
|
114
125
|
约束:未结束的步不会显示 100%;已结束(完成或失败)的步固定为 100%。
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
|
|
127
|
+
选用规则依据可核验性:待办表是逐行计数得到的量值,模型自报没有对应的行级依据。因此只要待办表存在已完成行,
|
|
128
|
+
百分比取该计数比值(`percentFor` 的两个分支均返回 `measured`),自报仅在无行可计数时作为备选;且取
|
|
129
|
+
**最近一次**自报,使后续报告能够改变显示值。高水位记录 `step.maxPercent` 保留且不回退,不参与显示值计算。
|
|
130
|
+
`foldTodos` 将已结束行改回 `in_progress`、或降级为 `pending` 时,清空 `percent` 与 `maxPercent` 并重置
|
|
131
|
+
`startedAt`:已结束记录的数字属于那一次结束,不属于重新打开它的行。
|
|
118
132
|
|
|
119
133
|
进度条下方另有一行元信息:`N 分钟前估计`,即 `view.asOf − step.lastEstimateAt`。
|
|
120
134
|
该值描述数字的**陈旧程度**,用于区分「正在更新」与「长时间未更新」。字段缺失时整行不渲染。
|
|
@@ -264,8 +278,13 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
|
|
|
264
278
|
| 点调用行 | 就地展开参数全文、结果与错误 |
|
|
265
279
|
| `Esc` | 关闭右页,不关闭面板 |
|
|
266
280
|
| 齿轮 | 打开设置:配色、动效、详情深度、节点上限 |
|
|
281
|
+
| `↻` | 立即重新读取当前会话的视图;读取成功后按钮短暂显示「已刷新」 |
|
|
267
282
|
| 复制控件 | 复制路径 / 命令 / 快照,1.5 秒后回执并复原 |
|
|
268
283
|
|
|
284
|
+
视图由轮询获得:页面可见时每 0.8 秒一次,隐藏时每 3 秒一次,条件请求(`If-None-Match`),
|
|
285
|
+
未变化时宿主返回 `304` 且无响应体。`↻` 只是触发同一请求的一个周期,用于自动轮询受阻时手动取一次,
|
|
286
|
+
不改变轮询节奏,也不构成第二条数据通道。
|
|
287
|
+
|
|
269
288
|
设置项:
|
|
270
289
|
|
|
271
290
|
| 项 | 取值 | 作用 |
|
|
@@ -303,7 +322,7 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
|
|
|
303
322
|
# 修改 src/client/*.js 后必须重新生成浏览器半
|
|
304
323
|
node tools/build-client.mjs
|
|
305
324
|
|
|
306
|
-
# 离线自测(10
|
|
325
|
+
# 离线自测(10 套)
|
|
307
326
|
node tools/selftest-fold.mjs # 折叠语义
|
|
308
327
|
node tools/selftest-host.mjs # 注册、schema、投影接线
|
|
309
328
|
node tools/selftest-orchestrator.mjs # 计划文件读取与合并
|
|
@@ -320,7 +339,7 @@ node tools/check-real-injects.mjs # 批量折叠历史日志
|
|
|
320
339
|
node tools/audit-real.mjs # 四项约束审计
|
|
321
340
|
```
|
|
322
341
|
|
|
323
|
-
浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge
|
|
342
|
+
浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge;含浅色/深色主题对比检查):
|
|
324
343
|
|
|
325
344
|
```bash
|
|
326
345
|
pwsh -File tools/measure-panel.ps1
|
|
@@ -342,7 +361,7 @@ pwsh -File tools/measure-main.ps1
|
|
|
342
361
|
|
|
343
362
|
## 10. 关键常量
|
|
344
363
|
|
|
345
|
-
|
|
364
|
+
所有阈值集中定义,并由自测直接引用,因此本表取值与实现取值同源:
|
|
346
365
|
|
|
347
366
|
| 常量 | 值 | 位置 | 含义 |
|
|
348
367
|
|---|---|---|---|
|
|
@@ -359,7 +378,22 @@ pwsh -File tools/measure-main.ps1
|
|
|
359
378
|
|
|
360
379
|
---
|
|
361
380
|
|
|
362
|
-
## 11.
|
|
381
|
+
## 11. 计划文件与待办表的优先级
|
|
382
|
+
|
|
383
|
+
地图的分步有两个来源,优先级固定:
|
|
384
|
+
|
|
385
|
+
| 优先级 | 来源 | 作用 |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| 1 | 待办表 `todo/write` | 本轮分步的标题与状态,是当前会话的最新陈述 |
|
|
388
|
+
| 2 | 计划文件 `.pae/<sessionId>/orchestrator.json` | `phase` 为 `planning` / `scheduled` / `executing` / `paused` 时,其步骤标题、`statuses`、`anchorSeqs` 生效 |
|
|
389
|
+
| — | 同一文件的 `completed` / `aborted` | 计划已结束:步骤与报告仍发布给详情页,但不再修改任何行的标题与状态 |
|
|
390
|
+
|
|
391
|
+
计划文件写入后长期保留,不会被删除。若不加区分地读取,上一轮计划的标题与状态会覆盖本轮待办表刚建出的行,
|
|
392
|
+
面板与对话因此不一致。视图的计划段为此携带布尔 `terminal`,两侧(宿主与浏览器半)都以它为准。
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## 12. 已知问题
|
|
363
397
|
|
|
364
398
|
0.3.0 记录过的两个缺陷均已关闭:
|
|
365
399
|
|
|
@@ -372,7 +406,7 @@ pwsh -File tools/measure-main.ps1
|
|
|
372
406
|
|
|
373
407
|
---
|
|
374
408
|
|
|
375
|
-
##
|
|
409
|
+
## 13. 目录
|
|
376
410
|
|
|
377
411
|
```
|
|
378
412
|
dsh-workflow-map/
|
|
@@ -392,7 +426,7 @@ dsh-workflow-map/
|
|
|
392
426
|
|
|
393
427
|
---
|
|
394
428
|
|
|
395
|
-
##
|
|
429
|
+
## 14. 常见问题
|
|
396
430
|
|
|
397
431
|
**能同时看多个会话吗?**
|
|
398
432
|
面板只展示当前会话。切换会话即切换内容;不合并、不并排。
|
|
@@ -420,13 +454,13 @@ dsh-workflow-map/
|
|
|
420
454
|
|
|
421
455
|
---
|
|
422
456
|
|
|
423
|
-
##
|
|
457
|
+
## 15. 许可
|
|
424
458
|
|
|
425
459
|
MIT。允许商用、修改与再分发,需保留版权声明。
|
|
426
460
|
|
|
427
461
|
---
|
|
428
462
|
|
|
429
|
-
##
|
|
463
|
+
## 16. 问题反馈
|
|
430
464
|
|
|
431
465
|
以下信息有助于定位问题:
|
|
432
466
|
|
package/client.js
CHANGED
|
@@ -232,6 +232,10 @@ window.__ModuleLoader__.load({
|
|
|
232
232
|
".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
|
|
233
233
|
".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
234
234
|
".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
235
|
+
// The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
|
|
236
|
+
// box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
|
|
237
|
+
".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
|
|
238
|
+
"width:auto;min-width:23px;padding:0 6px;font-size:11px}",
|
|
235
239
|
// Hot injection is the one header action that injects text into the RUNNING turn, so it is the
|
|
236
240
|
// one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
|
|
237
241
|
// User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
|
|
@@ -1382,6 +1386,22 @@ window.__ModuleLoader__.load({
|
|
|
1382
1386
|
return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
|
|
1383
1387
|
}
|
|
1384
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* May the poll loop re-arm after this result?
|
|
1391
|
+
*
|
|
1392
|
+
* A tick aborts the request it replaces, and an aborted request resolves as status -1.
|
|
1393
|
+
* That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
|
|
1394
|
+
* the loop: it only defers to the replacement that is already in the air. Returning
|
|
1395
|
+
* false there is what keeps two loops from running; returning false for anything else
|
|
1396
|
+
* (a real error, a 4xx) would stop the panel from ever updating again.
|
|
1397
|
+
*
|
|
1398
|
+
* Extracted from the hook so the rule is testable without a renderer.
|
|
1399
|
+
*/
|
|
1400
|
+
function wfPollContinues(result, inflight) {
|
|
1401
|
+
if (!result || result.status !== -1) return true;
|
|
1402
|
+
return wfFinite(inflight) === null ? true : inflight <= 0;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1385
1405
|
/**
|
|
1386
1406
|
* One conditional GET. Never throws: every outcome is a value, so the loop can
|
|
1387
1407
|
* always decide what to render and how long to wait.
|
|
@@ -1572,6 +1592,9 @@ window.__ModuleLoader__.load({
|
|
|
1572
1592
|
}
|
|
1573
1593
|
}
|
|
1574
1594
|
return {
|
|
1595
|
+
// A finished plan must not decide a card's status any more; the summary carries
|
|
1596
|
+
// the flag so the status policy can stand down without re-reading the phase.
|
|
1597
|
+
terminal: plan.terminal === true,
|
|
1575
1598
|
statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
|
|
1576
1599
|
anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
|
|
1577
1600
|
skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
|
|
@@ -2085,7 +2108,10 @@ window.__ModuleLoader__.load({
|
|
|
2085
2108
|
errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
|
|
2086
2109
|
seq: wfFinite(found.seq),
|
|
2087
2110
|
error: typeof found.error === "string" && found.error !== "",
|
|
2088
|
-
result:
|
|
2111
|
+
// The host's result boolean is the settled fact: a call that returned with an EMPTY body is
|
|
2112
|
+
// settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
|
|
2113
|
+
// it in the "calling right now" state and its dot pulsed after the call had already returned.
|
|
2114
|
+
result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
|
|
2089
2115
|
durationMs: wfFinite(found.durationMs),
|
|
2090
2116
|
at: wfFinite(found.at),
|
|
2091
2117
|
});
|
|
@@ -2566,7 +2592,17 @@ window.__ModuleLoader__.load({
|
|
|
2566
2592
|
* working session as idle (which would make the curve and the frame look broken).
|
|
2567
2593
|
*/
|
|
2568
2594
|
function wfSessionRunning(view, activeNodeId, nowMs) {
|
|
2569
|
-
|
|
2595
|
+
// The clock is the second witness. view.running is the turn flag, and a turn that never
|
|
2596
|
+
// received its turn/end event (interrupt, crash, a compacted session) leaves it true forever.
|
|
2597
|
+
// Once the last folded event is older than the idle window the session is not working,
|
|
2598
|
+
// whatever the flag says.
|
|
2599
|
+
if (view && typeof view.running === "boolean") {
|
|
2600
|
+
if (view.running !== true) return false;
|
|
2601
|
+
var flagAsOf = wfFinite(view.asOf);
|
|
2602
|
+
var flagNow = wfFinite(nowMs);
|
|
2603
|
+
if (flagAsOf !== null && flagAsOf > 0 && flagNow !== null && flagNow - flagAsOf >= WF_IDLE_AFTER_MS) return false;
|
|
2604
|
+
return true;
|
|
2605
|
+
}
|
|
2570
2606
|
if (typeof activeNodeId === "string" && activeNodeId !== "") return true;
|
|
2571
2607
|
var asOf = wfFinite(view && view.asOf);
|
|
2572
2608
|
if (asOf === null || asOf <= 0) return false;
|
|
@@ -3266,8 +3302,11 @@ window.__ModuleLoader__.load({
|
|
|
3266
3302
|
*/
|
|
3267
3303
|
function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
|
|
3268
3304
|
var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
|
|
3305
|
+
// A finished plan (completed / aborted) is history: its statuses and anchor
|
|
3306
|
+
// cursor describe a turn that is over, so the todo table and the host decide.
|
|
3307
|
+
var live = plan && plan.terminal !== true;
|
|
3269
3308
|
|
|
3270
|
-
if (
|
|
3309
|
+
if (live && planIndex !== null) {
|
|
3271
3310
|
// 1. PAE's own statuses are the published truth.
|
|
3272
3311
|
var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
|
|
3273
3312
|
var fromPae = wfCanonicalStatus(label);
|
|
@@ -7800,6 +7839,9 @@ window.__ModuleLoader__.load({
|
|
|
7800
7839
|
"panel.planning.note": "计划尚未提交,提交后会显示分步。",
|
|
7801
7840
|
"panel.collapse": "收起",
|
|
7802
7841
|
"panel.expand": "展开",
|
|
7842
|
+
"panel.refresh": "刷新",
|
|
7843
|
+
"panel.refresh.hint": "立即重新读取这一步的状态(自动刷新每 0.8 秒一次,这里是不等它的手动兜底)",
|
|
7844
|
+
"panel.refresh.done": "已刷新",
|
|
7803
7845
|
"panel.settings": "设置",
|
|
7804
7846
|
"panel.steps": "总任务分步",
|
|
7805
7847
|
"panel.flow": "分步流程",
|
|
@@ -8010,6 +8052,9 @@ window.__ModuleLoader__.load({
|
|
|
8010
8052
|
"panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
|
|
8011
8053
|
"panel.collapse": "Collapse",
|
|
8012
8054
|
"panel.expand": "Expand",
|
|
8055
|
+
"panel.refresh": "Refresh",
|
|
8056
|
+
"panel.refresh.hint": "Read this step's state again now (auto-refresh runs every 0.8 s; this is the manual fallback)",
|
|
8057
|
+
"panel.refresh.done": "Refreshed",
|
|
8013
8058
|
"panel.settings": "Settings",
|
|
8014
8059
|
"panel.steps": "Task steps",
|
|
8015
8060
|
"panel.flow": "Step flow",
|
|
@@ -8289,6 +8334,10 @@ window.__ModuleLoader__.load({
|
|
|
8289
8334
|
});
|
|
8290
8335
|
var snap = pair[0];
|
|
8291
8336
|
var setSnap = pair[1];
|
|
8337
|
+
// The running loop's own tick, so the header's refresh button can pull once on
|
|
8338
|
+
// demand without waiting for the interval. A ref, not state: swapping the function
|
|
8339
|
+
// must not re-render, and a click must never restart the effect.
|
|
8340
|
+
var tickRef = React.useRef(null);
|
|
8292
8341
|
|
|
8293
8342
|
React.useEffect(
|
|
8294
8343
|
function () {
|
|
@@ -8301,6 +8350,9 @@ window.__ModuleLoader__.load({
|
|
|
8301
8350
|
var timer = 0;
|
|
8302
8351
|
var controller = null;
|
|
8303
8352
|
var etag = "";
|
|
8353
|
+
// How many requests are in the air. A replaced request resolves as -1 and must
|
|
8354
|
+
// not re-arm while the newer one is still going, or two loops would run.
|
|
8355
|
+
var inflight = 0;
|
|
8304
8356
|
|
|
8305
8357
|
setSnap({ status: "loading", view: null, rev: null, error: "" });
|
|
8306
8358
|
|
|
@@ -8325,11 +8377,20 @@ window.__ModuleLoader__.load({
|
|
|
8325
8377
|
}
|
|
8326
8378
|
controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
8327
8379
|
var signal = controller ? controller.signal : undefined;
|
|
8380
|
+
inflight += 1;
|
|
8328
8381
|
wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
|
|
8382
|
+
inflight -= 1;
|
|
8329
8383
|
if (stopped) return;
|
|
8330
|
-
// -1 = this request was aborted
|
|
8331
|
-
//
|
|
8332
|
-
|
|
8384
|
+
// -1 = this request was aborted. A tick aborts the request it replaces, so this
|
|
8385
|
+
// is the ORDINARY path on a slow link, not an exception: returning here without
|
|
8386
|
+
// re-arming stopped the loop for good the first time a poll outlived its tick —
|
|
8387
|
+
// the map then froze while the session kept working, which is the reported
|
|
8388
|
+
// "the map does not update by itself". Only the deliberate stop (stopped) ends it.
|
|
8389
|
+
if (result.status === -1) {
|
|
8390
|
+
// A replaced request: re-arm only when no replacement is still in the air.
|
|
8391
|
+
if (wfPollContinues(result, inflight)) schedule(delay);
|
|
8392
|
+
return;
|
|
8393
|
+
}
|
|
8333
8394
|
if (result.status === 304) {
|
|
8334
8395
|
setSnap(function (prev) {
|
|
8335
8396
|
return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
|
|
@@ -8362,10 +8423,12 @@ window.__ModuleLoader__.load({
|
|
|
8362
8423
|
tick();
|
|
8363
8424
|
}
|
|
8364
8425
|
|
|
8426
|
+
tickRef.current = tick;
|
|
8365
8427
|
tick();
|
|
8366
8428
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
8367
8429
|
return function () {
|
|
8368
8430
|
stopped = true;
|
|
8431
|
+
tickRef.current = null;
|
|
8369
8432
|
window.clearTimeout(timer);
|
|
8370
8433
|
if (controller && typeof controller.abort === "function") {
|
|
8371
8434
|
try {
|
|
@@ -8380,7 +8443,18 @@ window.__ModuleLoader__.load({
|
|
|
8380
8443
|
[sessionId]
|
|
8381
8444
|
);
|
|
8382
8445
|
|
|
8383
|
-
|
|
8446
|
+
// One object, not two returns: every caller already reads the snapshot field by field,
|
|
8447
|
+
// so the refresh hook rides along without changing the shape they destructure.
|
|
8448
|
+
return {
|
|
8449
|
+
status: snap.status,
|
|
8450
|
+
view: snap.view,
|
|
8451
|
+
rev: snap.rev,
|
|
8452
|
+
error: snap.error,
|
|
8453
|
+
refresh: function () {
|
|
8454
|
+
var pull = tickRef.current;
|
|
8455
|
+
if (typeof pull === "function") pull();
|
|
8456
|
+
},
|
|
8457
|
+
};
|
|
8384
8458
|
}
|
|
8385
8459
|
|
|
8386
8460
|
/** The stylesheet lives exactly as long as the mount. */
|
|
@@ -8400,6 +8474,25 @@ window.__ModuleLoader__.load({
|
|
|
8400
8474
|
}, []);
|
|
8401
8475
|
}
|
|
8402
8476
|
|
|
8477
|
+
/**
|
|
8478
|
+
* Clear a "just happened" stamp after a moment, so the button returns to its own
|
|
8479
|
+
* glyph. One timer, cleared on unmount and on a second click (the stamp changes).
|
|
8480
|
+
*/
|
|
8481
|
+
function wfUseFlashClear(stamp, clear) {
|
|
8482
|
+
React.useEffect(
|
|
8483
|
+
function () {
|
|
8484
|
+
if (!(stamp > 0)) return undefined;
|
|
8485
|
+
var timer = window.setTimeout(function () {
|
|
8486
|
+
clear();
|
|
8487
|
+
}, 1200);
|
|
8488
|
+
return function () {
|
|
8489
|
+
window.clearTimeout(timer);
|
|
8490
|
+
};
|
|
8491
|
+
},
|
|
8492
|
+
[stamp]
|
|
8493
|
+
);
|
|
8494
|
+
}
|
|
8495
|
+
|
|
8403
8496
|
/** Alt+W toggles the collapsed state; the listener is removed on unmount. */
|
|
8404
8497
|
function wfUseAltW(updatePrefs) {
|
|
8405
8498
|
React.useEffect(function () {
|
|
@@ -8792,6 +8885,15 @@ window.__ModuleLoader__.load({
|
|
|
8792
8885
|
var prefs = prefsHandle.prefs;
|
|
8793
8886
|
var updatePrefs = prefsHandle.update;
|
|
8794
8887
|
var snap = wfUseSnap(sessionId);
|
|
8888
|
+
// When the header's refresh was last clicked; drives the one-word acknowledgement
|
|
8889
|
+
// that clears itself. Deliberately NOT the poll's own state: a click must not look
|
|
8890
|
+
// like a data change.
|
|
8891
|
+
var refreshedPair = React.useState(0);
|
|
8892
|
+
var refreshed = refreshedPair[0];
|
|
8893
|
+
var setRefreshed = refreshedPair[1];
|
|
8894
|
+
wfUseFlashClear(refreshed, function () {
|
|
8895
|
+
setRefreshed(0);
|
|
8896
|
+
});
|
|
8795
8897
|
var settingsPair = React.useState(false);
|
|
8796
8898
|
var settingsOpen = settingsPair[0];
|
|
8797
8899
|
var setSettingsOpen = settingsPair[1];
|
|
@@ -8890,6 +8992,13 @@ window.__ModuleLoader__.load({
|
|
|
8890
8992
|
onToggleSettings: function () {
|
|
8891
8993
|
setSettingsOpen(!settingsOpen);
|
|
8892
8994
|
},
|
|
8995
|
+
// The manual fallback: one pull, now, without waiting for the interval and without
|
|
8996
|
+
// touching the loop. It reads the same endpoint with the same validator, so a click
|
|
8997
|
+
// is a normal poll and never a second data path.
|
|
8998
|
+
onRefresh: function () {
|
|
8999
|
+
setRefreshed(Date.now());
|
|
9000
|
+
if (typeof snap.refresh === "function") snap.refresh();
|
|
9001
|
+
},
|
|
8893
9002
|
// Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
|
|
8894
9003
|
// right page opens on it. It deliberately does not expand anything in place.
|
|
8895
9004
|
// right page opens on it. It deliberately does not expand anything in place.
|
|
@@ -8968,6 +9077,7 @@ window.__ModuleLoader__.load({
|
|
|
8968
9077
|
|
|
8969
9078
|
return wfRenderPanel(model, prefs, say, actions, {
|
|
8970
9079
|
settingsOpen: settingsOpen,
|
|
9080
|
+
refreshed: refreshed,
|
|
8971
9081
|
status: snap.status,
|
|
8972
9082
|
error: snap.error,
|
|
8973
9083
|
rev: snap.rev,
|
|
@@ -9008,6 +9118,9 @@ window.__ModuleLoader__.load({
|
|
|
9008
9118
|
var pendingCount = wfPendingInjectCount(model.injects);
|
|
9009
9119
|
var extras = ui || {};
|
|
9010
9120
|
var fire = actions || {};
|
|
9121
|
+
// The refresh button's one-second acknowledgement, handed in by the component: the
|
|
9122
|
+
// renderer stays pure, so a self-test can drive it with a fixed stamp.
|
|
9123
|
+
var refreshed = wfFinite(extras.refreshed) === null ? 0 : extras.refreshed;
|
|
9011
9124
|
// the two attributes the living layer needs. `motion` comes from the system's
|
|
9012
9125
|
// prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
|
|
9013
9126
|
// Both live on .wf-root so the capsule is covered as well as the panel.
|
|
@@ -9107,6 +9220,24 @@ window.__ModuleLoader__.load({
|
|
|
9107
9220
|
h(
|
|
9108
9221
|
"div",
|
|
9109
9222
|
{ className: "wf-btns", key: "btns" },
|
|
9223
|
+
// First in the row: it is the escape hatch when the automatic poll is not
|
|
9224
|
+
// delivering, so it must not be the hard one to find.
|
|
9225
|
+
h(
|
|
9226
|
+
"button",
|
|
9227
|
+
{
|
|
9228
|
+
type: "button",
|
|
9229
|
+
key: "refresh",
|
|
9230
|
+
className: refreshed > 0 ? "wf-btn wf-btn-ok" : "wf-btn",
|
|
9231
|
+
"data-dsh-workflow-map": "refresh",
|
|
9232
|
+
"data-refreshed": refreshed > 0 ? "true" : undefined,
|
|
9233
|
+
title: say("panel.refresh.hint"),
|
|
9234
|
+
"aria-label": say("panel.refresh"),
|
|
9235
|
+
onClick: function () {
|
|
9236
|
+
wfFire(fire.onRefresh);
|
|
9237
|
+
},
|
|
9238
|
+
},
|
|
9239
|
+
refreshed > 0 ? say("panel.refresh.done") : "↻"
|
|
9240
|
+
),
|
|
9110
9241
|
h(
|
|
9111
9242
|
"button",
|
|
9112
9243
|
{
|
|
@@ -9577,6 +9708,7 @@ window.__ModuleLoader__.load({
|
|
|
9577
9708
|
sessionIdFromSnapshot: wfSessionIdFromSnapshot,
|
|
9578
9709
|
pollUrl: wfPollUrl,
|
|
9579
9710
|
pollOnce: wfPollOnce,
|
|
9711
|
+
pollContinues: wfPollContinues,
|
|
9580
9712
|
parsePrefs: wfParsePrefs,
|
|
9581
9713
|
sanitizePrefs: wfSanitizePrefs,
|
|
9582
9714
|
applyPrefsPatch: wfApplyPrefsPatch,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workflow-map",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "把当前会话渲染成一张可交互的分步工作流地图:会话标签栏、左栏菜单与主内容区共用同一份面板,逐步显示状态、工具与文件类型明细、进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件、读取按步执行(PAE)计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"docs/host-compatibility.md",
|
|
64
64
|
"docs/screenshot.png",
|
|
65
65
|
"docs/screenshot-detail.png",
|
|
66
|
+
"docs/screenshots",
|
|
66
67
|
"package.json"
|
|
67
68
|
],
|
|
68
69
|
"scripts": {
|
package/src/client/StepColumn.js
CHANGED
|
@@ -155,8 +155,11 @@ function wfHasKey(object, key) {
|
|
|
155
155
|
*/
|
|
156
156
|
function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
|
|
157
157
|
var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
|
|
158
|
+
// A finished plan (completed / aborted) is history: its statuses and anchor
|
|
159
|
+
// cursor describe a turn that is over, so the todo table and the host decide.
|
|
160
|
+
var live = plan && plan.terminal !== true;
|
|
158
161
|
|
|
159
|
-
if (
|
|
162
|
+
if (live && planIndex !== null) {
|
|
160
163
|
// 1. PAE's own statuses are the published truth.
|
|
161
164
|
var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
|
|
162
165
|
var fromPae = wfCanonicalStatus(label);
|
package/src/client/panel.js
CHANGED
|
@@ -34,6 +34,9 @@ var DICT_ZH = {
|
|
|
34
34
|
"panel.planning.note": "计划尚未提交,提交后会显示分步。",
|
|
35
35
|
"panel.collapse": "收起",
|
|
36
36
|
"panel.expand": "展开",
|
|
37
|
+
"panel.refresh": "刷新",
|
|
38
|
+
"panel.refresh.hint": "立即重新读取这一步的状态(自动刷新每 0.8 秒一次,这里是不等它的手动兜底)",
|
|
39
|
+
"panel.refresh.done": "已刷新",
|
|
37
40
|
"panel.settings": "设置",
|
|
38
41
|
"panel.steps": "总任务分步",
|
|
39
42
|
"panel.flow": "分步流程",
|
|
@@ -244,6 +247,9 @@ var DICT_EN = {
|
|
|
244
247
|
"panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
|
|
245
248
|
"panel.collapse": "Collapse",
|
|
246
249
|
"panel.expand": "Expand",
|
|
250
|
+
"panel.refresh": "Refresh",
|
|
251
|
+
"panel.refresh.hint": "Read this step's state again now (auto-refresh runs every 0.8 s; this is the manual fallback)",
|
|
252
|
+
"panel.refresh.done": "Refreshed",
|
|
247
253
|
"panel.settings": "Settings",
|
|
248
254
|
"panel.steps": "Task steps",
|
|
249
255
|
"panel.flow": "Step flow",
|
|
@@ -523,6 +529,10 @@ function wfUseSnap(sessionId) {
|
|
|
523
529
|
});
|
|
524
530
|
var snap = pair[0];
|
|
525
531
|
var setSnap = pair[1];
|
|
532
|
+
// The running loop's own tick, so the header's refresh button can pull once on
|
|
533
|
+
// demand without waiting for the interval. A ref, not state: swapping the function
|
|
534
|
+
// must not re-render, and a click must never restart the effect.
|
|
535
|
+
var tickRef = React.useRef(null);
|
|
526
536
|
|
|
527
537
|
React.useEffect(
|
|
528
538
|
function () {
|
|
@@ -535,6 +545,9 @@ function wfUseSnap(sessionId) {
|
|
|
535
545
|
var timer = 0;
|
|
536
546
|
var controller = null;
|
|
537
547
|
var etag = "";
|
|
548
|
+
// How many requests are in the air. A replaced request resolves as -1 and must
|
|
549
|
+
// not re-arm while the newer one is still going, or two loops would run.
|
|
550
|
+
var inflight = 0;
|
|
538
551
|
|
|
539
552
|
setSnap({ status: "loading", view: null, rev: null, error: "" });
|
|
540
553
|
|
|
@@ -559,11 +572,20 @@ function wfUseSnap(sessionId) {
|
|
|
559
572
|
}
|
|
560
573
|
controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
561
574
|
var signal = controller ? controller.signal : undefined;
|
|
575
|
+
inflight += 1;
|
|
562
576
|
wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
|
|
577
|
+
inflight -= 1;
|
|
563
578
|
if (stopped) return;
|
|
564
|
-
// -1 = this request was aborted
|
|
565
|
-
//
|
|
566
|
-
|
|
579
|
+
// -1 = this request was aborted. A tick aborts the request it replaces, so this
|
|
580
|
+
// is the ORDINARY path on a slow link, not an exception: returning here without
|
|
581
|
+
// re-arming stopped the loop for good the first time a poll outlived its tick —
|
|
582
|
+
// the map then froze while the session kept working, which is the reported
|
|
583
|
+
// "the map does not update by itself". Only the deliberate stop (stopped) ends it.
|
|
584
|
+
if (result.status === -1) {
|
|
585
|
+
// A replaced request: re-arm only when no replacement is still in the air.
|
|
586
|
+
if (wfPollContinues(result, inflight)) schedule(delay);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
567
589
|
if (result.status === 304) {
|
|
568
590
|
setSnap(function (prev) {
|
|
569
591
|
return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
|
|
@@ -596,10 +618,12 @@ function wfUseSnap(sessionId) {
|
|
|
596
618
|
tick();
|
|
597
619
|
}
|
|
598
620
|
|
|
621
|
+
tickRef.current = tick;
|
|
599
622
|
tick();
|
|
600
623
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
601
624
|
return function () {
|
|
602
625
|
stopped = true;
|
|
626
|
+
tickRef.current = null;
|
|
603
627
|
window.clearTimeout(timer);
|
|
604
628
|
if (controller && typeof controller.abort === "function") {
|
|
605
629
|
try {
|
|
@@ -614,7 +638,18 @@ function wfUseSnap(sessionId) {
|
|
|
614
638
|
[sessionId]
|
|
615
639
|
);
|
|
616
640
|
|
|
617
|
-
|
|
641
|
+
// One object, not two returns: every caller already reads the snapshot field by field,
|
|
642
|
+
// so the refresh hook rides along without changing the shape they destructure.
|
|
643
|
+
return {
|
|
644
|
+
status: snap.status,
|
|
645
|
+
view: snap.view,
|
|
646
|
+
rev: snap.rev,
|
|
647
|
+
error: snap.error,
|
|
648
|
+
refresh: function () {
|
|
649
|
+
var pull = tickRef.current;
|
|
650
|
+
if (typeof pull === "function") pull();
|
|
651
|
+
},
|
|
652
|
+
};
|
|
618
653
|
}
|
|
619
654
|
|
|
620
655
|
/** The stylesheet lives exactly as long as the mount. */
|
|
@@ -634,6 +669,25 @@ function wfUseStyle() {
|
|
|
634
669
|
}, []);
|
|
635
670
|
}
|
|
636
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Clear a "just happened" stamp after a moment, so the button returns to its own
|
|
674
|
+
* glyph. One timer, cleared on unmount and on a second click (the stamp changes).
|
|
675
|
+
*/
|
|
676
|
+
function wfUseFlashClear(stamp, clear) {
|
|
677
|
+
React.useEffect(
|
|
678
|
+
function () {
|
|
679
|
+
if (!(stamp > 0)) return undefined;
|
|
680
|
+
var timer = window.setTimeout(function () {
|
|
681
|
+
clear();
|
|
682
|
+
}, 1200);
|
|
683
|
+
return function () {
|
|
684
|
+
window.clearTimeout(timer);
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
[stamp]
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
637
691
|
/** Alt+W toggles the collapsed state; the listener is removed on unmount. */
|
|
638
692
|
function wfUseAltW(updatePrefs) {
|
|
639
693
|
React.useEffect(function () {
|
|
@@ -1026,6 +1080,15 @@ function WorkflowMapPanel(props) {
|
|
|
1026
1080
|
var prefs = prefsHandle.prefs;
|
|
1027
1081
|
var updatePrefs = prefsHandle.update;
|
|
1028
1082
|
var snap = wfUseSnap(sessionId);
|
|
1083
|
+
// When the header's refresh was last clicked; drives the one-word acknowledgement
|
|
1084
|
+
// that clears itself. Deliberately NOT the poll's own state: a click must not look
|
|
1085
|
+
// like a data change.
|
|
1086
|
+
var refreshedPair = React.useState(0);
|
|
1087
|
+
var refreshed = refreshedPair[0];
|
|
1088
|
+
var setRefreshed = refreshedPair[1];
|
|
1089
|
+
wfUseFlashClear(refreshed, function () {
|
|
1090
|
+
setRefreshed(0);
|
|
1091
|
+
});
|
|
1029
1092
|
var settingsPair = React.useState(false);
|
|
1030
1093
|
var settingsOpen = settingsPair[0];
|
|
1031
1094
|
var setSettingsOpen = settingsPair[1];
|
|
@@ -1124,6 +1187,13 @@ function WorkflowMapPanel(props) {
|
|
|
1124
1187
|
onToggleSettings: function () {
|
|
1125
1188
|
setSettingsOpen(!settingsOpen);
|
|
1126
1189
|
},
|
|
1190
|
+
// The manual fallback: one pull, now, without waiting for the interval and without
|
|
1191
|
+
// touching the loop. It reads the same endpoint with the same validator, so a click
|
|
1192
|
+
// is a normal poll and never a second data path.
|
|
1193
|
+
onRefresh: function () {
|
|
1194
|
+
setRefreshed(Date.now());
|
|
1195
|
+
if (typeof snap.refresh === "function") snap.refresh();
|
|
1196
|
+
},
|
|
1127
1197
|
// Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
|
|
1128
1198
|
// right page opens on it. It deliberately does not expand anything in place.
|
|
1129
1199
|
// right page opens on it. It deliberately does not expand anything in place.
|
|
@@ -1202,6 +1272,7 @@ function WorkflowMapPanel(props) {
|
|
|
1202
1272
|
|
|
1203
1273
|
return wfRenderPanel(model, prefs, say, actions, {
|
|
1204
1274
|
settingsOpen: settingsOpen,
|
|
1275
|
+
refreshed: refreshed,
|
|
1205
1276
|
status: snap.status,
|
|
1206
1277
|
error: snap.error,
|
|
1207
1278
|
rev: snap.rev,
|
|
@@ -1242,6 +1313,9 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
|
|
|
1242
1313
|
var pendingCount = wfPendingInjectCount(model.injects);
|
|
1243
1314
|
var extras = ui || {};
|
|
1244
1315
|
var fire = actions || {};
|
|
1316
|
+
// The refresh button's one-second acknowledgement, handed in by the component: the
|
|
1317
|
+
// renderer stays pure, so a self-test can drive it with a fixed stamp.
|
|
1318
|
+
var refreshed = wfFinite(extras.refreshed) === null ? 0 : extras.refreshed;
|
|
1245
1319
|
// the two attributes the living layer needs. `motion` comes from the system's
|
|
1246
1320
|
// prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
|
|
1247
1321
|
// Both live on .wf-root so the capsule is covered as well as the panel.
|
|
@@ -1341,6 +1415,24 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
|
|
|
1341
1415
|
h(
|
|
1342
1416
|
"div",
|
|
1343
1417
|
{ className: "wf-btns", key: "btns" },
|
|
1418
|
+
// First in the row: it is the escape hatch when the automatic poll is not
|
|
1419
|
+
// delivering, so it must not be the hard one to find.
|
|
1420
|
+
h(
|
|
1421
|
+
"button",
|
|
1422
|
+
{
|
|
1423
|
+
type: "button",
|
|
1424
|
+
key: "refresh",
|
|
1425
|
+
className: refreshed > 0 ? "wf-btn wf-btn-ok" : "wf-btn",
|
|
1426
|
+
"data-dsh-workflow-map": "refresh",
|
|
1427
|
+
"data-refreshed": refreshed > 0 ? "true" : undefined,
|
|
1428
|
+
title: say("panel.refresh.hint"),
|
|
1429
|
+
"aria-label": say("panel.refresh"),
|
|
1430
|
+
onClick: function () {
|
|
1431
|
+
wfFire(fire.onRefresh);
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
refreshed > 0 ? say("panel.refresh.done") : "↻"
|
|
1435
|
+
),
|
|
1344
1436
|
h(
|
|
1345
1437
|
"button",
|
|
1346
1438
|
{
|
|
@@ -1811,6 +1903,7 @@ var __test = {
|
|
|
1811
1903
|
sessionIdFromSnapshot: wfSessionIdFromSnapshot,
|
|
1812
1904
|
pollUrl: wfPollUrl,
|
|
1813
1905
|
pollOnce: wfPollOnce,
|
|
1906
|
+
pollContinues: wfPollContinues,
|
|
1814
1907
|
parsePrefs: wfParsePrefs,
|
|
1815
1908
|
sanitizePrefs: wfSanitizePrefs,
|
|
1816
1909
|
applyPrefsPatch: wfApplyPrefsPatch,
|
package/src/client/pulse.js
CHANGED
|
@@ -53,7 +53,17 @@ function wfMotionMode(matchMediaFn) {
|
|
|
53
53
|
* working session as idle (which would make the curve and the frame look broken).
|
|
54
54
|
*/
|
|
55
55
|
function wfSessionRunning(view, activeNodeId, nowMs) {
|
|
56
|
-
|
|
56
|
+
// The clock is the second witness. view.running is the turn flag, and a turn that never
|
|
57
|
+
// received its turn/end event (interrupt, crash, a compacted session) leaves it true forever.
|
|
58
|
+
// Once the last folded event is older than the idle window the session is not working,
|
|
59
|
+
// whatever the flag says.
|
|
60
|
+
if (view && typeof view.running === "boolean") {
|
|
61
|
+
if (view.running !== true) return false;
|
|
62
|
+
var flagAsOf = wfFinite(view.asOf);
|
|
63
|
+
var flagNow = wfFinite(nowMs);
|
|
64
|
+
if (flagAsOf !== null && flagAsOf > 0 && flagNow !== null && flagNow - flagAsOf >= WF_IDLE_AFTER_MS) return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
57
67
|
if (typeof activeNodeId === "string" && activeNodeId !== "") return true;
|
|
58
68
|
var asOf = wfFinite(view && view.asOf);
|
|
59
69
|
if (asOf === null || asOf <= 0) return false;
|
package/src/client/store.js
CHANGED
|
@@ -175,6 +175,22 @@ function wfPollUrl(sessionId) {
|
|
|
175
175
|
return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* May the poll loop re-arm after this result?
|
|
180
|
+
*
|
|
181
|
+
* A tick aborts the request it replaces, and an aborted request resolves as status -1.
|
|
182
|
+
* That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
|
|
183
|
+
* the loop: it only defers to the replacement that is already in the air. Returning
|
|
184
|
+
* false there is what keeps two loops from running; returning false for anything else
|
|
185
|
+
* (a real error, a 4xx) would stop the panel from ever updating again.
|
|
186
|
+
*
|
|
187
|
+
* Extracted from the hook so the rule is testable without a renderer.
|
|
188
|
+
*/
|
|
189
|
+
function wfPollContinues(result, inflight) {
|
|
190
|
+
if (!result || result.status !== -1) return true;
|
|
191
|
+
return wfFinite(inflight) === null ? true : inflight <= 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
/**
|
|
179
195
|
* One conditional GET. Never throws: every outcome is a value, so the loop can
|
|
180
196
|
* always decide what to render and how long to wait.
|
|
@@ -365,6 +381,9 @@ function wfPlanSummary(plan) {
|
|
|
365
381
|
}
|
|
366
382
|
}
|
|
367
383
|
return {
|
|
384
|
+
// A finished plan must not decide a card's status any more; the summary carries
|
|
385
|
+
// the flag so the status policy can stand down without re-reading the phase.
|
|
386
|
+
terminal: plan.terminal === true,
|
|
368
387
|
statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
|
|
369
388
|
anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
|
|
370
389
|
skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
|
|
@@ -878,7 +897,10 @@ function wfBuildModel(view, prefs, nowMs) {
|
|
|
878
897
|
errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
|
|
879
898
|
seq: wfFinite(found.seq),
|
|
880
899
|
error: typeof found.error === "string" && found.error !== "",
|
|
881
|
-
result:
|
|
900
|
+
// The host's result boolean is the settled fact: a call that returned with an EMPTY body is
|
|
901
|
+
// settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
|
|
902
|
+
// it in the "calling right now" state and its dot pulsed after the call had already returned.
|
|
903
|
+
result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
|
|
882
904
|
durationMs: wfFinite(found.durationMs),
|
|
883
905
|
at: wfFinite(found.at),
|
|
884
906
|
});
|
package/src/client/theme.js
CHANGED
|
@@ -203,6 +203,10 @@ var WF_CSS = [
|
|
|
203
203
|
".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
|
|
204
204
|
".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
205
205
|
".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
206
|
+
// The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
|
|
207
|
+
// box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
|
|
208
|
+
".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
|
|
209
|
+
"width:auto;min-width:23px;padding:0 6px;font-size:11px}",
|
|
206
210
|
// Hot injection is the one header action that injects text into the RUNNING turn, so it is the
|
|
207
211
|
// one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
|
|
208
212
|
// User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
|
package/src/fold.mjs
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
// demotes a step that still carried `done`/`running`, and a step any table no longer names is
|
|
69
69
|
// dropped). Checkpoints written before the bump hold ledgers from the older mapping — wrong
|
|
70
70
|
// statuses and leftover rows — so the same contract applies: drop and rebuild from the log.
|
|
71
|
-
export const STATE_VERSION =
|
|
71
|
+
export const STATE_VERSION = 5;
|
|
72
72
|
|
|
73
73
|
export const SCHEMA_VERSION = 1;
|
|
74
74
|
|
|
@@ -548,7 +548,11 @@ function percentFor(state, step, now) {
|
|
|
548
548
|
const value = measured;
|
|
549
549
|
return { percent: clamp(value, 0, 99), source: "todos" };
|
|
550
550
|
}
|
|
551
|
-
|
|
551
|
+
// The model's latest statement is the number, and an open step is 0..99: 100 % on a running
|
|
552
|
+
// row is a claim the log does not support. The high-water record is deliberately not applied
|
|
553
|
+
// here, as the comment above states; applying it freezes a row at the highest number it ever
|
|
554
|
+
// reached, so later reports cannot move the bar.
|
|
555
|
+
return { percent: clamp(reported.percent, 0, 99), source: "model" };
|
|
552
556
|
}
|
|
553
557
|
// The checklist's own completion ratio — a real measurement, so it sits above the time baseline.
|
|
554
558
|
// It never claims 100 % for an OPEN step: the remaining row is still in progress.
|
|
@@ -620,6 +624,10 @@ function newNode(kind, tool, title, at, extra) {
|
|
|
620
624
|
argsText: "",
|
|
621
625
|
resultHead: "",
|
|
622
626
|
error: "",
|
|
627
|
+
// Whether the call returned. Separate from the body text: a call that succeeds with
|
|
628
|
+
// no output has an empty resultHead and is still settled. The browser half colours by
|
|
629
|
+
// this flag, so deriving it from resultHead leaves every silent call "running".
|
|
630
|
+
settled: false,
|
|
623
631
|
at,
|
|
624
632
|
durationMs: null,
|
|
625
633
|
// filled by appendNode when the caller did not pass one (see toolKey).
|
|
@@ -751,6 +759,25 @@ function argsDigest(args) {
|
|
|
751
759
|
// fold
|
|
752
760
|
// ---------------------------------------------------------------------------
|
|
753
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Reopen a closed row without letting its 100 % speak for the reopened one.
|
|
764
|
+
*
|
|
765
|
+
* A row taken back to in_progress used to keep the percent and the high-water record of its
|
|
766
|
+
* closed state, so the panel showed an in-progress step pinned at 100 %: a bar that can never
|
|
767
|
+
* move again, and a later self-report cannot lower it. A closed record's number belongs to
|
|
768
|
+
* that closure, not to the row that reopens it.
|
|
769
|
+
*/
|
|
770
|
+
function reopenStep(step, time) {
|
|
771
|
+
const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
|
|
772
|
+
const base = {
|
|
773
|
+
...step,
|
|
774
|
+
status: STEP_STATUS.running,
|
|
775
|
+
endedAt: null,
|
|
776
|
+
startedAt: reopened || !step.startedAt ? time : step.startedAt,
|
|
777
|
+
};
|
|
778
|
+
return reopened ? { ...base, percent: null, percentSource: "none", maxPercent: 0 } : base;
|
|
779
|
+
}
|
|
780
|
+
|
|
754
781
|
function foldTodos(state, event, time) {
|
|
755
782
|
const data = dataOf(event);
|
|
756
783
|
const raw = data && Array.isArray(data.todos) ? data.todos : [];
|
|
@@ -844,7 +871,7 @@ function foldTodos(state, event, time) {
|
|
|
844
871
|
if (next.startedAt > 0) durations = [...durations, time - next.startedAt].filter((value) => value > 0);
|
|
845
872
|
} else if (item.status === "in_progress" && next.status !== STEP_STATUS.running) {
|
|
846
873
|
// A step re-opened by a re-plan or a retry: keep its ledger row, restart the clock.
|
|
847
|
-
steps[at] =
|
|
874
|
+
steps[at] = reopenStep(next, time);
|
|
848
875
|
} else if (next !== step) {
|
|
849
876
|
steps[at] = next;
|
|
850
877
|
}
|
|
@@ -863,9 +890,18 @@ function foldTodos(state, event, time) {
|
|
|
863
890
|
if (step.startedAt > 0) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
|
|
864
891
|
steps[at] = closeStep(step, time);
|
|
865
892
|
} else if (claim.status === "in_progress" && step.status !== STEP_STATUS.running) {
|
|
866
|
-
steps[at] =
|
|
893
|
+
steps[at] = reopenStep(step, time);
|
|
867
894
|
} else if (claim.status === "pending" && step.status !== STEP_STATUS.pending) {
|
|
868
|
-
|
|
895
|
+
// Same rule for a demotion: the row is not done, so it cannot keep a done row's number.
|
|
896
|
+
steps[at] = {
|
|
897
|
+
...step,
|
|
898
|
+
status: STEP_STATUS.pending,
|
|
899
|
+
endedAt: null,
|
|
900
|
+
startedAt: 0,
|
|
901
|
+
percent: null,
|
|
902
|
+
percentSource: "none",
|
|
903
|
+
maxPercent: 0,
|
|
904
|
+
};
|
|
869
905
|
}
|
|
870
906
|
}
|
|
871
907
|
|
|
@@ -965,6 +1001,8 @@ function foldToolResult(state, event, time, seq) {
|
|
|
965
1001
|
...previous,
|
|
966
1002
|
resultHead: isError ? "" : line(body),
|
|
967
1003
|
error: isError ? line(body) : "",
|
|
1004
|
+
// Settled means "the call came back", whatever its body: empty output is not a pending call.
|
|
1005
|
+
settled: true,
|
|
968
1006
|
durationMs: previous.at > 0 && time > previous.at ? time - previous.at : null,
|
|
969
1007
|
};
|
|
970
1008
|
const answered = { ...state, asOf: time, nodes };
|
|
@@ -979,7 +1017,14 @@ function foldToolResult(state, event, time, seq) {
|
|
|
979
1017
|
),
|
|
980
1018
|
}
|
|
981
1019
|
: answered;
|
|
982
|
-
|
|
1020
|
+
// A result is not an activity. Re-asserting the node that just finished as the active one
|
|
1021
|
+
// makes the panel claim "calling right now" about a call that already returned. Keep the
|
|
1022
|
+
// node a newer call opened, and otherwise hand the highlight back to the step.
|
|
1023
|
+
const keepActive =
|
|
1024
|
+
state.active && typeof state.active.nodeId === "string" && state.active.nodeId !== "" && state.active.nodeId !== previous.id
|
|
1025
|
+
? state.active.nodeId
|
|
1026
|
+
: null;
|
|
1027
|
+
const next = withActive(settled, keepActive, time);
|
|
983
1028
|
// A tool result is the moment two branch decisions become possible: a job that is STILL
|
|
984
1029
|
// open (background) is promoted to a lane, and a launcher's acknowledgement attaches its
|
|
985
1030
|
// run id. Both are no-ops for ordinary calls.
|
|
@@ -2326,6 +2371,8 @@ function nodeView(node) {
|
|
|
2326
2371
|
files: node.files.map((file) => ({ path: file.path, action: file.action })),
|
|
2327
2372
|
resultHead: node.resultHead,
|
|
2328
2373
|
error: node.error,
|
|
2374
|
+
// the boolean the browser half colours by; see newNode.settled
|
|
2375
|
+
result: node.settled === true,
|
|
2329
2376
|
at: node.at,
|
|
2330
2377
|
durationMs: node.durationMs,
|
|
2331
2378
|
// the detail's meta line names the event that produced the node. Null for the
|
|
@@ -2458,10 +2505,24 @@ function syncToTodos(state) {
|
|
|
2458
2505
|
// the record it landed on was still pending — both seen in one session on 2026-10-30.
|
|
2459
2506
|
const rowStatus = (step, row) => {
|
|
2460
2507
|
if (row.status === "completed") return step.status === STEP_STATUS.done ? step : closeStep(step, state.asOf || 0);
|
|
2508
|
+
// A closed record cannot speak for an open row. When the table takes a row back to
|
|
2509
|
+
// in_progress/pending, the record it is bound to may be a step this table already closed,
|
|
2510
|
+
// or an older row a reworded title landed on. Keeping that percent and high-water renders
|
|
2511
|
+
// a brand-new first row at 100 % the instant it appears.
|
|
2512
|
+
const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
|
|
2513
|
+
const carried = reopened
|
|
2514
|
+
? { percent: null, percentSource: "none", maxPercent: 0 }
|
|
2515
|
+
: { percent: step.percent, percentSource: step.percentSource, maxPercent: step.maxPercent };
|
|
2461
2516
|
if (row.status === "in_progress") {
|
|
2462
|
-
return {
|
|
2517
|
+
return {
|
|
2518
|
+
...step,
|
|
2519
|
+
...carried,
|
|
2520
|
+
status: STEP_STATUS.running,
|
|
2521
|
+
endedAt: null,
|
|
2522
|
+
startedAt: reopened || !step.startedAt ? state.asOf || 0 : step.startedAt,
|
|
2523
|
+
};
|
|
2463
2524
|
}
|
|
2464
|
-
return { ...step, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
|
|
2525
|
+
return { ...step, ...carried, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
|
|
2465
2526
|
};
|
|
2466
2527
|
|
|
2467
2528
|
const byKey = new Map();
|
package/src/orchestrator.mjs
CHANGED
|
@@ -37,6 +37,21 @@ export const PLAN_VERSION = 1;
|
|
|
37
37
|
export const PLAN_ROOT = ".pae";
|
|
38
38
|
export const PLAN_FILE = "orchestrator.json";
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Plan phases that are history rather than a live view.
|
|
42
|
+
*
|
|
43
|
+
* PAE's own vocabulary (dsh-plan-and-execute): none / planning / scheduled /
|
|
44
|
+
* executing / paused / completed / aborted. The last two never resume, and the
|
|
45
|
+
* file that carries them is never removed, so they must not speak for a row the
|
|
46
|
+
* session created after the plan ended.
|
|
47
|
+
*/
|
|
48
|
+
const TERMINAL_PHASES = new Set(["completed", "aborted"]);
|
|
49
|
+
|
|
50
|
+
/** Is this normalized plan finished? True for completed and aborted. */
|
|
51
|
+
export function planIsTerminal(plan) {
|
|
52
|
+
return isRecord(plan) && TERMINAL_PHASES.has(String(plan.phase ?? "").trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
/** Bounds. The plan is display data; a runaway file must not grow the session cache. */
|
|
41
56
|
export const MAX_PLAN_STEPS = 200;
|
|
42
57
|
export const MAX_STEP_REPORTS = 200;
|
|
@@ -197,9 +212,14 @@ export function normalizePlanDocument(raw, meta) {
|
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
const rawTask = typeof doc.task === "string" ? doc.task : "";
|
|
215
|
+
const phase = text(doc.phase, MAX_PHASE);
|
|
200
216
|
const result = {
|
|
201
217
|
ver: PLAN_VERSION,
|
|
202
|
-
phase
|
|
218
|
+
phase,
|
|
219
|
+
// Published as a boolean as well: the browser half decides card statuses from this
|
|
220
|
+
// section alone, and "is this plan finished" must not be re-derived from a string
|
|
221
|
+
// vocabulary in two places.
|
|
222
|
+
terminal: TERMINAL_PHASES.has(phase.trim().toLowerCase()),
|
|
203
223
|
task: text(rawTask, MAX_TASK),
|
|
204
224
|
// The first non-empty line, before flattening: it is the panel's header.
|
|
205
225
|
taskTitle: line(rawTask, MAX_TASK_TITLE),
|
|
@@ -263,6 +283,10 @@ export function applyPlanToView(view, plan) {
|
|
|
263
283
|
const normalized = isRecord(plan) ? plan : null;
|
|
264
284
|
const result = { ...view, plan: normalized };
|
|
265
285
|
if (normalized === null) return result;
|
|
286
|
+
// A finished plan is published for the detail page but patches nothing: its
|
|
287
|
+
// rows belong to a turn that is over, and the current todo table is the newer
|
|
288
|
+
// statement. See planIsTerminal.
|
|
289
|
+
if (planIsTerminal(normalized)) return result;
|
|
266
290
|
|
|
267
291
|
if (normalized.taskTitle !== "") result.task = normalized.taskTitle;
|
|
268
292
|
|