dsh-global-todo 0.12.7 → 0.12.9
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 +48 -0
- package/README.md +6 -3
- package/README.zh.md +1 -1
- package/docs/WINDOWS.md +5 -0
- package/lib/atomic.js +77 -0
- package/lib/capture.js +6 -0
- package/lib/diagnostics.js +10 -10
- package/lib/errors.js +3 -0
- package/lib/index.js +14 -1
- package/lib/ingest.js +4 -1
- package/lib/model.js +44 -4
- package/lib/routes.js +58 -1
- package/lib/service.js +13 -1
- package/lib/store.js +13 -65
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
## [0.12.9] — 2026-10-21
|
|
2
|
+
|
|
3
|
+
Everything a Windows tester found in a 41-case sweep, answered (#3).
|
|
4
|
+
|
|
5
|
+
- **`P0`–`P3` are accepted as input.** The panel printed the labels and the API rejected them.
|
|
6
|
+
`priority: "P0"` now means `urgent` (likewise `P1`–`P3`), in create and in patch.
|
|
7
|
+
- **A paraphrase of something finished minutes ago is the same errand.** The 7-day cooldown only
|
|
8
|
+
compared exact titles, so 「T3 逾期项(再办一次)」 slipped past a row completed a minute earlier.
|
|
9
|
+
Similar wording now counts, with its own reason (`similar-recently-done`).
|
|
10
|
+
- **`POST /note` needs text.** An empty note appended a nameless entry to the activity trail; it is
|
|
11
|
+
now `400 invalid-body`.
|
|
12
|
+
- **`POST /diagnostics` needs a scope.** Without it every report collapsed into one nameless bucket
|
|
13
|
+
in `/health`.
|
|
14
|
+
- **A missing `id` is a bad body, not a missing record.** `POST /dispatch {}` answered 404; it is 400
|
|
15
|
+
now, matching the documented error contract.
|
|
16
|
+
- **One bad ingest row no longer aborts the batch.** Each item is isolated (like capture), and the
|
|
17
|
+
ingest title cap is the model's own 200 rather than a second opinion at 500.
|
|
18
|
+
- **Out-of-range settings are reported.** `{idleMinutes: -5}` answered 200 with 5; a provided value
|
|
19
|
+
outside its range is `400 invalid-body` instead of a silent clamp.
|
|
20
|
+
- **`POST /restore` refuses partial records.** `{id, title}` used to restore with an invented
|
|
21
|
+
`createdAt`, empty tags and a reassigned `seq`; a lossy success is worse than a refusal.
|
|
22
|
+
- **`/health` says when the installed package is newer than the running module**
|
|
23
|
+
(`packageVersion`, `stale`), which is the silent trap behind "I upgraded and the panel calls a
|
|
24
|
+
route that 404s".
|
|
25
|
+
- **Smaller ones**: `invalid-evidence` / `invalid-steps` / `invalid-decision` are now listed in
|
|
26
|
+
`ERROR_CODES`; an over-long step is rejected like an over-long list (both used to differ); the
|
|
27
|
+
Markdown section notes that `doing`/`cancelled` come back as `open`.
|
|
28
|
+
- **Reviewed, not changed**: an agent completion already sets `review.pending` in every path
|
|
29
|
+
(`model.js:545/641/685`), so the auto-complete case the report describes is covered since 0.7.
|
|
30
|
+
|
|
31
|
+
## [0.12.8] — 2026-10-21
|
|
32
|
+
|
|
33
|
+
The Windows runner kept finding things after 0.12.7. Four more, all real.
|
|
34
|
+
|
|
35
|
+
- **`mkdir` over a file is `EEXIST` on Windows**, not `ENOTDIR`. The store translated only the
|
|
36
|
+
POSIX code, so an unwritable location could surface as a raw error; both now become
|
|
37
|
+
`store-unwritable`.
|
|
38
|
+
- **The diagnostics trim could lose its rename** while a reader held the log. The atomic write is
|
|
39
|
+
now shared (`lib/atomic.js`) by the store, settings and diagnostics, with a longer retry budget
|
|
40
|
+
for a file that is read constantly.
|
|
41
|
+
- **`tools/preview.mjs` created a directory named `panel.html`** on Windows: it stripped the file
|
|
42
|
+
name with a SQL-style regex on `/`, which matched nothing there. `path.dirname` instead — this is
|
|
43
|
+
what made `npm pack --dry-run` fail on the Windows runner.
|
|
44
|
+
- **Two test assertions were POSIX literals** (a default store path and a `path.resolve` result).
|
|
45
|
+
They now compare resolved paths, which is what the contract actually says.
|
|
46
|
+
- **CI is green on `windows-latest`** — tests, static checks, package shape and
|
|
47
|
+
`node tools/windows-check.mjs` all run there.
|
|
48
|
+
|
|
1
49
|
## [0.12.7] — 2026-10-21
|
|
2
50
|
|
|
3
51
|
Windows, tested instead of assumed.
|
package/README.md
CHANGED
|
@@ -94,7 +94,9 @@ description, steps, attachments and progress notes stay.
|
|
|
94
94
|
|
|
95
95
|
### Markdown in and out
|
|
96
96
|
|
|
97
|
-
The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand
|
|
97
|
+
The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand
|
|
98
|
+
(note that `doing` and `cancelled` come back as `open`: a hand-edited list has no work-in-progress
|
|
99
|
+
state, and the JSON remains the format that round-trips exactly):
|
|
98
100
|
|
|
99
101
|
```text
|
|
100
102
|
- [ ] 标题
|
|
@@ -288,7 +290,8 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
288
290
|
wall of rows. A conversation with one item shows that row directly; items with no source
|
|
289
291
|
conversation (added in the panel) sit under 手动添加. `time` is the original
|
|
290
292
|
overdue / today / soon / later / no-due buckets, `level` groups by priority.
|
|
291
|
-
- **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low
|
|
293
|
+
- **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low`; the API accepts
|
|
294
|
+
either the canonical names or the labels (`priority: "P0"` works), with a badge on the
|
|
292
295
|
row. Records written before levels existed keep their `high/normal/low`, so nothing needed a
|
|
293
296
|
migration. A conversation group shows the highest level it holds, and a green dot marks a
|
|
294
297
|
group with a conversation still running.
|
|
@@ -378,7 +381,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
378
381
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
379
382
|
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
|
|
380
383
|
| POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }` with `Authorization: Bearer <settings.ingestToken>` — external items; below `ingestConfirmBelow` they wait in the inbox |
|
|
381
|
-
| GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded) |
|
|
384
|
+
| GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded; `version` is the running module and `packageVersion`/`stale` say whether the installed package is newer than the running one) |
|
|
382
385
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
|
|
383
386
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
|
384
387
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
package/README.zh.md
CHANGED
|
@@ -217,7 +217,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
217
217
|
`auto` 优先复用这条待办已绑定的会话、其次是当前对话、最后才新开;`new` 总是新开对话;`bound` 只发给已绑定的对话,不在就明确报出原因。
|
|
218
218
|
找不到任何可用的对话时**明确失败**,不会偷偷开一个新的。
|
|
219
219
|
- **分组维度**:工具栏上有 `对话 / 时间 / 级别` 三个维度,**默认按对话**——同一个 agent 对话提交的待办会**合并成一条**(显示「N 条」,点开才展开),这样几次「抓一下待办」不会把清单糊成一片。单条的对话直接显示那一行;没有来源对话的(在面板里手加的)归到「手动添加」。`时间` 是原来的逾期 / 今天 / 接下来 / 以后 / 没有截止时间,`级别` 按高 / 一般 / 低分组。
|
|
220
|
-
- **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3
|
|
220
|
+
- **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3 低`;接口两种写法都收(`priority: "P0"` 或 `"urgent"`),旧记录里的 high/normal/low 原样有效,默认 P2。行上有徽标,对话分组头显示组内**最高级别**,绿色小圆点表示该对话里还有在跑的会话。
|
|
221
221
|
- **从本对话抓取**:面板上一个按钮,把「请把这段对话里需要跟进的事用 `global_todo_capture` 抓一下」注入你正在说话的那个对话——聊着聊着把该跟进的事一次变成待办,不用自己一条条敲。提示词里同样写清了「不要记什么」(会话计划、闲聊、已完成的事),否则抓取只会制造噪音。
|
|
222
222
|
- **自动完成**:派发出去的会话**干净结束**时,宿主自己把这条待办标成完成,并附上**它最后那段回复**作为完成说明、**这一轮改动的文件**作为产出——不用等模型记得调工具。面板上完成的行显示「完成于 …」加产出芯片,展开详情有「完成记录」段(说明 + 产出路径,可点开或复制)。
|
|
223
223
|
两条护栏值得知道:**只对刚派发过的会话生效**(你在那个会话里随便聊两句不会把待办完成掉),**失败 / 中断的回合不标记完成**(宁可留着未完成,也不能让一份干砸的活显示成已完成)。
|
package/docs/WINDOWS.md
CHANGED
|
@@ -21,6 +21,11 @@ Windows machine should run.
|
|
|
21
21
|
CI runs the suite on **ubuntu-latest and windows-latest** (node 22.19 on both, node 24 on Linux).
|
|
22
22
|
Windows runs `node tools/windows-check.mjs` as an extra step.
|
|
23
23
|
|
|
24
|
+
That matrix earned its keep on its first day: it found four things Linux could not see — a `mkdir`
|
|
25
|
+
over a file reporting `EEXIST` instead of `ENOTDIR`, the diagnostics trim losing a rename to a busy
|
|
26
|
+
reader, `tools/preview.mjs` creating a directory named `panel.html` (a POSIX-only regex), and two
|
|
27
|
+
test assertions that pinned POSIX literals. All fixed in 0.12.8.
|
|
28
|
+
|
|
24
29
|
## The real-filesystem check
|
|
25
30
|
|
|
26
31
|
```powershell
|
package/lib/atomic.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, shared file-system guarantees.
|
|
3
|
+
*
|
|
4
|
+
* Both the todo file and the diagnostics log are replaced by "write a sibling, then
|
|
5
|
+
* rename". That pattern has two platform traps this module exists to absorb:
|
|
6
|
+
*
|
|
7
|
+
* 1. `fsync` on Windows is `FlushFileBuffers`, which needs a **writable** handle. A
|
|
8
|
+
* read-only handle always throws `EPERM`, and a save that throws before its rename
|
|
9
|
+
* is a save that never happened (issue #2).
|
|
10
|
+
* 2. Antivirus, the search indexer and a concurrent reader can hold a handle for a few
|
|
11
|
+
* milliseconds, which makes the rename fail with `EPERM`/`EBUSY`/`EACCES`. Retrying
|
|
12
|
+
* those turns a spurious failure into a write; other errors still surface at once.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-global-todo/atomic
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { open, rename, unlink } from 'node:fs/promises'
|
|
18
|
+
|
|
19
|
+
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rename, retrying only the errors that mean "someone else has it for a moment".
|
|
23
|
+
*
|
|
24
|
+
* @param {{ rename: (from: string, to: string) => Promise<void> }} io
|
|
25
|
+
* @param {string} from temporary file
|
|
26
|
+
* @param {string} to target file
|
|
27
|
+
*/
|
|
28
|
+
export async function renameWithRetry(io, from, to, attempts = 4) {
|
|
29
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
30
|
+
try {
|
|
31
|
+
await io.rename(from, to)
|
|
32
|
+
return
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (attempt >= attempts || !TRANSIENT_RENAME_CODES.has(error?.code)) throw error
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Replace a file atomically, and survive a platform that refuses `fsync`.
|
|
42
|
+
*
|
|
43
|
+
* The handle is opened for **writing** on purpose: on Windows `handle.sync()` needs a
|
|
44
|
+
* writable handle, and opening the temp file read-only made every write throw before the
|
|
45
|
+
* rename (the todo list looked empty while `*.tmp` debris accumulated).
|
|
46
|
+
*
|
|
47
|
+
* The sync itself is best effort: durability matters, but losing a save because a
|
|
48
|
+
* platform will not flush a handle is worse than a save that reaches the page cache.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} file absolute path of the target
|
|
51
|
+
* @param {string} payload bytes to write
|
|
52
|
+
* @param {{ fs?: object, onSyncError?: (error: unknown) => void, renameAttempts?: number }} [options]
|
|
53
|
+
* injected for tests; `renameAttempts` is there for callers whose target is read constantly
|
|
54
|
+
*/
|
|
55
|
+
export async function atomicWrite(file, payload, options = {}) {
|
|
56
|
+
const io = options.fs ?? { open, rename, unlink }
|
|
57
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
58
|
+
let handle = null
|
|
59
|
+
let renamed = false
|
|
60
|
+
try {
|
|
61
|
+
handle = await io.open(temp, 'w', 0o600)
|
|
62
|
+
await handle.writeFile(payload, 'utf8')
|
|
63
|
+
try {
|
|
64
|
+
await handle.sync()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
options.onSyncError?.(error)
|
|
67
|
+
}
|
|
68
|
+
await handle.close()
|
|
69
|
+
handle = null
|
|
70
|
+
await renameWithRetry(io, temp, file, options.renameAttempts)
|
|
71
|
+
renamed = true
|
|
72
|
+
} finally {
|
|
73
|
+
if (handle !== null) await handle.close().catch(() => {})
|
|
74
|
+
// Only debris is cleaned up: after a successful rename the temp file is gone.
|
|
75
|
+
if (!renamed) await io.unlink(temp).catch(() => {})
|
|
76
|
+
}
|
|
77
|
+
}
|
package/lib/capture.js
CHANGED
|
@@ -91,6 +91,11 @@ export function findDuplicate(input) {
|
|
|
91
91
|
if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
|
|
92
92
|
return { duplicate: true, reason: 'similar-active', matchId: todo.id }
|
|
93
93
|
}
|
|
94
|
+
} else if (now - doneAt < cooldownMs && similarity(title, todo.title) >= threshold) {
|
|
95
|
+
// The cooldown was exact-title only, so 「T3 逾期项」 finished two minutes ago and
|
|
96
|
+
// 「T3 逾期项(再办一次)」 was accepted — the same errand, re-registered. Similar
|
|
97
|
+
// wording counts here for the same reason it counts among active rows.
|
|
98
|
+
return { duplicate: true, reason: 'similar-recently-done', matchId: todo.id }
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
return { duplicate: false }
|
|
@@ -101,4 +106,5 @@ export const CAPTURE_REASONS = Object.freeze({
|
|
|
101
106
|
'same-active': '清单里已有同名的未完成条目,未重复登记',
|
|
102
107
|
'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
|
|
103
108
|
'recently-done': '这条刚完成不久(7 天内),没有重复登记',
|
|
109
|
+
'similar-recently-done': '这件事刚完成不久(7 天内),措辞不同也视为同一件,未重复登记',
|
|
104
110
|
})
|
package/lib/diagnostics.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* @module dsh-global-todo/diagnostics
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { appendFile, readFile,
|
|
12
|
+
import { appendFile, readFile, rm } from 'node:fs/promises'
|
|
13
|
+
|
|
14
|
+
import { atomicWrite } from './atomic.js'
|
|
13
15
|
|
|
14
16
|
/** How many entries `/health` shows, how many are kept in memory, how many on disk. */
|
|
15
17
|
const RECENT = 5
|
|
@@ -66,15 +68,13 @@ export function createDiagnostics(options) {
|
|
|
66
68
|
* @param {string[]} lines
|
|
67
69
|
*/
|
|
68
70
|
const rewrite = async (lines) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
throw error
|
|
77
|
-
}
|
|
71
|
+
rewrites += 1
|
|
72
|
+
// Same helper as the todo file: opened for writing (Windows cannot fsync a read-only
|
|
73
|
+
// handle), rename retried while an antivirus or a reader holds the file for a moment,
|
|
74
|
+
// temp file removed if the replace fails.
|
|
75
|
+
// A debug reader (a tail, an editor, this test) may hold the file almost continuously,
|
|
76
|
+
// so the trim gets a longer retry budget than a normal save.
|
|
77
|
+
await atomicWrite(options.file, `${lines.slice(-ON_DISK).join('\n')}\n`, { renameAttempts: 12 })
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
return {
|
package/lib/errors.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @module dsh-global-todo
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
23
24
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
24
25
|
import path from 'node:path'
|
|
25
26
|
|
|
@@ -36,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
36
37
|
import { createTools } from './tools.js'
|
|
37
38
|
|
|
38
39
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
39
|
-
export const VERSION = '0.12.
|
|
40
|
+
export const VERSION = '0.12.9'
|
|
40
41
|
|
|
41
42
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
43
|
export const name = 'dsh-global-todo'
|
|
@@ -525,6 +526,15 @@ export function apply(ctx, config) {
|
|
|
525
526
|
|
|
526
527
|
ctx.inject(['webServer'], (webCtx) => {
|
|
527
528
|
webCtx.effect(() => {
|
|
529
|
+
// The running module is the truth for behaviour; package.json is the truth for what
|
|
530
|
+
// was installed. When they differ the host is still executing the old graph, and
|
|
531
|
+
// nothing else in the system says so.
|
|
532
|
+
let diskVersion
|
|
533
|
+
try {
|
|
534
|
+
diskVersion = JSON.parse(readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf8')).version
|
|
535
|
+
} catch {
|
|
536
|
+
diskVersion = undefined
|
|
537
|
+
}
|
|
528
538
|
const routes = createRoutes({
|
|
529
539
|
service,
|
|
530
540
|
dispatcher,
|
|
@@ -545,6 +555,9 @@ export function apply(ctx, config) {
|
|
|
545
555
|
autoCollect: autoCollectState,
|
|
546
556
|
reminders: reminderState,
|
|
547
557
|
version: VERSION,
|
|
558
|
+
// What is on disk, which after an upgrade is not what this process is running.
|
|
559
|
+
// The panel talks to a tree that changed under it; /health is where that is visible.
|
|
560
|
+
diskVersion,
|
|
548
561
|
})
|
|
549
562
|
const disposers = routes.map((definition) => webCtx.webServer.register(definition))
|
|
550
563
|
webCtx.logger?.info?.(`dsh-global-todo: 注册了 ${disposers.length} 条路由(${ROUTE_BASE})`)
|
package/lib/ingest.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module dsh-global-todo/ingest
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { LIMITS } from './model.js'
|
|
12
13
|
import { INGEST_SOURCES } from './settings.js'
|
|
13
14
|
|
|
14
15
|
/** Tag that marks a record as waiting for a person. */
|
|
@@ -28,7 +29,9 @@ export function normalizeIngestItem(raw, options = {}) {
|
|
|
28
29
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { error: '条目必须是对象' }
|
|
29
30
|
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
|
|
30
31
|
if (title === '') return { error: '缺 title' }
|
|
31
|
-
|
|
32
|
+
// The model's own limit, not a second opinion: a 500-character title passed here and was
|
|
33
|
+
// then rejected by create(), which aborted the whole batch instead of skipping one row.
|
|
34
|
+
if (title.length > LIMITS.title) return { error: `title 超过 ${LIMITS.title} 字` }
|
|
32
35
|
const source = raw.source !== null && typeof raw.source === 'object' && !Array.isArray(raw.source) ? raw.source : {}
|
|
33
36
|
const kind = typeof source.kind === 'string' ? source.kind.trim().toLowerCase() : ''
|
|
34
37
|
if (!INGEST_SOURCES.includes(kind)) return { error: `source.kind 必须是 ${INGEST_SOURCES.join(' / ')}` }
|
package/lib/model.js
CHANGED
|
@@ -40,6 +40,23 @@ export const PRIORITY_LABELS = Object.freeze({
|
|
|
40
40
|
low: 'P3 低',
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The labels are also accepted on the way in.
|
|
45
|
+
*
|
|
46
|
+
* The panel and the tools show `P0`–`P3` everywhere, so a person (or a model reading the
|
|
47
|
+
* schema description) will eventually send `"P0"`; rejecting it with `invalid-priority`
|
|
48
|
+
* while the UI prints exactly that string is a trap, not a validation.
|
|
49
|
+
*/
|
|
50
|
+
export const PRIORITY_ALIASES = Object.freeze({ p0: 'urgent', p1: 'high', p2: 'normal', p3: 'low' })
|
|
51
|
+
|
|
52
|
+
/** @param {unknown} value @returns {string} a canonical priority, or '' when unusable */
|
|
53
|
+
function canonicalPriority(value) {
|
|
54
|
+
if (typeof value !== 'string') return ''
|
|
55
|
+
const key = value.trim().toLowerCase()
|
|
56
|
+
if (PRIORITIES.includes(key)) return key
|
|
57
|
+
return PRIORITY_ALIASES[key] ?? ''
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** Who performed a write. Recorded on every entry of the activity trail. */
|
|
44
61
|
export const ACTORS = Object.freeze(['human', 'agent'])
|
|
45
62
|
|
|
@@ -139,6 +156,21 @@ export function normalizeTags(value) {
|
|
|
139
156
|
* @param {string} fallback
|
|
140
157
|
* @returns {string}
|
|
141
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* Priority accepts the canonical names and the P0–P3 labels the panel prints.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} value
|
|
163
|
+
* @param {string} fallback
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function normalizePriorityField(value, fallback) {
|
|
167
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
168
|
+
const canonical = canonicalPriority(value)
|
|
169
|
+
if (canonical === '') {
|
|
170
|
+
throw new TodoError('invalid-priority', `优先级只能是 ${PRIORITIES.join(' / ')}(或 P0–P3 标签),收到 ${String(value)}`)
|
|
171
|
+
}
|
|
172
|
+
return canonical
|
|
173
|
+
}
|
|
142
174
|
function normalizeEnum(value, allowed, code, field, fallback) {
|
|
143
175
|
if (value === undefined || value === null || value === '') return fallback
|
|
144
176
|
if (typeof value !== 'string') throw new TodoError(code, `${field} 必须是字符串`)
|
|
@@ -204,7 +236,7 @@ export function createTodo(input, options = {}) {
|
|
|
204
236
|
title,
|
|
205
237
|
description: normalizeText(raw.description, '描述', LIMITS.description, 'invalid-description'),
|
|
206
238
|
status: normalizeEnum(raw.status, STATUSES, 'invalid-status', '状态', 'open'),
|
|
207
|
-
priority:
|
|
239
|
+
priority: normalizePriorityField(raw.priority, 'normal'),
|
|
208
240
|
dueAt: normalizeDue(raw.dueAt ?? raw.due),
|
|
209
241
|
tags: normalizeTags(raw.tags),
|
|
210
242
|
links,
|
|
@@ -563,7 +595,7 @@ export function applyPatch(todo, patch, options = {}) {
|
|
|
563
595
|
}
|
|
564
596
|
}
|
|
565
597
|
if (patch.priority !== undefined) {
|
|
566
|
-
const priority =
|
|
598
|
+
const priority = normalizePriorityField(patch.priority, todo.priority)
|
|
567
599
|
if (priority !== todo.priority) {
|
|
568
600
|
todo.priority = priority
|
|
569
601
|
changed.push('priority')
|
|
@@ -684,7 +716,12 @@ export function normalizeSteps(value) {
|
|
|
684
716
|
const out = []
|
|
685
717
|
for (const item of value) {
|
|
686
718
|
if (typeof item === 'string') {
|
|
687
|
-
const text = item.trim()
|
|
719
|
+
const text = item.trim()
|
|
720
|
+
// Over-long text used to be truncated silently while an over-long list was rejected;
|
|
721
|
+
// two limits, two behaviours, and the quiet one loses words. Both refuse now.
|
|
722
|
+
if (text.length > LIMITS.step) {
|
|
723
|
+
throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
|
|
724
|
+
}
|
|
688
725
|
// A blank line in a textarea is a typo, not a step.
|
|
689
726
|
if (text !== '') out.push({ text, done: false })
|
|
690
727
|
continue
|
|
@@ -692,7 +729,10 @@ export function normalizeSteps(value) {
|
|
|
692
729
|
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
693
730
|
throw new TodoError('invalid-steps', '步骤必须是字符串或 { text, done }')
|
|
694
731
|
}
|
|
695
|
-
const text = typeof item.text === 'string' ? item.text.trim()
|
|
732
|
+
const text = typeof item.text === 'string' ? item.text.trim() : ''
|
|
733
|
+
if (text.length > LIMITS.step) {
|
|
734
|
+
throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
|
|
735
|
+
}
|
|
696
736
|
if (text === '') continue
|
|
697
737
|
out.push({ text, done: item.done === true })
|
|
698
738
|
}
|
package/lib/routes.js
CHANGED
|
@@ -419,6 +419,11 @@ export function createRoutes(deps) {
|
|
|
419
419
|
return {
|
|
420
420
|
plugin: 'dsh-global-todo',
|
|
421
421
|
version,
|
|
422
|
+
// `version` is what this process runs; `packageVersion` is what is installed. After
|
|
423
|
+
// an upgrade the two differ until the host restarts, and the panel then calls routes
|
|
424
|
+
// the old graph does not have (a 404 that looks like a plugin bug).
|
|
425
|
+
packageVersion: deps.diskVersion ?? version,
|
|
426
|
+
stale: deps.diskVersion !== undefined && deps.diskVersion !== version,
|
|
422
427
|
store: await service.describe(),
|
|
423
428
|
counts,
|
|
424
429
|
// Purely additive, in-memory counters: hand-off success rate, agent tool
|
|
@@ -510,6 +515,11 @@ export function createRoutes(deps) {
|
|
|
510
515
|
|
|
511
516
|
exact('POST', `${ROUTE_BASE}/diagnostics`, async (request) => {
|
|
512
517
|
const body = await readJsonBody(request)
|
|
518
|
+
// `scope` is the grouping key in /health; without it every entry collapses into one
|
|
519
|
+
// nameless bucket, so it is required rather than defaulted.
|
|
520
|
+
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
|
|
521
|
+
throw new TodoError('invalid-body', 'diagnostics 需要 scope(如 health / list / dispatch)')
|
|
522
|
+
}
|
|
513
523
|
// The panel's failures happen in someone else's browser, where neither the Host
|
|
514
524
|
// nor the person wants to be reading consoles. Reporting them here is what makes
|
|
515
525
|
// "click it and tell me what it said" unnecessary.
|
|
@@ -519,6 +529,10 @@ export function createRoutes(deps) {
|
|
|
519
529
|
|
|
520
530
|
exact('POST', `${ROUTE_BASE}/settings`, async (request) => {
|
|
521
531
|
const body = await readJsonBody(request)
|
|
532
|
+
// The settings store clamps silently, which is right for a hand-edited file and wrong
|
|
533
|
+
// for an API: `{idleMinutes: -5}` answering 200-but-5 hides the rejected intent. A
|
|
534
|
+
// provided value outside its range is reported instead.
|
|
535
|
+
assertSettingsRange(body ?? {})
|
|
522
536
|
const next = await deps.settings.update(body ?? {})
|
|
523
537
|
return {
|
|
524
538
|
settings: {
|
|
@@ -605,6 +619,11 @@ export function createRoutes(deps) {
|
|
|
605
619
|
|
|
606
620
|
exact('POST', `${ROUTE_BASE}/note`, async (request) => {
|
|
607
621
|
const body = await readJsonBody(request)
|
|
622
|
+
// A note with no text records an empty entry in the activity trail: the README calls
|
|
623
|
+
// `text` required, so an empty one is a bad body, not a note.
|
|
624
|
+
if (typeof body.text !== 'string' || body.text.trim() === '') {
|
|
625
|
+
throw new TodoError('invalid-body', 'note 需要非空的 text')
|
|
626
|
+
}
|
|
608
627
|
const { todo, note } = await service.note(body.id, body.text, { by: 'human' })
|
|
609
628
|
return { todo, note }
|
|
610
629
|
}),
|
|
@@ -644,7 +663,18 @@ export function createRoutes(deps) {
|
|
|
644
663
|
// The panel's undo: put a removed record back verbatim, timestamps and
|
|
645
664
|
// attachments included, instead of re-creating it as a new todo.
|
|
646
665
|
const body = await readJsonBody(request)
|
|
647
|
-
|
|
666
|
+
// A partial object would be "restored" with invented defaults (a new createdAt, empty
|
|
667
|
+
// tags, a reassigned seq) — a lossy success is worse than a refusal.
|
|
668
|
+
const todo = body.todo
|
|
669
|
+
if (todo === null || typeof todo !== 'object' || Array.isArray(todo)) {
|
|
670
|
+
throw new TodoError('invalid-body', 'restore 需要完整的 todo 对象')
|
|
671
|
+
}
|
|
672
|
+
for (const field of ['id', 'title', 'createdAt', 'status']) {
|
|
673
|
+
if (todo[field] === undefined || todo[field] === null || todo[field] === '') {
|
|
674
|
+
throw new TodoError('invalid-body', `restore 的 todo 缺少 ${field};撤销要原样放回,不能补默认值`)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { todo: await service.restore(todo, { by: 'human' }) }
|
|
648
678
|
}),
|
|
649
679
|
|
|
650
680
|
exact('POST', `${ROUTE_BASE}/link`, async (request) => {
|
|
@@ -681,6 +711,10 @@ export function createRoutes(deps) {
|
|
|
681
711
|
|
|
682
712
|
exact('POST', `${ROUTE_BASE}/dispatch`, async (request) => {
|
|
683
713
|
const body = await readJsonBody(request)
|
|
714
|
+
// A missing id is a malformed request (400), not a record that does not exist (404).
|
|
715
|
+
if (typeof body.id !== 'string' || body.id.trim() === '') {
|
|
716
|
+
throw new TodoError('invalid-body', 'dispatch 需要 id')
|
|
717
|
+
}
|
|
684
718
|
return dispatcher.dispatch(body.id, {
|
|
685
719
|
mode: body.mode,
|
|
686
720
|
sessionId: body.sessionId,
|
|
@@ -698,4 +732,27 @@ export function createRoutes(deps) {
|
|
|
698
732
|
]
|
|
699
733
|
}
|
|
700
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Ranges the settings route refuses, mirroring the clamps in `normalizeSettings`.
|
|
737
|
+
*
|
|
738
|
+
* @param {Record<string, unknown>} body
|
|
739
|
+
*/
|
|
740
|
+
function assertSettingsRange(body) {
|
|
741
|
+
const ranges = {
|
|
742
|
+
idleMinutes: [5, 24 * 60],
|
|
743
|
+
cooldownMinutes: [5, 24 * 60],
|
|
744
|
+
ingestConfirmBelow: [0, 1],
|
|
745
|
+
reminderLeadMinutes: [0, 24 * 60],
|
|
746
|
+
}
|
|
747
|
+
for (const [field, [low, high]] of Object.entries(ranges)) {
|
|
748
|
+
const value = body[field]
|
|
749
|
+
if (value === undefined || value === null) continue
|
|
750
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
751
|
+
throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
|
|
752
|
+
}
|
|
753
|
+
if (value < low || value > high) {
|
|
754
|
+
throw new TodoError('invalid-body', `${field} 必须在 ${low}–${high} 之间,收到 ${value}`)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
701
758
|
export { sendJson, readJsonBody }
|
package/lib/service.js
CHANGED
|
@@ -775,7 +775,19 @@ export function createService(options) {
|
|
|
775
775
|
preview.push({ title: verdict.item.title, tags: verdict.item.tags, inbox: verdict.inbox })
|
|
776
776
|
continue
|
|
777
777
|
}
|
|
778
|
-
|
|
778
|
+
// One bad row must not take the batch down with it: `global_todo_capture` already
|
|
779
|
+
// isolates each create, and the HTTP door should behave the same way.
|
|
780
|
+
let todo
|
|
781
|
+
try {
|
|
782
|
+
todo = await create(verdict.item, { by: 'agent' })
|
|
783
|
+
} catch (error) {
|
|
784
|
+
skipped.push({
|
|
785
|
+
title: verdict.item.title.slice(0, 80),
|
|
786
|
+
reason: 'invalid',
|
|
787
|
+
error: error?.message ?? String(error),
|
|
788
|
+
})
|
|
789
|
+
continue
|
|
790
|
+
}
|
|
779
791
|
created.push(todo)
|
|
780
792
|
existing.push(todo)
|
|
781
793
|
}
|
package/lib/store.js
CHANGED
|
@@ -19,76 +19,18 @@
|
|
|
19
19
|
* @module dsh-global-todo/store
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Rename, retrying only the errors that mean "someone else has it for a moment".
|
|
26
|
-
*
|
|
27
|
-
* @param {{ rename: (from: string, to: string) => Promise<void> }} io
|
|
28
|
-
* @param {string} from temporary file
|
|
29
|
-
* @param {string} to target file
|
|
30
|
-
*/
|
|
31
|
-
async function renameWithRetry(io, from, to) {
|
|
32
|
-
const transient = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
33
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
34
|
-
try {
|
|
35
|
-
await io.rename(from, to)
|
|
36
|
-
return
|
|
37
|
-
} catch (error) {
|
|
38
|
-
if (attempt >= 4 || !transient.has(error?.code)) throw error
|
|
39
|
-
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)))
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Replace a file atomically, and survive a platform that refuses `fsync`.
|
|
45
|
-
*
|
|
46
|
-
* Opened for **writing** on purpose: on Windows `handle.sync()` is `FlushFileBuffers`,
|
|
47
|
-
* which needs a writable handle. Opening the temp file read-only and syncing it threw
|
|
48
|
-
* `EPERM` on every write, and because that sync sat before `rename()`, the target file
|
|
49
|
-
* was never replaced — the list looked empty while the data piled up as `*.tmp` debris.
|
|
50
|
-
* That was issue #2.
|
|
51
|
-
*
|
|
52
|
-
* The sync itself is best effort: durability matters, but losing a save because a
|
|
53
|
-
* platform will not flush a handle is strictly worse than a save that reaches the page
|
|
54
|
-
* cache. A failing sync is reported and the rename still happens.
|
|
55
|
-
*
|
|
56
|
-
* @param {string} file absolute path of the target
|
|
57
|
-
* @param {string} payload bytes to write
|
|
58
|
-
* @param {{ fs?: object, onSyncError?: (error: unknown) => void }} [options] injected for tests
|
|
59
|
-
*/
|
|
60
|
-
export async function atomicWrite(file, payload, options = {}) {
|
|
61
|
-
const io = options.fs ?? { open, rename, unlink }
|
|
62
|
-
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
63
|
-
let handle = null
|
|
64
|
-
let renamed = false
|
|
65
|
-
try {
|
|
66
|
-
handle = await io.open(temp, 'w', 0o600)
|
|
67
|
-
await handle.writeFile(payload, 'utf8')
|
|
68
|
-
try {
|
|
69
|
-
await handle.sync()
|
|
70
|
-
} catch (error) {
|
|
71
|
-
options.onSyncError?.(error)
|
|
72
|
-
}
|
|
73
|
-
await handle.close()
|
|
74
|
-
handle = null
|
|
75
|
-
// Windows antivirus and the search indexer take short-lived locks on files that were
|
|
76
|
-
// just written, which makes the rename fail with EPERM/EBUSY for a few milliseconds.
|
|
77
|
-
// A retry turns a spurious save failure into a save; other errors still surface at once.
|
|
78
|
-
await renameWithRetry(io, temp, file)
|
|
79
|
-
renamed = true
|
|
80
|
-
} finally {
|
|
81
|
-
if (handle !== null) await handle.close().catch(() => {})
|
|
82
|
-
// Only debris is cleaned up: after a successful rename the temp file is gone.
|
|
83
|
-
if (!renamed) await io.unlink(temp).catch(() => {})
|
|
84
|
-
}
|
|
85
|
-
}
|
|
22
|
+
import { mkdir, open, readFile, stat } from 'node:fs/promises'
|
|
86
23
|
import { homedir } from 'node:os'
|
|
87
24
|
import path from 'node:path'
|
|
88
25
|
|
|
26
|
+
import { atomicWrite } from './atomic.js'
|
|
89
27
|
import { TodoError } from './errors.js'
|
|
90
28
|
import { LIMITS, SCHEMA_VERSION, normalizeStored } from './model.js'
|
|
91
29
|
|
|
30
|
+
// Re-exported because the tests (and anyone poking at the store) reach for it here; the
|
|
31
|
+
// implementation lives in atomic.js so the diagnostics log can share it.
|
|
32
|
+
export { atomicWrite }
|
|
33
|
+
|
|
92
34
|
/**
|
|
93
35
|
* @param {Record<string, string | undefined>} [env]
|
|
94
36
|
* @returns {string} the DSH home directory
|
|
@@ -248,7 +190,13 @@ export function createStore(options) {
|
|
|
248
190
|
*/
|
|
249
191
|
async function write(doc) {
|
|
250
192
|
const directory = path.dirname(file)
|
|
251
|
-
|
|
193
|
+
try {
|
|
194
|
+
await mkdir(directory, { recursive: true })
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// A file where the directory should be reports ENOTDIR on POSIX and EEXIST on
|
|
197
|
+
// Windows; both mean the same thing to the caller, so both become store-unwritable.
|
|
198
|
+
throw new TodoError('store-unwritable', `建不了待办目录 ${directory}:${error?.message ?? error}`, { file })
|
|
199
|
+
}
|
|
252
200
|
const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
|
|
253
201
|
try {
|
|
254
202
|
await atomicWrite(file, payload, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
|
|
6
6
|
"type": "module",
|