dsh-pause-resume 0.1.0 → 0.3.0

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/lib/snapshot.js CHANGED
@@ -1,133 +1,320 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import * as fs from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
 
4
4
  /**
5
- * The one durable fact this plugin owns: a snapshot of the work a press stopped, plus a journal
6
- * of what the following restore already put back.
5
+ * The durable half of the pause button: the active plan, the crash plan a restart inherited, one
6
+ * restore journal, one archive per consumed plan, and the last restore record.
7
7
  *
8
- * Every write is temp-file + fsync + rename, so a reader never sees half a snapshot and a crash
9
- * leaves the previous complete one in place. Nothing here throws: a disk that cannot be written
10
- * is reported as `null`, and the caller decides to stay in its current phase instead of claiming
11
- * a state it could not record.
8
+ * Every write is temp file + fsync + rename + best-effort directory fsync, so a reader never sees
9
+ * half a plan and a crash leaves the previous complete file in place. A directory fsync that the
10
+ * platform refuses (EPERM/ENOTSUP on some network and sandboxed filesystems) does not undo a
11
+ * rename that already happened: the record is published, only its power-loss guarantee is weaker.
12
+ * Nothing here throws: a disk that cannot be written answers `false`/`null`, and the caller
13
+ * decides whether to stay in its current phase instead of claiming a state it could not record.
14
+ *
15
+ * Formats read: version 3 (this release), version 2 (0.2.x), version 1 (0.1.x). A version 1 plan is
16
+ * migrated conservatively: it has no turn baseline, so its sessions are mounted and never sent a
17
+ * continuation message, and its goal rows carry no revision, so they are reported, not resumed.
12
18
  */
13
- export function createStore(dir) {
14
- const file = join(dir, 'snapshot.json')
15
- const journal = join(dir, 'restore.jsonl')
16
- const consumed = join(dir, 'last-restore.json')
17
- let ready = false
18
-
19
- function ensure() {
20
- if (ready) return true
19
+
20
+ const UNSAFE = /[^A-Za-z0-9._-]/gu
21
+ const CURRENT_VERSION = 3
22
+ const DIR_SYNC_CODES = new Set(['EPERM', 'EACCES', 'ENOTSUP', 'EINVAL', 'ENOSYS', 'EBADF', 'EISDIR'])
23
+
24
+ let counter = 0
25
+
26
+ function sanitize(value) {
27
+ const name = String(value ?? '').replace(UNSAFE, '_')
28
+ return name === '' ? '_' : name
29
+ }
30
+
31
+ function closeQuietly(fsv, fd) {
32
+ try {
33
+ fsv.closeSync(fd)
34
+ } catch {}
35
+ }
36
+
37
+ export function createStore(dir, options = {}) {
38
+ const fsv = options.fs ?? fs
39
+ const now = typeof options.now === 'function' ? options.now : Date.now
40
+ const log = typeof options.log === 'function' ? options.log : () => {}
41
+
42
+ const snapshotPath = join(dir, 'snapshot.json')
43
+ const pendingPath = join(dir, 'pending.json')
44
+ const journalDir = join(dir, 'journal')
45
+ const restoresDir = join(dir, 'restores')
46
+ const lastRestorePath = join(dir, 'last-restore.json')
47
+
48
+ const journalPath = (planId) => join(journalDir, `${sanitize(planId)}.jsonl`)
49
+
50
+ /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
51
+ function syncDir(path) {
52
+ if (process.platform === 'win32') return // ui-copy-ignore
53
+ let fd = null
21
54
  try {
22
- mkdirSync(dir, { recursive: true })
23
- ready = true
24
- return true
25
- } catch {
26
- return false
55
+ fd = fsv.openSync(path, 'r')
56
+ fsv.fsyncSync(fd)
57
+ } catch (error) {
58
+ const code = error?.code
59
+ if (DIR_SYNC_CODES.has(code) === false) log(`cannot flush ${path}: ${error?.message ?? error}`)
60
+ } finally {
61
+ if (fd !== null) closeQuietly(fsv, fd)
27
62
  }
28
63
  }
29
64
 
30
- function read() {
65
+ /** `published` is the truth: the rename landed, so readers see the new record. */
66
+ function atomicWrite(target, value) {
67
+ let body = null
31
68
  try {
32
- const raw = readFileSync(file, 'utf8')
33
- const parsed = JSON.parse(raw)
34
- if (parsed === null || typeof parsed !== 'object') return null
35
- if (parsed.version !== 1) return null
36
- if (parsed.status !== 'paused' && parsed.status !== 'pausing') return null
37
- return parsed
38
- } catch {
39
- return null
69
+ body = JSON.stringify(value)
70
+ } catch (error) {
71
+ log(`cannot serialize ${target}: ${error?.message ?? error}`)
72
+ return false
40
73
  }
41
- }
42
-
43
- function write(value) {
44
- if (!ensure()) return null
45
- const body = JSON.stringify({ ...value, version: 1 })
46
- const tmp = `${file}.${process.pid}.tmp`
74
+ const tmp = `${target}.${process.pid}.${counter++}.tmp`
47
75
  let fd = null
76
+ let renamed = false
48
77
  try {
49
- fd = openSync(tmp, 'w')
50
- writeSync(fd, body)
51
- fsyncSync(fd)
52
- closeSync(fd)
78
+ fsv.mkdirSync(dirname(target), { recursive: true })
79
+ fd = fsv.openSync(tmp, 'w')
80
+ fsv.writeSync(fd, body)
81
+ fsv.fsyncSync(fd)
82
+ const opened = fd
53
83
  fd = null
54
- renameSync(tmp, file)
55
- return value
56
- } catch {
57
- if (fd !== null) {
84
+ fsv.closeSync(opened)
85
+ fsv.renameSync(tmp, target)
86
+ renamed = true
87
+ syncDir(dirname(target))
88
+ return true
89
+ } catch (error) {
90
+ log(`cannot write ${target}: ${error?.message ?? error}`)
91
+ return false
92
+ } finally {
93
+ if (fd !== null) closeQuietly(fsv, fd)
94
+ if (renamed === false) {
58
95
  try {
59
- closeSync(fd)
96
+ fsv.rmSync(tmp, { force: true })
60
97
  } catch {}
61
98
  }
62
- try {
63
- rmSync(tmp, { force: true })
64
- } catch {}
99
+ }
100
+ }
101
+
102
+ function parse(path) {
103
+ let parsed = null
104
+ try {
105
+ parsed = JSON.parse(fsv.readFileSync(path, 'utf8'))
106
+ } catch {
65
107
  return null
66
108
  }
109
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
110
+ return parsed
111
+ }
112
+
113
+ /**
114
+ * A version 1 plan predates the turn baseline and the goal revision. Mounting is safe; claiming
115
+ * that a turn was interrupted is not, and resuming a goal without its revision is not either.
116
+ */
117
+ function migrateLegacy(legacy, stamp) {
118
+ const sessions = (Array.isArray(legacy.sessions) ? legacy.sessions : [])
119
+ .filter((row) => typeof row?.sessionId === 'string')
120
+ .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null }))
121
+ const goals = (Array.isArray(legacy.goals) ? legacy.goals : [])
122
+ .filter((row) => typeof row?.sessionId === 'string')
123
+ .map((row) => ({ sessionId: row.sessionId, goalId: typeof row.goalId === 'string' ? row.goalId : null, revision: null }))
124
+ const jobs = (Array.isArray(legacy.jobs) ? legacy.jobs : []).map((row) => ({ id: String(row?.id ?? ''), owner: row?.owner ?? null }))
125
+ return {
126
+ planId: `legacy-${stamp}`, // ui-copy-ignore
127
+ status: 'paused', // ui-copy-ignore
128
+ source: 'legacy', // ui-copy-ignore
129
+ legacy: true, // ui-copy-ignore
130
+ createdAt: legacy.createdAt ?? stamp,
131
+ sessions,
132
+ goals,
133
+ jobs,
134
+ attention: ['legacy plan: sessions are mounted only; goals need a new pause'],
135
+ }
67
136
  }
68
137
 
69
- /** The snapshot is spent: the same press must not restore the same work twice. */
70
- function consume(summary) {
71
- if (!ensure()) return null
138
+ function normalize(parsed, stamp = now()) {
139
+ if (parsed === null) return null
140
+ if (parsed.version === 1) return migrateLegacy(parsed, typeof parsed.createdAt === 'number' ? parsed.createdAt : stamp)
141
+ if (parsed.version !== 2 && parsed.version !== CURRENT_VERSION) return null
142
+ if (parsed.status !== 'pausing' && parsed.status !== 'paused' && parsed.status !== 'partial') return null
143
+ const plan = { ...parsed, version: CURRENT_VERSION }
144
+ if (typeof plan.planId !== 'string' || plan.planId === '') plan.planId = `plan-${plan.createdAt ?? stamp}`
145
+ if (Array.isArray(plan.attention) === false) plan.attention = []
146
+ if (plan.status === 'pausing') plan.interrupted = true
147
+ return plan
148
+ }
149
+
150
+ function readFrom(path) {
151
+ const parsed = parse(path)
152
+ if (parsed === null) return null
153
+ // A version 1 plan has no plan id; the file stamp keeps one journal per legacy press.
154
+ let stamp = now()
72
155
  try {
73
- const current = read()
74
- const record = current === null ? { status: 'restored' } : { ...current, status: 'restored', restoredAt: new Date().toISOString(), summary }
75
- const tmp = `${consumed}.${process.pid}.tmp`
76
- writeSync(openSync(tmp, 'w'), JSON.stringify(record))
77
- renameSync(tmp, consumed)
156
+ stamp = Math.trunc(fsv.statSync(path).mtimeMs)
78
157
  } catch {}
158
+ return normalize(parsed, stamp)
159
+ }
160
+
161
+ function read() {
162
+ return readFrom(snapshotPath)
163
+ }
164
+
165
+ function write(plan) {
166
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
167
+ const record = { ...plan, version: CURRENT_VERSION }
168
+ return atomicWrite(snapshotPath, record) ? record : null
169
+ }
170
+
171
+ /** The crash plan survives this process: it is written before the heartbeat overwrites its file. */
172
+ function readPending() {
173
+ return readFrom(pendingPath)
174
+ }
175
+
176
+ function writePending(plan) {
177
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
178
+ const record = { ...plan, version: CURRENT_VERSION }
179
+ return atomicWrite(pendingPath, record) ? record : null
180
+ }
181
+
182
+ function clearPending() {
79
183
  try {
80
- rmSync(file, { force: true })
81
- } catch {}
82
- return summary
184
+ fsv.rmSync(pendingPath, { force: true })
185
+ syncDir(dir)
186
+ return true
187
+ } catch (error) {
188
+ log(`cannot clear pending plan: ${error?.message ?? error}`)
189
+ return false
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Journals carry the boot that wrote each row. A row from an earlier process is evidence for the
195
+ * summary, never permission to skip work: the only things a new process can trust are the live
196
+ * Harness and the plan itself.
197
+ */
198
+ function appendJournal(planId, key, status, bootId) {
199
+ let line = null
200
+ try {
201
+ line = JSON.stringify({ k: key, s: status ?? 'ok', b: bootId ?? null, at: now() })
202
+ } catch {
203
+ return false
204
+ }
205
+ let fd = null
206
+ try {
207
+ fsv.mkdirSync(journalDir, { recursive: true })
208
+ fd = fsv.openSync(journalPath(planId), 'a')
209
+ fsv.writeSync(fd, `${line}\n`)
210
+ fsv.fsyncSync(fd)
211
+ return true
212
+ } catch (error) {
213
+ log(`cannot journal ${planId}: ${error?.message ?? error}`)
214
+ return false
215
+ } finally {
216
+ if (fd !== null) closeQuietly(fsv, fd)
217
+ }
83
218
  }
84
219
 
85
- /** Journals are append-only lines of `{"k":"s:<id>"}`; a torn last line is dropped on read. */
86
- function readJournal() {
87
- const done = new Set()
220
+ function readJournal(planId) {
221
+ const rows = new Map()
88
222
  let raw = ''
89
223
  try {
90
- raw = readFileSync(journal, 'utf8')
224
+ raw = fsv.readFileSync(journalPath(planId), 'utf8')
91
225
  } catch {
92
- return done
226
+ return rows
93
227
  }
94
228
  for (const line of raw.split('\n')) {
95
229
  if (line === '') continue
96
230
  try {
97
231
  const row = JSON.parse(line)
98
- if (typeof row?.k === 'string') done.add(row.k)
99
- } catch {}
232
+ if (typeof row?.k === 'string') rows.set(row.k, { status: typeof row.s === 'string' ? row.s : 'ok', boot: typeof row.b === 'string' ? row.b : null })
233
+ } catch {
234
+ // A torn last line is a write that never finished, not a key that was restored.
235
+ }
100
236
  }
101
- return done
237
+ return rows
102
238
  }
103
239
 
104
- function appendJournal(key) {
105
- if (!ensure()) return false
240
+ function clearJournal(planId) {
106
241
  try {
107
- writeSync(openSync(journal, 'a'), `${JSON.stringify({ k: key })}\n`)
108
- return true
109
- } catch {
110
- return false
111
- }
242
+ fsv.rmSync(journalPath(planId), { force: true })
243
+ } catch {}
112
244
  }
113
245
 
114
- function clearJournal() {
115
- try {
116
- rmSync(journal, { force: true })
117
- } catch {}
246
+ function writeResult(record) {
247
+ return atomicWrite(lastRestorePath, record)
248
+ }
249
+
250
+ /**
251
+ * The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
252
+ * that could not be archived stays on disk, so the same work is never claimed twice. A snapshot
253
+ * that could not be removed is reported as a failure even though the archive landed: the next
254
+ * reader would otherwise see a plan that is already restored and try to restore it again.
255
+ */
256
+ function consume(plan, summary) {
257
+ const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
258
+ const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
259
+ const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
260
+ const archived = atomicWrite(archivePath, record)
261
+ const published = archived && writeResult(record)
262
+ if (published === false) {
263
+ log('cannot archive the restored plan: the snapshot stays')
264
+ return { ok: false, archivePath: null, published: false }
265
+ }
266
+ const current = read()
267
+ // Another press may have written a newer plan while this one restored; that one is not ours.
268
+ if (current === null || current.planId === source.planId) {
269
+ try {
270
+ fsv.rmSync(snapshotPath, { force: true })
271
+ syncDir(dir)
272
+ } catch (error) {
273
+ log(`cannot clear the restored plan: ${error?.message ?? error}`)
274
+ return { ok: false, archivePath, published: true }
275
+ }
276
+ }
277
+ clearJournal(source.planId)
278
+ return { ok: true, archivePath, published: true }
118
279
  }
119
280
 
120
281
  function lastRestore() {
282
+ const parsed = parse(lastRestorePath)
283
+ return parsed
284
+ }
285
+
286
+ function hasSnapshot() {
121
287
  try {
122
- return JSON.parse(readFileSync(consumed, 'utf8'))
288
+ return fsv.existsSync(snapshotPath) === true
123
289
  } catch {
124
- return null
290
+ return false
125
291
  }
126
292
  }
127
293
 
128
- function hasSnapshot() {
129
- return existsSync(file)
294
+ function hasPending() {
295
+ try {
296
+ return fsv.existsSync(pendingPath) === true
297
+ } catch {
298
+ return false
299
+ }
130
300
  }
131
301
 
132
- return { dir, file, read, write, consume, readJournal, appendJournal, clearJournal, lastRestore, hasSnapshot }
302
+ return {
303
+ dir,
304
+ snapshotPath,
305
+ pendingPath,
306
+ read,
307
+ write,
308
+ readPending,
309
+ writePending,
310
+ clearPending,
311
+ appendJournal,
312
+ readJournal,
313
+ clearJournal,
314
+ writeResult,
315
+ consume,
316
+ lastRestore,
317
+ hasSnapshot,
318
+ hasPending,
319
+ }
133
320
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "One header button stops every running turn and goal, writes a snapshot, and puts the same work back on the next boot; the button's shape is the only status.",
5
+ "description": "One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./index.js",