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/README.md +72 -35
- package/client.js +114 -29
- package/index.js +105 -32
- package/lib/engine.js +380 -94
- package/lib/heartbeat.js +200 -50
- package/lib/probe.js +284 -116
- package/lib/snapshot.js +270 -83
- package/package.json +2 -2
package/lib/snapshot.js
CHANGED
|
@@ -1,133 +1,320 @@
|
|
|
1
|
-
import
|
|
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
|
|
6
|
-
*
|
|
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
|
|
9
|
-
* leaves the previous complete
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
96
|
+
fsv.rmSync(tmp, { force: true })
|
|
60
97
|
} catch {}
|
|
61
98
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
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
|
-
|
|
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(
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
const done = new Set()
|
|
220
|
+
function readJournal(planId) {
|
|
221
|
+
const rows = new Map()
|
|
88
222
|
let raw = ''
|
|
89
223
|
try {
|
|
90
|
-
raw = readFileSync(
|
|
224
|
+
raw = fsv.readFileSync(journalPath(planId), 'utf8')
|
|
91
225
|
} catch {
|
|
92
|
-
return
|
|
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')
|
|
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
|
|
237
|
+
return rows
|
|
102
238
|
}
|
|
103
239
|
|
|
104
|
-
function
|
|
105
|
-
if (!ensure()) return false
|
|
240
|
+
function clearJournal(planId) {
|
|
106
241
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} catch {
|
|
110
|
-
return false
|
|
111
|
-
}
|
|
242
|
+
fsv.rmSync(journalPath(planId), { force: true })
|
|
243
|
+
} catch {}
|
|
112
244
|
}
|
|
113
245
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
288
|
+
return fsv.existsSync(snapshotPath) === true
|
|
123
289
|
} catch {
|
|
124
|
-
return
|
|
290
|
+
return false
|
|
125
291
|
}
|
|
126
292
|
}
|
|
127
293
|
|
|
128
|
-
function
|
|
129
|
-
|
|
294
|
+
function hasPending() {
|
|
295
|
+
try {
|
|
296
|
+
return fsv.existsSync(pendingPath) === true
|
|
297
|
+
} catch {
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
130
300
|
}
|
|
131
301
|
|
|
132
|
-
return {
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "One header button stops
|
|
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",
|