dsh-global-todo 0.12.5 → 0.12.7
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 +37 -0
- package/docs/WINDOWS.md +54 -0
- package/lib/index.js +1 -1
- package/lib/markdown.js +4 -1
- package/lib/settings.js +15 -5
- package/lib/store.js +69 -10
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
## [0.12.7] — 2026-10-21
|
|
2
|
+
|
|
3
|
+
Windows, tested instead of assumed.
|
|
4
|
+
|
|
5
|
+
- **CI runs on windows-latest.** Issue #2 shipped for months because only Linux was ever
|
|
6
|
+
exercised; the suite now runs on both, plus `node tools/windows-check.mjs` on Windows.
|
|
7
|
+
- **`npm run check:windows`** — a real-filesystem smoke test (no faked `fs`): create a store,
|
|
8
|
+
update/complete/review then reload, assert no `.tmp` debris, write and read 500 todos, import a
|
|
9
|
+
CRLF Markdown file, use a path with spaces and non-ASCII characters, and rename over an existing
|
|
10
|
+
file. `docs/WINDOWS.md` says what to report when one of them fails.
|
|
11
|
+
- **Windows paths are exempt-able.** Settings compared exemptions with `startsWith('/')`, so every
|
|
12
|
+
workspace a Windows user configured was silently dropped. Now `isAbsolutePath` (drive and UNC),
|
|
13
|
+
with case-insensitive comparison on Windows and trailing separators of either kind removed.
|
|
14
|
+
- **CRLF Markdown imports.** `fromMarkdown` split on `\n` and left `\r` on every line; a document
|
|
15
|
+
saved on Windows now parses exactly like the same document saved on Linux.
|
|
16
|
+
- **Transient locks are retried.** Antivirus and the indexer can hold a just-written file for a few
|
|
17
|
+
milliseconds; `EPERM`/`EBUSY`/`EACCES` during the final rename are retried (bounded), other
|
|
18
|
+
errors still fail at once, and a real failure cleans up its temp file.
|
|
19
|
+
- **Settings writes are atomic too.** A half-written `settings.json` would silently reset every
|
|
20
|
+
switch; it now goes through the same temp-file-and-rename path as the todo list.
|
|
21
|
+
|
|
22
|
+
## [0.12.6] — 2026-10-21
|
|
23
|
+
|
|
24
|
+
Saving on Windows never worked. Fixed, with the root cause written down (#2).
|
|
25
|
+
|
|
26
|
+
- **`open(temp, 'r')` → opened for writing.** The atomic write reopened its temp file read-only
|
|
27
|
+
and called `handle.sync()`. On Windows that is `FlushFileBuffers`, which requires a writable
|
|
28
|
+
handle, so **every** save threw `EPERM` — and because the sync sat before `rename()`, the
|
|
29
|
+
target file was never replaced: the list looked empty while `*.tmp` debris accumulated. POSIX
|
|
30
|
+
allows `fsync` on a read-only descriptor, which is why Linux and macOS never showed it.
|
|
31
|
+
- **The sync is best effort now.** A platform that refuses to flush the temp file gets a warning,
|
|
32
|
+
and the rename still happens: losing a save is worse than a save that only reached the page
|
|
33
|
+
cache. The temp file is unlinked when a write fails, so a failure leaves no debris.
|
|
34
|
+
- **The write path is a function with tests.** `atomicWrite()` takes an injectable fs surface; four
|
|
35
|
+
tests pin the invariant that failed: the handle must be opened for writing, a refused sync must
|
|
36
|
+
not lose the save, a failed write must clean up, and the payload must reach the file first.
|
|
37
|
+
|
|
1
38
|
## [0.12.5] — 2026-10-21
|
|
2
39
|
|
|
3
40
|
A check for the one install mistake that is invisible until the desktop complains.
|
package/docs/WINDOWS.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Windows
|
|
2
|
+
|
|
3
|
+
Issue #2 was a Windows-only save failure that shipped for months because CI only ever ran Linux:
|
|
4
|
+
the atomic write reopened its temp file read-only and called `fsync`, which on Windows is
|
|
5
|
+
`FlushFileBuffers` and requires a writable handle. Every save threw `EPERM` **before** the rename,
|
|
6
|
+
so the list looked empty while the data piled up as `*.tmp` debris. Fixed in 0.12.6.
|
|
7
|
+
|
|
8
|
+
This file is the checklist that keeps that from happening again, and the script a tester on a real
|
|
9
|
+
Windows machine should run.
|
|
10
|
+
|
|
11
|
+
## What the test suite already covers on every platform
|
|
12
|
+
|
|
13
|
+
| Area | Where | What it pins |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| atomic write | `test/atomic-write.test.mjs` | the temp file is opened for **writing**; a refused `fsync` warns instead of losing the save; a failed write leaves no debris |
|
|
16
|
+
| rename under a lock | `test/windows.test.mjs` | `EPERM`/`EBUSY`/`EACCES` are retried (bounded), other errors fail at once, no temp file survives a real failure |
|
|
17
|
+
| workspace paths | `test/windows.test.mjs` | `C:\\dev\\demo`, `C:/dev/demo` and `\\\\server\\share` are accepted as exemptions; relative paths are not |
|
|
18
|
+
| CRLF markdown | `test/windows.test.mjs` | a document saved on Windows imports like the same document saved on Linux |
|
|
19
|
+
| install shape | `test/manifest.test.mjs` | `locale/` ships and the `./locale/*.json` export exists (the desktop metadata error) |
|
|
20
|
+
|
|
21
|
+
CI runs the suite on **ubuntu-latest and windows-latest** (node 22.19 on both, node 24 on Linux).
|
|
22
|
+
Windows runs `node tools/windows-check.mjs` as an extra step.
|
|
23
|
+
|
|
24
|
+
## The real-filesystem check
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
git clone https://github.com/LUgABeEEE/dsh-global-todo
|
|
28
|
+
cd dsh-global-todo
|
|
29
|
+
node tools/windows-check.mjs # or: npm run check:windows
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It uses `os.tmpdir()` and does seven things a faked `fs` cannot: create a store, update/complete/
|
|
33
|
+
review then reload, assert no `.tmp` debris, write and read back 500 todos, import a CRLF Markdown
|
|
34
|
+
file, write into a path with spaces and non-ASCII characters, and rename over an existing file. It
|
|
35
|
+
prints one line per check and exits non-zero on the first failure.
|
|
36
|
+
|
|
37
|
+
## What to report if something fails
|
|
38
|
+
|
|
39
|
+
1. the check name from the `FAIL` line and the full message;
|
|
40
|
+
2. `node -v`, `dsh --version`, Windows build (`winver`);
|
|
41
|
+
3. whether the data directory is on a network drive, OneDrive, or a synced folder — those change
|
|
42
|
+
`fsync`/`rename` semantics more than the OS version does;
|
|
43
|
+
4. the contents of `%USERPROFILE%\.dsh\global-todo\` (file names are enough; redact titles if you
|
|
44
|
+
prefer) — `*.tmp` files are evidence of a write that never renamed.
|
|
45
|
+
|
|
46
|
+
## Known Windows behaviours that are *not* bugs
|
|
47
|
+
|
|
48
|
+
- **A `*.tmp` file can briefly appear** while a write is in flight. After a successful save it is
|
|
49
|
+
gone; after a failed one it is removed by the writer.
|
|
50
|
+
- **Antivirus can hold a handle for a few milliseconds.** That is why the rename is retried rather
|
|
51
|
+
than reported on the first `EPERM`.
|
|
52
|
+
- **Paths longer than 260 characters** depend on the machine's `LongPathsEnabled` policy; the plugin
|
|
53
|
+
uses normal Node file APIs, so it inherits that limit rather than working around it.
|
|
54
|
+
- **Case**: workspace exemptions are compared case-insensitively on Windows only.
|
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
36
36
|
import { createTools } from './tools.js'
|
|
37
37
|
|
|
38
38
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
39
|
-
export const VERSION = '0.12.
|
|
39
|
+
export const VERSION = '0.12.7'
|
|
40
40
|
|
|
41
41
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
42
|
export const name = 'dsh-global-todo'
|
package/lib/markdown.js
CHANGED
|
@@ -79,7 +79,10 @@ export function toMarkdown(doc, options = {}) {
|
|
|
79
79
|
export function fromMarkdown(text) {
|
|
80
80
|
const todos = []
|
|
81
81
|
let current = null
|
|
82
|
-
|
|
82
|
+
// A file saved on Windows arrives with CRLF; split the same way everywhere instead of
|
|
83
|
+
// leaving a trailing \r on every line for the matchers to trip over.
|
|
84
|
+
const normalized = clean(text).replace(/\r\n?/g, '\n')
|
|
85
|
+
for (const raw of normalized.split('\n')) {
|
|
83
86
|
const line = raw.replace(/\s+$/, '')
|
|
84
87
|
const task = /^- \[([ xX])\] (.+)$/.exec(line)
|
|
85
88
|
if (task !== null) {
|
package/lib/settings.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* @module dsh-global-todo/settings
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { chmod, readFile
|
|
11
|
+
import { chmod, readFile } from 'node:fs/promises'
|
|
12
|
+
import { isAbsolutePath } from './links.js'
|
|
13
|
+
import { atomicWrite } from './store.js'
|
|
12
14
|
|
|
13
15
|
/** How many sessions the cooldown bookkeeping remembers. */
|
|
14
16
|
const ASKED_LIMIT = 50
|
|
@@ -91,13 +93,19 @@ export function normalizeSettings(raw) {
|
|
|
91
93
|
// Newest first, so the cap drops the oldest bookkeeping rather than the newest.
|
|
92
94
|
entries.sort((left, right) => right[1] - left[1])
|
|
93
95
|
for (const [sessionId, at] of entries.slice(0, ASKED_LIMIT)) asked[sessionId] = at
|
|
94
|
-
// Trailing
|
|
96
|
+
// Trailing separators come off so `/x/` and `/x` (or `C:\\x\\` and `C:\\x`) cannot both be
|
|
97
|
+
// listed and both match. Windows workspaces are drive or UNC paths, so the test is
|
|
98
|
+
// `isAbsolutePath`, not "starts with a slash" — the latter silently rejected every
|
|
99
|
+
// exempt workspace a Windows user configured.
|
|
95
100
|
const exemptWorkspaces = []
|
|
96
101
|
for (const entry of Array.isArray(source.exemptWorkspaces) ? source.exemptWorkspaces : []) {
|
|
97
102
|
if (typeof entry !== 'string') continue
|
|
98
|
-
const dir = entry.trim().replace(
|
|
103
|
+
const dir = entry.trim().replace(/[\\/]+$/, '')
|
|
99
104
|
// A relative path would match by accident; an exemption has to name a place.
|
|
100
|
-
if (dir === '' || !
|
|
105
|
+
if (dir === '' || !isAbsolutePath(dir)) continue
|
|
106
|
+
// Windows paths are case-insensitive, so `C:\\Dev` and `c:\\dev` are one place.
|
|
107
|
+
const key = process.platform === 'win32' ? dir.toLowerCase() : dir
|
|
108
|
+
if (exemptWorkspaces.some((listed) => (process.platform === 'win32' ? listed.toLowerCase() : listed) === key)) continue
|
|
101
109
|
if (exemptWorkspaces.length >= EXEMPT_LIMIT) break
|
|
102
110
|
exemptWorkspaces.push(dir)
|
|
103
111
|
}
|
|
@@ -161,7 +169,9 @@ export function createSettings(options) {
|
|
|
161
169
|
|
|
162
170
|
const persist = async () => {
|
|
163
171
|
try {
|
|
164
|
-
|
|
172
|
+
// Same atomic path as the todo file: a half-written settings file on Windows (the
|
|
173
|
+
// kind a crash or a locked rename leaves behind) would silently reset every switch.
|
|
174
|
+
await atomicWrite(options.file, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`)
|
|
165
175
|
// The file can hold an ingest token; keep it readable by the Host user only. Best-effort:
|
|
166
176
|
// a filesystem without POSIX modes must not fail the write.
|
|
167
177
|
await chmod(options.file, 0o600).catch(() => {})
|
package/lib/store.js
CHANGED
|
@@ -19,7 +19,70 @@
|
|
|
19
19
|
* @module dsh-global-todo/store
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { open, mkdir, readFile, rename, stat,
|
|
22
|
+
import { open, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises'
|
|
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
|
+
}
|
|
23
86
|
import { homedir } from 'node:os'
|
|
24
87
|
import path from 'node:path'
|
|
25
88
|
|
|
@@ -187,16 +250,12 @@ export function createStore(options) {
|
|
|
187
250
|
const directory = path.dirname(file)
|
|
188
251
|
await mkdir(directory, { recursive: true })
|
|
189
252
|
const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
|
|
190
|
-
const temp = `${file}.${process.pid}.${now()}.tmp`
|
|
191
253
|
try {
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
await handle.close()
|
|
198
|
-
}
|
|
199
|
-
await rename(temp, file)
|
|
254
|
+
await atomicWrite(file, payload, {
|
|
255
|
+
// A platform that will not flush the temp file is a warning, not a lost save.
|
|
256
|
+
onSyncError: (error) =>
|
|
257
|
+
logger?.warn?.(`dsh-global-todo: 落盘后 fsync 失败(已继续)${file}:${error?.message ?? error}`),
|
|
258
|
+
})
|
|
200
259
|
} catch (error) {
|
|
201
260
|
throw new TodoError('store-unwritable', `写入待办文件失败 ${file}:${error?.message ?? error}`, { file })
|
|
202
261
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.7",
|
|
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",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"docs/ROADMAP.md",
|
|
51
51
|
"docs/QUICKSTART.md",
|
|
52
52
|
"docs/FAQ.md",
|
|
53
|
+
"docs/WINDOWS.md",
|
|
53
54
|
"docs/icon.svg",
|
|
54
55
|
"docs/screenshots",
|
|
55
56
|
"tools/doctor.mjs",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"scripts": {
|
|
72
73
|
"check": "node scripts/check.mjs",
|
|
73
74
|
"doctor": "node tools/doctor.mjs",
|
|
75
|
+
"check:windows": "node tools/windows-check.mjs",
|
|
74
76
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
75
77
|
"verify": "npm run check && npm run check:render && npm test",
|
|
76
78
|
"preview": "node tools/preview.mjs",
|