dsh-global-todo 0.12.5 → 0.12.6

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 CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.12.6] — 2026-10-21
2
+
3
+ Saving on Windows never worked. Fixed, with the root cause written down (#2).
4
+
5
+ - **`open(temp, 'r')` → opened for writing.** The atomic write reopened its temp file read-only
6
+ and called `handle.sync()`. On Windows that is `FlushFileBuffers`, which requires a writable
7
+ handle, so **every** save threw `EPERM` — and because the sync sat before `rename()`, the
8
+ target file was never replaced: the list looked empty while `*.tmp` debris accumulated. POSIX
9
+ allows `fsync` on a read-only descriptor, which is why Linux and macOS never showed it.
10
+ - **The sync is best effort now.** A platform that refuses to flush the temp file gets a warning,
11
+ and the rename still happens: losing a save is worse than a save that only reached the page
12
+ cache. The temp file is unlinked when a write fails, so a failure leaves no debris.
13
+ - **The write path is a function with tests.** `atomicWrite()` takes an injectable fs surface; four
14
+ tests pin the invariant that failed: the handle must be opened for writing, a refused sync must
15
+ not lose the save, a failed write must clean up, and the payload must reach the file first.
16
+
1
17
  ## [0.12.5] — 2026-10-21
2
18
 
3
19
  A check for the one install mistake that is invisible until the desktop complains.
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.5'
39
+ export const VERSION = '0.12.6'
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,7 +19,48 @@
19
19
  * @module dsh-global-todo/store
20
20
  */
21
21
 
22
- import { open, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
22
+ import { open, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises'
23
+
24
+ /**
25
+ * Replace a file atomically, and survive a platform that refuses `fsync`.
26
+ *
27
+ * Opened for **writing** on purpose: on Windows `handle.sync()` is `FlushFileBuffers`,
28
+ * which needs a writable handle. Opening the temp file read-only and syncing it threw
29
+ * `EPERM` on every write, and because that sync sat before `rename()`, the target file
30
+ * was never replaced — the list looked empty while the data piled up as `*.tmp` debris.
31
+ * That was issue #2.
32
+ *
33
+ * The sync itself is best effort: durability matters, but losing a save because a
34
+ * platform will not flush a handle is strictly worse than a save that reaches the page
35
+ * cache. A failing sync is reported and the rename still happens.
36
+ *
37
+ * @param {string} file absolute path of the target
38
+ * @param {string} payload bytes to write
39
+ * @param {{ fs?: object, onSyncError?: (error: unknown) => void }} [options] injected for tests
40
+ */
41
+ export async function atomicWrite(file, payload, options = {}) {
42
+ const io = options.fs ?? { open, rename, unlink }
43
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
44
+ let handle = null
45
+ let renamed = false
46
+ try {
47
+ handle = await io.open(temp, 'w', 0o600)
48
+ await handle.writeFile(payload, 'utf8')
49
+ try {
50
+ await handle.sync()
51
+ } catch (error) {
52
+ options.onSyncError?.(error)
53
+ }
54
+ await handle.close()
55
+ handle = null
56
+ await io.rename(temp, file)
57
+ renamed = true
58
+ } finally {
59
+ if (handle !== null) await handle.close().catch(() => {})
60
+ // Only debris is cleaned up: after a successful rename the temp file is gone.
61
+ if (!renamed) await io.unlink(temp).catch(() => {})
62
+ }
63
+ }
23
64
  import { homedir } from 'node:os'
24
65
  import path from 'node:path'
25
66
 
@@ -187,16 +228,12 @@ export function createStore(options) {
187
228
  const directory = path.dirname(file)
188
229
  await mkdir(directory, { recursive: true })
189
230
  const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
190
- const temp = `${file}.${process.pid}.${now()}.tmp`
191
231
  try {
192
- await writeFile(temp, payload, { mode: 0o600 })
193
- const handle = await open(temp, 'r')
194
- try {
195
- await handle.sync()
196
- } finally {
197
- await handle.close()
198
- }
199
- await rename(temp, file)
232
+ await atomicWrite(file, payload, {
233
+ // A platform that will not flush the temp file is a warning, not a lost save.
234
+ onSyncError: (error) =>
235
+ logger?.warn?.(`dsh-global-todo: 落盘后 fsync 失败(已继续)${file}:${error?.message ?? error}`),
236
+ })
200
237
  } catch (error) {
201
238
  throw new TodoError('store-unwritable', `写入待办文件失败 ${file}:${error?.message ?? error}`, { file })
202
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.5",
3
+ "version": "0.12.6",
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",