dsh-global-todo 0.12.7 → 0.12.8
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 +18 -0
- package/docs/WINDOWS.md +5 -0
- package/lib/atomic.js +77 -0
- package/lib/diagnostics.js +10 -10
- package/lib/index.js +1 -1
- package/lib/store.js +13 -65
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [0.12.8] — 2026-10-21
|
|
2
|
+
|
|
3
|
+
The Windows runner kept finding things after 0.12.7. Four more, all real.
|
|
4
|
+
|
|
5
|
+
- **`mkdir` over a file is `EEXIST` on Windows**, not `ENOTDIR`. The store translated only the
|
|
6
|
+
POSIX code, so an unwritable location could surface as a raw error; both now become
|
|
7
|
+
`store-unwritable`.
|
|
8
|
+
- **The diagnostics trim could lose its rename** while a reader held the log. The atomic write is
|
|
9
|
+
now shared (`lib/atomic.js`) by the store, settings and diagnostics, with a longer retry budget
|
|
10
|
+
for a file that is read constantly.
|
|
11
|
+
- **`tools/preview.mjs` created a directory named `panel.html`** on Windows: it stripped the file
|
|
12
|
+
name with a SQL-style regex on `/`, which matched nothing there. `path.dirname` instead — this is
|
|
13
|
+
what made `npm pack --dry-run` fail on the Windows runner.
|
|
14
|
+
- **Two test assertions were POSIX literals** (a default store path and a `path.resolve` result).
|
|
15
|
+
They now compare resolved paths, which is what the contract actually says.
|
|
16
|
+
- **CI is green on `windows-latest`** — tests, static checks, package shape and
|
|
17
|
+
`node tools/windows-check.mjs` all run there.
|
|
18
|
+
|
|
1
19
|
## [0.12.7] — 2026-10-21
|
|
2
20
|
|
|
3
21
|
Windows, tested instead of assumed.
|
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/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/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.8'
|
|
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/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.8",
|
|
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",
|